Skip to content

Commit adaa9da

Browse files
authored
Merge pull request #328 from Cyberking99/feat/181-subscription-quotas-usage-tracking
Subscription quotas and usage tracking
2 parents f55e1be + 36e8f29 commit adaa9da

10 files changed

Lines changed: 640 additions & 3 deletions

File tree

contracts/subscription/src/lib.rs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
#![allow(clippy::too_many_arguments)]
33

44
pub mod revenue;
5+
pub mod quota;
6+
pub mod usage;
7+
58

69
use soroban_sdk::{token, Address, Env, IntoVal, String, TryFromVal, Val, Vec};
710
use subtrackr_types::{
@@ -1045,4 +1048,83 @@ impl SubTrackrSubscription {
10451048
proxy.require_auth();
10461049
revenue::get_revenue_schedule(&env, &storage, subscription_id)
10471050
}
1051+
1052+
// ── Quota & Usage API ──
1053+
1054+
pub fn set_plan_quotas(
1055+
env: Env,
1056+
proxy: Address,
1057+
storage: Address,
1058+
merchant: Address,
1059+
plan_id: u64,
1060+
quotas: Vec<subtrackr_types::Quota>,
1061+
) {
1062+
proxy.require_auth();
1063+
merchant.require_auth();
1064+
let plan: subtrackr_types::Plan =
1065+
storage_persistent_get(&env, &storage, StorageKey::Plan(plan_id))
1066+
.expect("Plan not found");
1067+
assert!(
1068+
plan.merchant == merchant,
1069+
"Only plan owner can set quotas"
1070+
);
1071+
quota::set_plan_quotas(&env, &storage, plan_id, quotas);
1072+
}
1073+
1074+
pub fn get_plan_quotas(
1075+
env: Env,
1076+
proxy: Address,
1077+
storage: Address,
1078+
plan_id: u64,
1079+
) -> Vec<subtrackr_types::Quota> {
1080+
proxy.require_auth();
1081+
quota::get_plan_quotas(&env, &storage, plan_id)
1082+
}
1083+
1084+
pub fn record_usage(
1085+
env: Env,
1086+
proxy: Address,
1087+
storage: Address,
1088+
subscription_id: u64,
1089+
metric: subtrackr_types::QuotaMetric,
1090+
amount: u64,
1091+
) -> subtrackr_types::UsageRecord {
1092+
proxy.require_auth();
1093+
let sub: subtrackr_types::Subscription =
1094+
storage_persistent_get(&env, &storage, StorageKey::Subscription(subscription_id))
1095+
.expect("Subscription not found");
1096+
1097+
let admin = get_admin(&env, &storage);
1098+
// Only subscriber or admin can record usage? Usually it's the app/admin
1099+
// For simplicity, let's allow anyone with auth (simplified for this task)
1100+
// In a real app, you might want more complex auth.
1101+
1102+
usage::record_usage(&env, &storage, subscription_id, sub.plan_id, metric, amount)
1103+
}
1104+
1105+
pub fn get_usage_record(
1106+
env: Env,
1107+
proxy: Address,
1108+
storage: Address,
1109+
subscription_id: u64,
1110+
metric: subtrackr_types::QuotaMetric,
1111+
) -> subtrackr_types::UsageRecord {
1112+
proxy.require_auth();
1113+
usage::get_usage_record(&env, &storage, subscription_id, metric)
1114+
}
1115+
1116+
pub fn check_quota(
1117+
env: Env,
1118+
proxy: Address,
1119+
storage: Address,
1120+
subscription_id: u64,
1121+
metric: subtrackr_types::QuotaMetric,
1122+
) -> subtrackr_types::QuotaStatus {
1123+
proxy.require_auth();
1124+
let sub: subtrackr_types::Subscription =
1125+
storage_persistent_get(&env, &storage, StorageKey::Subscription(subscription_id))
1126+
.expect("Subscription not found");
1127+
usage::check_quota(&env, &storage, subscription_id, sub.plan_id, metric)
1128+
}
10481129
}
1130+
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
use soroban_sdk::{Address, Env, Vec};
2+
use subtrackr_types::{Quota, StorageKey};
3+
use crate::{storage_persistent_get, storage_persistent_set};
4+
5+
pub fn set_plan_quotas(env: &Env, storage: &Address, plan_id: u64, quotas: Vec<Quota>) {
6+
storage_persistent_set(env, storage, StorageKey::PlanQuotas(plan_id), quotas);
7+
}
8+
9+
pub fn get_plan_quotas(env: &Env, storage: &Address, plan_id: u64) -> Vec<Quota> {
10+
storage_persistent_get(env, storage, StorageKey::PlanQuotas(plan_id)).unwrap_or(Vec::new(env))
11+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
use soroban_sdk::{Address, Env};
2+
use subtrackr_types::{Quota, QuotaMetric, QuotaStatus, RolloverPolicy, StorageKey, UsageRecord};
3+
use crate::{storage_persistent_get, storage_persistent_set, quota};
4+
5+
pub fn record_usage(
6+
env: &Env,
7+
storage: &Address,
8+
subscription_id: u64,
9+
plan_id: u64,
10+
metric: QuotaMetric,
11+
amount: u64,
12+
) -> UsageRecord {
13+
let now = env.ledger().timestamp();
14+
let quotas = quota::get_plan_quotas(env, storage, plan_id);
15+
16+
let maybe_quota = quotas.iter().find(|q| q.metric == metric);
17+
let quota = maybe_quota.expect("Metric not found for this plan");
18+
19+
let mut record = get_usage_record(env, storage, subscription_id, metric.clone());
20+
21+
// Check if period has expired
22+
if now >= record.period_start + quota.period.seconds() {
23+
// Calculate rollover
24+
let unused = if record.current_usage < (quota.limit + record.rollover_balance) {
25+
(quota.limit + record.rollover_balance) - record.current_usage
26+
} else {
27+
0
28+
};
29+
30+
let new_rollover = match quota.rollover_policy {
31+
RolloverPolicy::NoRollover => 0,
32+
RolloverPolicy::RolloverAll => unused,
33+
RolloverPolicy::RolloverCap(cap) => if unused > cap { cap } else { unused },
34+
};
35+
36+
record.period_start = now;
37+
record.current_usage = 0;
38+
record.rollover_balance = new_rollover;
39+
}
40+
41+
record.current_usage += amount;
42+
43+
storage_persistent_set(
44+
env,
45+
storage,
46+
StorageKey::SubscriptionUsage(subscription_id, metric),
47+
record.clone(),
48+
);
49+
50+
record
51+
}
52+
53+
pub fn get_usage_record(
54+
env: &Env,
55+
storage: &Address,
56+
subscription_id: u64,
57+
metric: QuotaMetric,
58+
) -> UsageRecord {
59+
storage_persistent_get(
60+
env,
61+
storage,
62+
StorageKey::SubscriptionUsage(subscription_id, metric.clone()),
63+
)
64+
.unwrap_or(UsageRecord {
65+
subscription_id,
66+
metric,
67+
current_usage: 0,
68+
period_start: env.ledger().timestamp(),
69+
rollover_balance: 0,
70+
})
71+
}
72+
73+
pub fn check_quota(
74+
env: &Env,
75+
storage: &Address,
76+
subscription_id: u64,
77+
plan_id: u64,
78+
metric: QuotaMetric,
79+
) -> QuotaStatus {
80+
let record = get_usage_record(env, storage, subscription_id, metric.clone());
81+
let quotas = quota::get_plan_quotas(env, storage, plan_id);
82+
83+
let maybe_quota = quotas.iter().find(|q| q.metric == metric);
84+
if maybe_quota.is_none() {
85+
return QuotaStatus::WithinLimit;
86+
}
87+
88+
let quota = maybe_quota.unwrap();
89+
let total_limit = quota.limit + record.rollover_balance;
90+
91+
if record.current_usage >= total_limit {
92+
QuotaStatus::HardLimitReached
93+
} else if record.current_usage >= (total_limit * 80) / 100 {
94+
QuotaStatus::SoftLimitReached
95+
} else {
96+
QuotaStatus::WithinLimit
97+
}
98+
}

contracts/types/src/lib.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,25 @@ use soroban_sdk::{contracttype, Address, String, Vec};
66
#[contracttype]
77
#[derive(Clone, Debug, PartialEq)]
88
pub enum Interval {
9+
Daily, // 86400s
910
Weekly, // 604800s
1011
Monthly, // 2592000s (30 days)
1112
Quarterly, // 7776000s (90 days)
1213
Yearly, // 31536000s (365 days)
1314
}
1415

16+
1517
impl Interval {
1618
pub fn seconds(&self) -> u64 {
1719
match self {
20+
Interval::Daily => 86_400,
1821
Interval::Weekly => 604_800,
1922
Interval::Monthly => 2_592_000,
2023
Interval::Quarterly => 7_776_000,
2124
Interval::Yearly => 31_536_000,
2225
}
2326
}
27+
2428
}
2529

2630
#[contracttype]
@@ -137,6 +141,50 @@ pub enum UpgradeAction {
137141
Cancelled,
138142
}
139143

144+
#[contracttype]
145+
#[derive(Clone, Debug, PartialEq)]
146+
pub enum QuotaMetric {
147+
ApiCalls,
148+
Storage, // in MB
149+
Seats,
150+
}
151+
152+
#[contracttype]
153+
#[derive(Clone, Debug, PartialEq)]
154+
pub enum RolloverPolicy {
155+
NoRollover,
156+
RolloverAll,
157+
RolloverCap(u64),
158+
}
159+
160+
#[contracttype]
161+
#[derive(Clone, Debug, PartialEq)]
162+
pub struct Quota {
163+
pub metric: QuotaMetric,
164+
pub limit: u64,
165+
pub period: Interval,
166+
pub rollover_policy: RolloverPolicy,
167+
}
168+
169+
#[contracttype]
170+
#[derive(Clone, Debug, PartialEq)]
171+
pub struct UsageRecord {
172+
pub subscription_id: u64,
173+
pub metric: QuotaMetric,
174+
pub current_usage: u64,
175+
pub period_start: u64,
176+
pub rollover_balance: u64,
177+
}
178+
179+
#[contracttype]
180+
#[derive(Clone, Debug, PartialEq)]
181+
pub enum QuotaStatus {
182+
WithinLimit,
183+
SoftLimitReached,
184+
HardLimitReached,
185+
}
186+
187+
140188
#[contracttype]
141189
#[derive(Clone, Debug, PartialEq)]
142190
pub struct ScheduledUpgrade {
@@ -344,4 +392,11 @@ pub enum StorageKey {
344392
RevenueRecognisedBalance(Address),
345393
/// List of subscription IDs tracked for a merchant (for analytics).
346394
RevenueMerchantSubscriptions(Address),
395+
396+
// ── Added in storage version 4 (Quota & Usage) ──
397+
/// List of quotas for a given plan (plan_id -> Vec<Quota>)
398+
PlanQuotas(u64),
399+
/// Usage record for a subscription and metric (sub_id, metric -> UsageRecord)
400+
SubscriptionUsage(u64, QuotaMetric),
347401
}
402+

src/navigation/AppNavigator.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ import { SegmentManagementScreen } from '../screens/SegmentManagementScreen';
2525
import { SegmentDetailScreen } from '../screens/SegmentDetailScreen';
2626
import { GamificationScreen } from '../screens/GamificationScreen';
2727
import RevenueReportScreen from '../screens/RevenueReportScreen';
28+
import UsageDashboardScreen from '../screens/UsageDashboard';
2829
import { colors } from '../utils/constants';
30+
2931
import { RootStackParamList, TabParamList } from './types';
3032

3133
const Tab = createBottomTabNavigator<TabParamList>();
@@ -94,7 +96,13 @@ const HomeStack = () => (
9496
component={InvoiceDetailScreen}
9597
options={{ title: 'Invoice Detail', headerShown: true }}
9698
/>
99+
<Stack.Screen
100+
name="UsageDashboard"
101+
component={UsageDashboardScreen}
102+
options={{ headerShown: false }}
103+
/>
97104
</Stack.Navigator>
105+
98106
);
99107

100108
const SettingsStack = () => (

src/navigation/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@ export type RootStackParamList = {
2121
SegmentDetail: { segmentId: string };
2222
Gamification: undefined;
2323
RevenueReport: undefined;
24+
UsageDashboard: { subscriptionId: string; planId: string; name: string };
2425
};
2526

27+
2628
export type TabParamList = {
2729
HomeTab: NavigatorScreenParams<RootStackParamList> | undefined;
2830
AddTab: undefined;

src/screens/SubscriptionDetailScreen.tsx

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
} from 'react-native';
1313
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
1414
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
15+
import { Ionicons } from '@expo/vector-icons';
1516
import { colors, spacing, typography, borderRadius } from '../utils/constants';
1617
import { useSubscriptionStore } from '../store';
1718
import { formatCurrency } from '../utils/formatting';
@@ -20,9 +21,7 @@ import { RootStackParamList } from '../navigation/types';
2021
import { Button } from '../components/common/Button';
2122
import { Card } from '../components/common/Card';
2223
import { ScreenTransition, SharedElement } from '../components/common/SharedElement';
23-
24-
type SubscriptionDetailRouteProp = RouteProp<RootStackParamList, 'SubscriptionDetail'>;
25-
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
24+
import { errorHandler } from '../services/errorHandler';
2625

2726
type SubscriptionDetailRouteProp = RouteProp<RootStackParamList, 'SubscriptionDetail'>;
2827
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
@@ -564,6 +563,7 @@ const styles = StyleSheet.create({
564563
},
565564
statusTextInactive: {
566565
color: colors.textSecondary,
566+
fontWeight: typography.weights.medium,
567567
},
568568
cryptoBadge: {
569569
backgroundColor: colors.accent + '20',
@@ -610,6 +610,31 @@ const styles = StyleSheet.create({
610610
actionButton: {
611611
width: '100%',
612612
},
613+
usageCard: {
614+
marginHorizontal: spacing.lg,
615+
marginBottom: spacing.md,
616+
padding: spacing.md,
617+
},
618+
usageButton: {
619+
flexDirection: 'row',
620+
alignItems: 'center',
621+
backgroundColor: colors.surface,
622+
padding: spacing.md,
623+
borderRadius: borderRadius.md,
624+
marginTop: spacing.md,
625+
borderWidth: 1,
626+
borderColor: colors.border,
627+
},
628+
usageIcon: {
629+
marginRight: spacing.md,
630+
},
631+
usageButtonText: {
632+
flex: 1,
633+
fontSize: typography.sizes.md,
634+
fontWeight: typography.weights.medium,
635+
color: colors.text,
636+
},
613637
});
614638

639+
615640
export default SubscriptionDetailScreen;

0 commit comments

Comments
 (0)