Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 28 additions & 4 deletions backend/services/billing/usageBillingCloseCron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
import { MeterUsageBreakdown, QuotaMetric } from '../../../src/types/usage';
import { MeteringService, meteringService } from './meteringService';
import { TieredPricingCalculator } from './tieredPricingCalculator';
import { useInvoiceStore } from '../../../src/store/invoiceStore';
import { useSubscriptionStore } from '../../../src/store/subscriptionStore';
import { InvoiceStatus } from '../../../src/types/invoice';
import { buildBillingPeriod } from '../../../src/utils/invoice';

export interface UsageBillingCloseEntry {
userId: string;
Expand Down Expand Up @@ -46,7 +50,7 @@ export class UsageBillingCloseCron {

start(): void {
if (this.intervalHandle) return;
this.intervalHandle = setInterval(() => this.runOnce(), this.intervalMs);
this.intervalHandle = setInterval(() => { this.runOnce().catch(console.error); }, this.intervalMs);
if (this.intervalHandle.unref) this.intervalHandle.unref();
}

Expand All @@ -58,13 +62,15 @@ export class UsageBillingCloseCron {
}

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

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

entries.push({
userId: account.userId,
Expand All @@ -73,12 +79,30 @@ export class UsageBillingCloseCron {
metric: account.metric,
unitsUsed,
includedUnits,
billableUnits: Math.max(0, unitsUsed - includedUnits),
amount: priced.totalAmount,
billableUnits,
amount,
},
});

this.service.resetPeriod(account.userId, account.metricType);

if (amount > 0) {
const sub = useSubscriptionStore.getState().subscriptions.find(s => s.id === account.userId);
if (sub) {
try {
const period = buildBillingPeriod(sub);
const invoiceStore = useInvoiceStore.getState();
const invoice = await invoiceStore.generateInvoiceFromSubscription({
subscription: sub,
period,
notes: `Usage overage for ${account.metricType} (${billableUnits} billable units)`
});
await invoiceStore.updateInvoiceStatus(invoice.id, InvoiceStatus.DRAFT);
} catch (err) {
console.error('Failed to generate usage invoice', err);
}
}
}
}

return { closedAt: new Date().toISOString(), entries };
Expand Down
41 changes: 40 additions & 1 deletion src/screens/UsageDashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import React, { useEffect, useMemo } from 'react';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, SafeAreaView } from 'react-native';
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
SafeAreaView,
Share,
} from 'react-native';
import { useAppRoute, useAppNavigation } from '../navigation/types';
import { colors, spacing, typography, borderRadius, shadows } from '../utils/constants';
import { useUsageStore } from '../store/usageStore';
Expand Down Expand Up @@ -33,6 +41,20 @@ const UsageDashboard: React.FC = () => {
const softAlerts = consumption.filter((c) => c.status === QuotaStatus.SOFT_LIMIT_REACHED);
const hardAlerts = consumption.filter((c) => c.status === QuotaStatus.HARD_LIMIT_REACHED);

const exportAsJson = () => {
const data = JSON.stringify(consumption, null, 2);
Share.share({ message: data, title: 'Usage Export' }).catch(() => {});
};

const exportAsCsv = () => {
if (consumption.length === 0) return;
const header = 'Metric,Current,Limit,Status,Percentage\n';
const rows = consumption
.map((c) => `${c.metric},${c.current},${c.limit},${c.status},${c.percentage}%`)
.join('\n');
Share.share({ message: header + rows, title: 'Usage Export CSV' }).catch(() => {});
};

const renderUsageCard = (
metric: QuotaMetric,
current: number,
Expand Down Expand Up @@ -131,6 +153,11 @@ const UsageDashboard: React.FC = () => {
)}

<Button title="Upgrade Plan" onPress={() => {}} style={styles.upgradeButton} />

<View style={styles.exportContainer}>
<Button title="Export JSON" onPress={exportAsJson} style={styles.exportButton} />
<Button title="Export CSV" onPress={exportAsCsv} style={styles.exportButton} />
</View>
</ScrollView>
</SafeAreaView>
);
Expand Down Expand Up @@ -240,6 +267,18 @@ const styles = StyleSheet.create({
upgradeButton: {
marginTop: spacing.xl,
},
exportContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
marginTop: spacing.md,
},
exportButton: {
flex: 1,
marginHorizontal: spacing.xs,
backgroundColor: colors.surface,
borderColor: colors.border,
borderWidth: 1,
},
});

export default UsageDashboard;
27 changes: 27 additions & 0 deletions src/store/usageStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,33 @@ export const useUsageStore = create<UsageState>()(
isLoading: false,
};
});

// Evaluate thresholds and trigger auto-upgrade if hard limit reached
const newStatus = get().getQuotaStatus(subscriptionId, metric);
if (newStatus === QuotaStatus.HARD_LIMIT_REACHED) {
const planId = get().subscriptionPlans[subscriptionId] || 'free';
const UPGRADE_PATH: Record<string, string> = { free: 'pro', pro: 'enterprise' };
const nextPlanId = UPGRADE_PATH[planId];

if (nextPlanId) {
import('./subscriptionStore').then(({ useSubscriptionStore }) => {
const newPrice = nextPlanId === 'enterprise' ? 99 : 29;
useSubscriptionStore
.getState()
.executePlanChange(
subscriptionId,
{ price: newPrice, name: nextPlanId },
'immediate'
)
.catch(console.error);

set((state) => ({
subscriptionPlans: { ...state.subscriptionPlans, [subscriptionId]: nextPlanId },
}));
get().fetchUsage(subscriptionId, nextPlanId);
});
}
}
} catch (error) {
const appError = errorHandler.handleError(error as Error, {
action: 'recordUsage',
Expand Down