Skip to content

Commit f9c1af2

Browse files
agenes01agenes01
andauthored
feat: implement usage-based billing with metered pricing and tiered overages (fixes #707) (#792)
Co-authored-by: agenes01 <agenes01@example.com>
1 parent 588a5a7 commit f9c1af2

3 files changed

Lines changed: 95 additions & 5 deletions

File tree

backend/services/billing/usageBillingCloseCron.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
import { MeterUsageBreakdown, QuotaMetric } from '../../../src/types/usage';
1212
import { MeteringService, meteringService } from './meteringService';
1313
import { TieredPricingCalculator } from './tieredPricingCalculator';
14+
import { useInvoiceStore } from '../../../src/store/invoiceStore';
15+
import { useSubscriptionStore } from '../../../src/store/subscriptionStore';
16+
import { InvoiceStatus } from '../../../src/types/invoice';
17+
import { buildBillingPeriod } from '../../../src/utils/invoice';
1418

1519
export interface UsageBillingCloseEntry {
1620
userId: string;
@@ -46,7 +50,7 @@ export class UsageBillingCloseCron {
4650

4751
start(): void {
4852
if (this.intervalHandle) return;
49-
this.intervalHandle = setInterval(() => this.runOnce(), this.intervalMs);
53+
this.intervalHandle = setInterval(() => { this.runOnce().catch(console.error); }, this.intervalMs);
5054
if (this.intervalHandle.unref) this.intervalHandle.unref();
5155
}
5256

@@ -58,13 +62,15 @@ export class UsageBillingCloseCron {
5862
}
5963

6064
/** Closes the current period for every registered account and resets it. */
61-
runOnce(): UsageBillingCloseReport {
65+
async runOnce(): Promise<UsageBillingCloseReport> {
6266
const entries: UsageBillingCloseEntry[] = [];
6367

6468
for (const account of this.accounts) {
6569
const unitsUsed = this.service.getCurrentPeriodConsumption(account.userId, account.metricType);
6670
const priced = account.calculator.calculate(unitsUsed);
6771
const includedUnits = priced.lines.find((l) => l.tier.unitPrice === 0)?.unitsInTier ?? 0;
72+
const billableUnits = Math.max(0, unitsUsed - includedUnits);
73+
const amount = priced.totalAmount;
6874

6975
entries.push({
7076
userId: account.userId,
@@ -73,12 +79,30 @@ export class UsageBillingCloseCron {
7379
metric: account.metric,
7480
unitsUsed,
7581
includedUnits,
76-
billableUnits: Math.max(0, unitsUsed - includedUnits),
77-
amount: priced.totalAmount,
82+
billableUnits,
83+
amount,
7884
},
7985
});
8086

8187
this.service.resetPeriod(account.userId, account.metricType);
88+
89+
if (amount > 0) {
90+
const sub = useSubscriptionStore.getState().subscriptions.find(s => s.id === account.userId);
91+
if (sub) {
92+
try {
93+
const period = buildBillingPeriod(sub);
94+
const invoiceStore = useInvoiceStore.getState();
95+
const invoice = await invoiceStore.generateInvoiceFromSubscription({
96+
subscription: sub,
97+
period,
98+
notes: `Usage overage for ${account.metricType} (${billableUnits} billable units)`
99+
});
100+
await invoiceStore.updateInvoiceStatus(invoice.id, InvoiceStatus.DRAFT);
101+
} catch (err) {
102+
console.error('Failed to generate usage invoice', err);
103+
}
104+
}
105+
}
82106
}
83107

84108
return { closedAt: new Date().toISOString(), entries };

src/screens/UsageDashboard.tsx

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
import React, { useEffect, useMemo } from 'react';
2-
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, SafeAreaView } from 'react-native';
2+
import {
3+
View,
4+
Text,
5+
StyleSheet,
6+
ScrollView,
7+
TouchableOpacity,
8+
SafeAreaView,
9+
Share,
10+
} from 'react-native';
311
import { useAppRoute, useAppNavigation } from '../navigation/types';
412
import { colors, spacing, typography, borderRadius, shadows } from '../utils/constants';
513
import { useUsageStore } from '../store/usageStore';
@@ -33,6 +41,20 @@ const UsageDashboard: React.FC = () => {
3341
const softAlerts = consumption.filter((c) => c.status === QuotaStatus.SOFT_LIMIT_REACHED);
3442
const hardAlerts = consumption.filter((c) => c.status === QuotaStatus.HARD_LIMIT_REACHED);
3543

44+
const exportAsJson = () => {
45+
const data = JSON.stringify(consumption, null, 2);
46+
Share.share({ message: data, title: 'Usage Export' }).catch(() => {});
47+
};
48+
49+
const exportAsCsv = () => {
50+
if (consumption.length === 0) return;
51+
const header = 'Metric,Current,Limit,Status,Percentage\n';
52+
const rows = consumption
53+
.map((c) => `${c.metric},${c.current},${c.limit},${c.status},${c.percentage}%`)
54+
.join('\n');
55+
Share.share({ message: header + rows, title: 'Usage Export CSV' }).catch(() => {});
56+
};
57+
3658
const renderUsageCard = (
3759
metric: QuotaMetric,
3860
current: number,
@@ -131,6 +153,11 @@ const UsageDashboard: React.FC = () => {
131153
)}
132154

133155
<Button title="Upgrade Plan" onPress={() => {}} style={styles.upgradeButton} />
156+
157+
<View style={styles.exportContainer}>
158+
<Button title="Export JSON" onPress={exportAsJson} style={styles.exportButton} />
159+
<Button title="Export CSV" onPress={exportAsCsv} style={styles.exportButton} />
160+
</View>
134161
</ScrollView>
135162
</SafeAreaView>
136163
);
@@ -240,6 +267,18 @@ const styles = StyleSheet.create({
240267
upgradeButton: {
241268
marginTop: spacing.xl,
242269
},
270+
exportContainer: {
271+
flexDirection: 'row',
272+
justifyContent: 'space-between',
273+
marginTop: spacing.md,
274+
},
275+
exportButton: {
276+
flex: 1,
277+
marginHorizontal: spacing.xs,
278+
backgroundColor: colors.surface,
279+
borderColor: colors.border,
280+
borderWidth: 1,
281+
},
243282
});
244283

245284
export default UsageDashboard;

src/store/usageStore.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,33 @@ export const useUsageStore = create<UsageState>()(
9797
isLoading: false,
9898
};
9999
});
100+
101+
// Evaluate thresholds and trigger auto-upgrade if hard limit reached
102+
const newStatus = get().getQuotaStatus(subscriptionId, metric);
103+
if (newStatus === QuotaStatus.HARD_LIMIT_REACHED) {
104+
const planId = get().subscriptionPlans[subscriptionId] || 'free';
105+
const UPGRADE_PATH: Record<string, string> = { free: 'pro', pro: 'enterprise' };
106+
const nextPlanId = UPGRADE_PATH[planId];
107+
108+
if (nextPlanId) {
109+
import('./subscriptionStore').then(({ useSubscriptionStore }) => {
110+
const newPrice = nextPlanId === 'enterprise' ? 99 : 29;
111+
useSubscriptionStore
112+
.getState()
113+
.executePlanChange(
114+
subscriptionId,
115+
{ price: newPrice, name: nextPlanId },
116+
'immediate'
117+
)
118+
.catch(console.error);
119+
120+
set((state) => ({
121+
subscriptionPlans: { ...state.subscriptionPlans, [subscriptionId]: nextPlanId },
122+
}));
123+
get().fetchUsage(subscriptionId, nextPlanId);
124+
});
125+
}
126+
}
100127
} catch (error) {
101128
const appError = errorHandler.handleError(error as Error, {
102129
action: 'recordUsage',

0 commit comments

Comments
 (0)