diff --git a/app/stores/meteringStore.ts b/app/stores/meteringStore.ts index a7dc5445..7a79801a 100644 --- a/app/stores/meteringStore.ts +++ b/app/stores/meteringStore.ts @@ -52,6 +52,33 @@ export interface TimeRange { end: number; } +export interface UsageAlertEntry { + id: string; + subscriptionId: string; + metric: string; + threshold: number; + currentUsage: number; + message: string; + timestamp: number; + acknowledged: boolean; +} + +export interface UsageTrend { + metric: string; + currentPeriod: number; + previousPeriod: number; + changePercent: number; + trend: 'increasing' | 'decreasing' | 'stable'; +} + +export interface UsageAnalytics { + totalUsage: number; + usageByMetric: Record; + usageBySubscription: Record; + trends: UsageTrend[]; + alertsCount: number; +} + export const DEFAULT_PERIOD_SECS = 86_400; const MAX_BUCKETS = 90; @@ -66,7 +93,8 @@ type MeterMap = Record; interface MeteringStoreState { meters: Record; // subscriptionId -> metric -> state now: () => number; - alerts: { subscriptionId: string; metric: string; total: number }[]; + alerts: UsageAlertEntry[]; + usageHistory: { subscriptionId: string; metric: string; value: number; timestamp: number }[]; registerMeter: ( subscriptionId: string, @@ -77,6 +105,11 @@ interface MeteringStoreState { calculateUsageCharge: (subscriptionId: string, period: TimeRange) => Charge; getMeters: (subscriptionId: string) => MeterState[]; getUsageTotal: (subscriptionId: string, metric: string) => number; + getUsageHistory: (subscriptionId: string, metric?: string) => { subscriptionId: string; metric: string; value: number; timestamp: number }[]; + getUsageTrends: (subscriptionId: string) => UsageTrend[]; + acknowledgeAlert: (alertId: string) => void; + getActiveAlerts: (subscriptionId?: string) => UsageAlertEntry[]; + getAnalytics: (subscriptionId?: string) => UsageAnalytics; } const newMeter = (metric: string, periodSecs: number): MeterState => ({ @@ -115,6 +148,7 @@ export const useMeteringStore = create()((set, get) => { meters: {}, now: () => Math.floor(Date.now() / 1000), alerts: [], + usageHistory: [], registerMeter: (sub, metric, config) => { const map = { ...metersFor(sub) }; @@ -140,9 +174,23 @@ export const useMeteringStore = create()((set, get) => { state.total += value; state.lastTimestamp = now; addToBucket(state, now, value); + + const historyEntry = { subscriptionId: sub, metric, value, timestamp: now }; + set((s) => ({ usageHistory: [...s.usageHistory, historyEntry] })); + if (state.alertThreshold !== 0 && !state.alertFired && state.total >= state.alertThreshold) { state.alertFired = true; - set((s) => ({ alerts: [...s.alerts, { subscriptionId: sub, metric, total: state.total }] })); + const alertEntry: UsageAlertEntry = { + id: `alert_${now.toString(36)}_${Math.random().toString(36).slice(2, 8)}`, + subscriptionId: sub, + metric, + threshold: state.alertThreshold, + currentUsage: state.total, + message: `Usage for ${metric} has reached ${Math.round((state.total / state.alertThreshold) * 100)}% of limit`, + timestamp: now, + acknowledged: false, + }; + set((s) => ({ alerts: [...s.alerts, alertEntry] })); } map[metric] = state; commit(sub, map); @@ -169,5 +217,103 @@ export const useMeteringStore = create()((set, get) => { getMeters: (sub) => Object.values(metersFor(sub)), getUsageTotal: (sub, metric) => metersFor(sub)[metric]?.total ?? 0, + + getUsageHistory: (sub, metric) => { + let history = get().usageHistory.filter((h) => h.subscriptionId === sub); + if (metric) { + history = history.filter((h) => h.metric === metric); + } + return history; + }, + + getUsageTrends: (sub) => { + const now = Date.now() / 1000; + const thirtyDaysAgo = now - 30 * 86400; + const sixtyDaysAgo = now - 60 * 86400; + + const currentHistory = get().usageHistory.filter( + (h) => h.subscriptionId === sub && h.timestamp >= thirtyDaysAgo + ); + const previousHistory = get().usageHistory.filter( + (h) => h.subscriptionId === sub && h.timestamp >= sixtyDaysAgo && h.timestamp < thirtyDaysAgo + ); + + const currentByMetric: Record = {}; + const previousByMetric: Record = {}; + + for (const entry of currentHistory) { + currentByMetric[entry.metric] = (currentByMetric[entry.metric] || 0) + entry.value; + } + for (const entry of previousHistory) { + previousByMetric[entry.metric] = (previousByMetric[entry.metric] || 0) + entry.value; + } + + const allMetrics = new Set([...Object.keys(currentByMetric), ...Object.keys(previousByMetric)]); + const trends: UsageTrend[] = []; + + for (const metric of allMetrics) { + const current = currentByMetric[metric] || 0; + const previous = previousByMetric[metric] || 0; + const changePercent = previous > 0 ? ((current - previous) / previous) * 100 : 0; + + let trend: 'increasing' | 'decreasing' | 'stable'; + if (changePercent > 5) trend = 'increasing'; + else if (changePercent < -5) trend = 'decreasing'; + else trend = 'stable'; + + trends.push({ + metric, + currentPeriod: current, + previousPeriod: previous, + changePercent: Math.round(changePercent * 100) / 100, + trend, + }); + } + + return trends; + }, + + acknowledgeAlert: (alertId) => { + set((s) => ({ + alerts: s.alerts.map((a) => (a.id === alertId ? { ...a, acknowledged: true } : a)), + })); + }, + + getActiveAlerts: (subscriptionId) => { + let alerts = get().alerts.filter((a) => !a.acknowledged); + if (subscriptionId) { + alerts = alerts.filter((a) => a.subscriptionId === subscriptionId); + } + return alerts; + }, + + getAnalytics: (subscriptionId) => { + const history = subscriptionId + ? get().usageHistory.filter((h) => h.subscriptionId === subscriptionId) + : get().usageHistory; + + const totalUsage = history.reduce((sum, h) => sum + h.value, 0); + + const usageByMetric: Record = {}; + const usageBySubscription: Record = {}; + for (const entry of history) { + usageByMetric[entry.metric] = (usageByMetric[entry.metric] || 0) + entry.value; + usageBySubscription[entry.subscriptionId] = + (usageBySubscription[entry.subscriptionId] || 0) + entry.value; + } + + const trends = subscriptionId ? get().getUsageTrends(subscriptionId) : []; + const activeAlerts = subscriptionId + ? get().getActiveAlerts(subscriptionId) + : get().getActiveAlerts(); + + return { + totalUsage, + usageByMetric, + usageBySubscription, + trends, + alertsCount: activeAlerts.length, + }; + }, }; }); diff --git a/backend/services/billing/dunningService.ts b/backend/services/billing/dunningService.ts index 45f91a20..00e42585 100644 --- a/backend/services/billing/dunningService.ts +++ b/backend/services/billing/dunningService.ts @@ -10,16 +10,65 @@ import type { import { DEFAULT_DUNNING_STAGES, DUNNING_TEMPLATES } from '../../../src/types/dunning'; const ONE_HOUR_MS = 3_600_000; +const ONE_DAY_MS = 86_400_000; const now = (): number => Date.now(); const createId = (prefix: string): string => `${prefix}_${now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; +export type FailureType = + | 'insufficient_funds' + | 'card_declined' + | 'expired_card' + | 'network_error' + | 'processing_error' + | 'auth_required' + | 'unknown'; + +export interface RetryScheduleConfig { + failureType: FailureType; + baseDelayHours: number; + maxRetries: number; + backoffMultiplier: number; + maxDelayHours: number; +} + +export interface RetryAnalytics { + totalRetries: number; + successfulRetries: number; + failedRetries: number; + retryRate: number; + successRate: number; + averageRetriesBeforeSuccess: number; + retriesByFailureType: Record; + retriesByStage: Record; + averageTimeToRecovery: number; +} + +const DEFAULT_RETRY_SCHEDULES: RetryScheduleConfig[] = [ + { failureType: 'insufficient_funds', baseDelayHours: 1, maxRetries: 5, backoffMultiplier: 2, maxDelayHours: 48 }, + { failureType: 'card_declined', baseDelayHours: 2, maxRetries: 3, backoffMultiplier: 3, maxDelayHours: 72 }, + { failureType: 'expired_card', baseDelayHours: 24, maxRetries: 2, backoffMultiplier: 1, maxDelayHours: 24 }, + { failureType: 'network_error', baseDelayHours: 0.5, maxRetries: 6, backoffMultiplier: 1.5, maxDelayHours: 12 }, + { failureType: 'processing_error', baseDelayHours: 1, maxRetries: 4, backoffMultiplier: 2, maxDelayHours: 24 }, + { failureType: 'auth_required', baseDelayHours: 0.25, maxRetries: 3, backoffMultiplier: 1, maxDelayHours: 1 }, + { failureType: 'unknown', baseDelayHours: 1, maxRetries: 3, backoffMultiplier: 2, maxDelayHours: 24 }, +]; + export class DunningService { private entries = new Map(); private configurations = new Map(); private communicationLog = new Map(); + private retrySchedules: RetryScheduleConfig[] = [...DEFAULT_RETRY_SCHEDULES]; + private retryHistory: Array<{ + subscriptionId: string; + failureType: FailureType; + attempt: number; + success: boolean; + timestamp: number; + delayHours: number; + }> = []; configurePlan(planId: string, config: Partial): DunningConfiguration { const existing = this.configurations.get(planId); @@ -41,6 +90,39 @@ export class DunningService { return this.configurations.get(planId); } + configureRetrySchedule(schedule: Partial & { failureType: FailureType }): void { + const existingIdx = this.retrySchedules.findIndex((s) => s.failureType === schedule.failureType); + const existing = existingIdx >= 0 ? this.retrySchedules[existingIdx] : undefined; + + const merged: RetryScheduleConfig = { + failureType: schedule.failureType, + baseDelayHours: schedule.baseDelayHours ?? existing?.baseDelayHours ?? 1, + maxRetries: schedule.maxRetries ?? existing?.maxRetries ?? 3, + backoffMultiplier: schedule.backoffMultiplier ?? existing?.backoffMultiplier ?? 2, + maxDelayHours: schedule.maxDelayHours ?? existing?.maxDelayHours ?? 24, + }; + + if (existingIdx >= 0) { + this.retrySchedules[existingIdx] = merged; + } else { + this.retrySchedules.push(merged); + } + } + + getRetrySchedule(failureType: FailureType): RetryScheduleConfig { + return ( + this.retrySchedules.find((s) => s.failureType === failureType) ?? + this.retrySchedules.find((s) => s.failureType === 'unknown')! + ); + } + + calculateRetryDelay(failureType: FailureType, attemptNumber: number): number { + const schedule = this.getRetrySchedule(failureType); + const delay = + schedule.baseDelayHours * Math.pow(schedule.backoffMultiplier, attemptNumber - 1); + return Math.min(delay, schedule.maxDelayHours); + } + startDunning( subscriptionId: string, subscriberId: string, @@ -80,11 +162,15 @@ export class DunningService { return entry; } - recordFailedCharge(subscriptionId: string): DunningEntry | null { + recordFailedCharge( + subscriptionId: string, + failureType: FailureType = 'unknown' + ): DunningEntry | null { const entry = this.entries.get(subscriptionId); if (!entry || entry.isPaused) return null; const config = this.configurations.get(entry.planId); + const schedule = this.getRetrySchedule(failureType); const now_ts = now(); entry.failedAttempts += 1; @@ -93,18 +179,27 @@ export class DunningService { entry.lastAttemptAt = now_ts; entry.updatedAt = now_ts; - const currentStageIndex = config - ? config.stages.findIndex((s) => s.stage === entry.currentStage) - : -1; + this.retryHistory.push({ + subscriptionId, + failureType, + attempt: entry.failedAttempts, + success: false, + timestamp: now_ts, + delayHours: 0, + }); const shouldAdvanceStage = (): boolean => { - if (currentStageIndex < 0) return false; - if (!config) return false; + if (entry.failedAttempts >= schedule.maxRetries) return true; + const currentStageIndex = config + ? config.stages.findIndex((s) => s.stage === entry.currentStage) + : -1; + if (currentStageIndex < 0 || !config) return false; const stageConfig = config.stages[currentStageIndex]; return entry.failedAttempts >= stageConfig.maxAttempts; }; if (shouldAdvanceStage() && config) { + const currentStageIndex = config.stages.findIndex((s) => s.stage === entry.currentStage); const nextStageIndex = currentStageIndex + 1; if (nextStageIndex < config.stages.length) { const nextStage = config.stages[nextStageIndex]; @@ -117,8 +212,8 @@ export class DunningService { entry.nextActionAt = now_ts + 24 * ONE_HOUR_MS; } } else { - const retryDelay = config?.retryIntervalHours ?? 1; - entry.nextActionAt = now_ts + retryDelay * ONE_HOUR_MS; + const delay = this.calculateRetryDelay(failureType, entry.failedAttempts); + entry.nextActionAt = now_ts + delay * ONE_HOUR_MS; } this.entries.set(subscriptionId, entry); @@ -127,7 +222,16 @@ export class DunningService { recordSuccessfulCharge(subscriptionId: string): void { const entry = this.entries.get(subscriptionId); - if (!entry) return; + if (entry) { + this.retryHistory.push({ + subscriptionId, + failureType: 'unknown', + attempt: entry.failedAttempts, + success: true, + timestamp: now(), + delayHours: 0, + }); + } this.entries.delete(subscriptionId); this.communicationLog.delete(subscriptionId); @@ -185,6 +289,84 @@ export class DunningService { return this.communicationLog.get(subscriptionId) ?? []; } + getRetryAnalytics(merchantId?: string): RetryAnalytics { + const entries = this.listActiveDunning(merchantId); + const relevantHistory = merchantId + ? this.retryHistory.filter((h) => + entries.some((e) => e.subscriptionId === h.subscriptionId) + ) + : this.retryHistory; + + const totalRetries = relevantHistory.length; + const successfulRetries = relevantHistory.filter((h) => h.success).length; + const failedRetries = totalRetries - successfulRetries; + + const retriesByFailureType: Record = { + insufficient_funds: 0, + card_declined: 0, + expired_card: 0, + network_error: 0, + processing_error: 0, + auth_required: 0, + unknown: 0, + }; + + const retriesByStage: Record = { + retry: 0, + warn: 0, + suspend: 0, + cancel: 0, + }; + + for (const entry of entries) { + retriesByStage[entry.currentStage] = (retriesByStage[entry.currentStage] ?? 0) + 1; + } + + for (const h of relevantHistory) { + retriesByFailureType[h.failureType] = (retriesByFailureType[h.failureType] ?? 0) + 1; + } + + const successfulSubscriptionIds = new Set( + relevantHistory.filter((h) => h.success).map((h) => h.subscriptionId) + ); + + const recoveryTimes: number[] = []; + for (const subId of successfulSubscriptionIds) { + const subHistory = relevantHistory.filter((h) => h.subscriptionId === subId); + if (subHistory.length >= 2) { + const first = subHistory[0]; + const last = subHistory[subHistory.length - 1]; + recoveryTimes.push((last.timestamp - first.timestamp) / ONE_DAY_MS); + } + } + + const avgRecoveryTime = + recoveryTimes.length > 0 + ? recoveryTimes.reduce((s, t) => s + t, 0) / recoveryTimes.length + : 0; + + const attemptsPerSuccess: number[] = []; + for (const subId of successfulSubscriptionIds) { + const subHistory = relevantHistory.filter((h) => h.subscriptionId === subId); + attemptsPerSuccess.push(subHistory.length); + } + + return { + totalRetries, + successfulRetries, + failedRetries, + retryRate: totalRetries > 0 ? Math.round((failedRetries / totalRetries) * 100) : 0, + successRate: totalRetries > 0 ? Math.round((successfulRetries / totalRetries) * 100) : 0, + averageRetriesBeforeSuccess: + attemptsPerSuccess.length > 0 + ? Math.round(attemptsPerSuccess.reduce((s, a) => s + a, 0) / attemptsPerSuccess.length) + : 0, + retriesByFailureType, + retriesByStage, + averageTimeToRecovery: Math.round(avgRecoveryTime * 10) / 10, + }; + } + getAnalytics(merchantId?: string): DunningAnalytics { const allEntries = this.listActiveDunning(merchantId); const stageBreakdown: Record = { @@ -198,22 +380,20 @@ export class DunningService { stageBreakdown[entry.currentStage] = (stageBreakdown[entry.currentStage] ?? 0) + 1; } - const totalRecovered = Array.from(this.entries.values()).filter( - (e) => e.totalFailedCharges === 0 - ).length; + const retryAnalytics = this.getRetryAnalytics(merchantId); return { totalActiveDunning: allEntries.length, stageBreakdown, - recoveryRate: 0, - totalRecovered, + recoveryRate: retryAnalytics.successRate, + totalRecovered: retryAnalytics.successfulRetries, totalLost: stageBreakdown.cancel, - averageDaysToRecovery: 0, + averageDaysToRecovery: retryAnalytics.averageTimeToRecovery, stageSuccessRates: { - retry: 0, - warn: 0, - suspend: 0, - cancel: 0, + retry: retryAnalytics.retriesByStage.retry, + warn: retryAnalytics.retriesByStage.warn, + suspend: retryAnalytics.retriesByStage.suspend, + cancel: retryAnalytics.retriesByStage.cancel, }, }; } diff --git a/backend/services/billing/index.ts b/backend/services/billing/index.ts index d2b4dc12..45f6e6e5 100644 --- a/backend/services/billing/index.ts +++ b/backend/services/billing/index.ts @@ -28,6 +28,14 @@ export type { TaxRemittanceReportRequest, } from './taxTypes'; export { DunningService, dunningService } from './dunningService'; +export type { FailureType, RetryScheduleConfig, RetryAnalytics } from './dunningService'; +export { ProrationService, prorationService } from './proration'; +export type { + ProrationConfiguration, + ProrationAnalytics, + ProrationDispute, + MidCycleChangeRequest, +} from './proration'; export { streamExport, reconcile } from './accountingExportService'; export type { AccountingFormat, diff --git a/backend/services/billing/interfaces.ts b/backend/services/billing/interfaces.ts index 609923f0..b745d762 100644 --- a/backend/services/billing/interfaces.ts +++ b/backend/services/billing/interfaces.ts @@ -34,6 +34,49 @@ export interface IMeteringService { ): number; checkThresholds(userId: string, metricType: string): Promise; calculateOverage(userId: string, metricType?: string): Promise; + getUsageByMetric(subscriptionId: string): Record; + getUsageHistory(subscriptionId?: string, metric?: string): Array<{ + subscriptionId: string; + metric: string; + value: number; + timestamp: Date; + }>; + getUsageTrends(subscriptionId: string): Array<{ + metric: string; + currentPeriod: number; + previousPeriod: number; + changePercent: number; + trend: 'increasing' | 'decreasing' | 'stable'; + }>; + getAnalytics(subscriptionId?: string): { + totalUsage: number; + usageByMetric: Record; + usageBySubscription: Record; + usageHistory: Array<{ + subscriptionId: string; + metric: string; + value: number; + timestamp: Date; + }>; + trends: Array<{ + metric: string; + currentPeriod: number; + previousPeriod: number; + changePercent: number; + trend: 'increasing' | 'decreasing' | 'stable'; + }>; + alertsCount: number; + alerts: Array<{ + id: string; + subscriptionId: string; + metric: string; + threshold: number; + currentUsage: number; + message: string; + createdAt: Date; + acknowledged: boolean; + }>; + }; } export interface IPricingService { @@ -52,7 +95,7 @@ export interface IDunningService { configurePlan(planId: string, config: Partial): DunningConfiguration; getConfiguration(planId: string): DunningConfiguration | undefined; startDunning(subscriptionId: string, subscriberId: string, merchantId: string, planId: string): DunningEntry; - recordFailedCharge(subscriptionId: string): DunningEntry | null; + recordFailedCharge(subscriptionId: string, failureType?: string): DunningEntry | null; recordSuccessfulCharge(subscriptionId: string): void; getDunningEntry(subscriptionId: string): DunningEntry | undefined; listActiveDunning(merchantId?: string): DunningEntry[]; @@ -62,6 +105,32 @@ export interface IDunningService { getCommunications(subscriptionId: string): DunningCommunication[]; getAnalytics(merchantId?: string): DunningAnalytics; getProcessableEntries(): DunningEntry[]; + configureRetrySchedule(schedule: { + failureType: string; + baseDelayHours?: number; + maxRetries?: number; + backoffMultiplier?: number; + maxDelayHours?: number; + }): void; + getRetrySchedule(failureType: string): { + failureType: string; + baseDelayHours: number; + maxRetries: number; + backoffMultiplier: number; + maxDelayHours: number; + }; + calculateRetryDelay(failureType: string, attempt: number): number; + getRetryAnalytics(merchantId?: string): { + totalRetries: number; + successfulRetries: number; + failedRetries: number; + retryRate: number; + successRate: number; + averageRetriesBeforeSuccess: number; + retriesByFailureType: Record; + retriesByStage: Record; + averageTimeToRecovery: number; + }; } export interface IAccountingExportService { diff --git a/backend/services/billing/meteringService.ts b/backend/services/billing/meteringService.ts index 0fff5005..60c5acc9 100644 --- a/backend/services/billing/meteringService.ts +++ b/backend/services/billing/meteringService.ts @@ -21,14 +21,29 @@ export interface UsageMetric { idempotencyKey?: string; } +export interface UsageAlert { + id: string; + subscriptionId: string; + metric: string; + threshold: number; + currentUsage: number; + message: string; + createdAt: Date; + acknowledged: boolean; +} + +export interface UsageHistoryEntry { + subscriptionId: string; + metric: string; + value: number; + timestamp: Date; +} + interface StoredUsageEvent { idempotencyKey: string; amount: number; - /** Time the client claims the usage happened. */ eventTime: number; - /** Time the server actually received/recorded the event. */ receivedAt: number; - /** True when eventTime and receivedAt diverged by more than MAX_CLOCK_SKEW_MS. */ clockSkewDetected: boolean; } @@ -55,10 +70,39 @@ function percentile(sortedValues: number[], p: number): number { return sortedValues[lower] * (1 - weight) + sortedValues[upper] * weight; } +export interface UsageTrend { + metric: string; + currentPeriod: number; + previousPeriod: number; + changePercent: number; + trend: 'increasing' | 'decreasing' | 'stable'; +} + +export interface UsageAnalytics { + totalUsage: number; + usageByMetric: Record; + usageBySubscription: Record; + usageHistory: UsageHistoryEntry[]; + trends: UsageTrend[]; + alertsCount: number; + alerts: UsageAlert[]; +} + +export interface UsageBillingIntegration { + subscriptionId: string; + meteredAmount: number; + unitPrice: number; + totalAmount: number; + currency: string; + period: { start: Date; end: Date }; +} + export class MeteringService { private events = new Map(); private seenIdempotencyKeys = new Map(); private limits = new Map(); + private usageHistory: UsageHistoryEntry[] = []; + private alerts: UsageAlert[] = []; /** Configure the quota limit used for threshold alerts on a given user+metric. */ setLimit(userId: string, metricType: string, limit: number): void { @@ -70,22 +114,14 @@ export class MeteringService { return result; } - /** - * Ingests a batch of usage events. Each event is deduplicated by - * `idempotencyKey` (events missing one are auto-keyed and therefore never - * deduplicated against retries — callers should always supply one). - */ async recordUsageBatch(metrics: UsageMetric[]): Promise { if (metrics.length > MAX_BATCH_SIZE) { throw new Error(`Batch size ${metrics.length} exceeds maximum of ${MAX_BATCH_SIZE}`); } - const results: UsageIngestResult[] = []; - for (const metric of metrics) { results.push(await this.recordOne(metric)); } - return results; } @@ -98,135 +134,179 @@ export class MeteringService { } if (!Number.isFinite(metric.amount) || metric.amount < 0) { - const result: UsageIngestResult = { - idempotencyKey, - status: 'rejected', - reason: 'amount must be a non-negative finite number', - }; - return result; + return { idempotencyKey, status: 'rejected', reason: 'amount must be a non-negative finite number' }; } const receivedAt = Date.now(); const eventTime = metric.timestamp.getTime(); const clockSkewDetected = Math.abs(receivedAt - eventTime) > MAX_CLOCK_SKEW_MS; - // Future-dated events are clamped to server time so callers can't push usage - // into a not-yet-closed billing window; late-arriving events keep their - // original timestamp (so they land in the correct historical bucket) but - // are flagged for audit. const normalizedEventTime = eventTime > receivedAt ? receivedAt : eventTime; const key = meterKey(metric.userId, metric.metricType); const list = this.events.get(key) ?? []; - list.push({ - idempotencyKey, - amount: metric.amount, - eventTime: normalizedEventTime, - receivedAt, - clockSkewDetected, - }); + list.push({ idempotencyKey, amount: metric.amount, eventTime: normalizedEventTime, receivedAt, clockSkewDetected }); this.events.set(key, list); + this.usageHistory.push({ + subscriptionId: metric.userId, + metric: metric.metricType, + value: metric.amount, + timestamp: metric.timestamp, + }); + const result: UsageIngestResult = { idempotencyKey, status: 'accepted', clockSkewDetected }; this.seenIdempotencyKeys.set(idempotencyKey, result); await this.checkThresholds(metric.userId, metric.metricType); - return result; } - /** Aggregates recorded usage for a window ending now. */ - aggregate( - userId: string, - metricType: string, - window: AggregationWindow, - fn: AggregationFunction = AggregationFunction.SUM, - now: number = Date.now() - ): number { + aggregate(userId: string, metricType: string, window: AggregationWindow, fn: AggregationFunction = AggregationFunction.SUM, now: number = Date.now()): number { const windowMs = AGGREGATION_WINDOW_MS[window]; const cutoff = now - windowMs; const values = (this.events.get(meterKey(userId, metricType)) ?? []) .filter((e) => e.eventTime >= cutoff && e.eventTime <= now) .map((e) => e.amount); - if (values.length === 0) return 0; - switch (fn) { - case AggregationFunction.SUM: - return values.reduce((a, b) => a + b, 0); - case AggregationFunction.MAX: - return Math.max(...values); - case AggregationFunction.AVERAGE: - return values.reduce((a, b) => a + b, 0) / values.length; - case AggregationFunction.PERCENTILE_95: - return percentile([...values].sort((a, b) => a - b), 95); - case AggregationFunction.PERCENTILE_99: - return percentile([...values].sort((a, b) => a - b), 99); - default: - return values.reduce((a, b) => a + b, 0); + case AggregationFunction.SUM: return values.reduce((a, b) => a + b, 0); + case AggregationFunction.MAX: return Math.max(...values); + case AggregationFunction.AVERAGE: return values.reduce((a, b) => a + b, 0) / values.length; + case AggregationFunction.PERCENTILE_95: return percentile([...values].sort((a, b) => a - b), 95); + case AggregationFunction.PERCENTILE_99: return percentile([...values].sort((a, b) => a - b), 99); + default: return values.reduce((a, b) => a + b, 0); } } - /** Total consumption for the metric in the current (default daily) window. */ - getCurrentPeriodConsumption( - userId: string, - metricType: string, - window: AggregationWindow = AggregationWindow.MONTHLY - ): number { + getCurrentPeriodConsumption(userId: string, metricType: string, window: AggregationWindow = AggregationWindow.MONTHLY): number { return this.aggregate(userId, metricType, window, AggregationFunction.SUM); } - /** - * Checks current consumption against the configured limit. Returns the - * highest alert level reached (soft = warning, hard = block) or null if - * usage is within bounds. - */ - async checkThresholds(userId: string, metricType: string): Promise { - const limit = this.limits.get(meterKey(userId, metricType)); - if (!limit || limit <= 0) return null; - - const usage = this.getCurrentPeriodConsumption(userId, metricType); - const ratio = usage / limit; - - if (ratio >= HARD_THRESHOLD_RATIO) { - return { - level: UsageAlertLevel.HARD, - metric: metricType as any, - subscriptionId: userId, - usage, - limit, - ratio, - }; - } - if (ratio >= SOFT_THRESHOLD_RATIO) { - return { - level: UsageAlertLevel.SOFT, - metric: metricType as any, - subscriptionId: userId, - usage, - limit, - ratio, - }; + async checkThresholds(userId: string, metricType?: string): Promise { + const metricTypes = metricType ? [metricType] : ['api', 'compute', 'storage']; + for (const mt of metricTypes) { + const limit = this.limits.get(meterKey(userId, mt)); + if (!limit || limit <= 0) continue; + const usage = this.getCurrentPeriodConsumption(userId, mt); + const ratio = usage / limit; + if (ratio >= HARD_THRESHOLD_RATIO) { + const alert: UsageAlert = { + id: `alert_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, + subscriptionId: userId, metric: mt, threshold: limit, currentUsage: usage, + message: `Usage for ${mt} has reached ${Math.round(ratio * 100)}% of limit`, + createdAt: new Date(), acknowledged: false, + }; + this.alerts.push(alert); + return { level: UsageAlertLevel.HARD, metric: mt as any, subscriptionId: userId, usage, limit, ratio }; + } + if (ratio >= SOFT_THRESHOLD_RATIO) { + const alert: UsageAlert = { + id: `alert_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, + subscriptionId: userId, metric: mt, threshold: limit, currentUsage: usage, + message: `Usage for ${mt} has reached ${Math.round(ratio * 100)}% of limit`, + createdAt: new Date(), acknowledged: false, + }; + this.alerts.push(alert); + return { level: UsageAlertLevel.SOFT, metric: mt as any, subscriptionId: userId, usage, limit, ratio }; + } } return null; } - /** Units billable beyond the free allotment, for the current period. */ async calculateOverage(userId: string, metricType = 'api'): Promise { const limit = this.limits.get(meterKey(userId, metricType)) ?? 0; const usage = this.getCurrentPeriodConsumption(userId, metricType); return Math.max(0, usage - limit); } - /** Returns true if a hard limit has already been reached (used to block further usage). */ async isBlocked(userId: string, metricType: string): Promise { const alert = await this.checkThresholds(userId, metricType); return alert?.level === UsageAlertLevel.HARD; } - /** Test/cron helper: clears events for a meter, e.g. after billing close. */ resetPeriod(userId: string, metricType: string): void { this.events.delete(meterKey(userId, metricType)); } + + getUsageByMetric(subscriptionId: string): Record { + const usage: Record = {}; + for (const entry of this.usageHistory) { + if (entry.subscriptionId === subscriptionId) { + usage[entry.metric] = (usage[entry.metric] || 0) + entry.value; + } + } + return usage; + } + + getUsageHistory(subscriptionId?: string, metric?: string): UsageHistoryEntry[] { + let history = this.usageHistory; + if (subscriptionId) history = history.filter((h) => h.subscriptionId === subscriptionId); + if (metric) history = history.filter((h) => h.metric === metric); + return history; + } + + getUsageTrends(subscriptionId: string): UsageTrend[] { + const now = new Date(); + const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + const sixtyDaysAgo = new Date(now.getTime() - 60 * 24 * 60 * 60 * 1000); + + const currentHistory = this.usageHistory.filter((h) => h.subscriptionId === subscriptionId && h.timestamp >= thirtyDaysAgo); + const previousHistory = this.usageHistory.filter((h) => h.subscriptionId === subscriptionId && h.timestamp >= sixtyDaysAgo && h.timestamp < thirtyDaysAgo); + + const currentByMetric: Record = {}; + const previousByMetric: Record = {}; + for (const entry of currentHistory) currentByMetric[entry.metric] = (currentByMetric[entry.metric] || 0) + entry.value; + for (const entry of previousHistory) previousByMetric[entry.metric] = (previousByMetric[entry.metric] || 0) + entry.value; + + const allMetrics = new Set([...Object.keys(currentByMetric), ...Object.keys(previousByMetric)]); + const trends: UsageTrend[] = []; + for (const metric of allMetrics) { + const current = currentByMetric[metric] || 0; + const previous = previousByMetric[metric] || 0; + const changePercent = previous > 0 ? ((current - previous) / previous) * 100 : 0; + let trend: 'increasing' | 'decreasing' | 'stable'; + if (changePercent > 5) trend = 'increasing'; + else if (changePercent < -5) trend = 'decreasing'; + else trend = 'stable'; + trends.push({ metric, currentPeriod: current, previousPeriod: previous, changePercent: Math.round(changePercent * 100) / 100, trend }); + } + return trends; + } + + acknowledgeAlert(alertId: string): void { + const alert = this.alerts.find((a) => a.id === alertId); + if (alert) alert.acknowledged = true; + } + + getActiveAlerts(subscriptionId?: string): UsageAlert[] { + let alerts = this.alerts.filter((a) => !a.acknowledged); + if (subscriptionId) alerts = alerts.filter((a) => a.subscriptionId === subscriptionId); + return alerts; + } + + getAnalytics(subscriptionId?: string): UsageAnalytics { + let history = subscriptionId ? this.usageHistory.filter((h) => h.subscriptionId === subscriptionId) : this.usageHistory; + const totalUsage = history.reduce((sum, h) => sum + h.value, 0); + const usageByMetric: Record = {}; + const usageBySubscription: Record = {}; + for (const entry of history) { + usageByMetric[entry.metric] = (usageByMetric[entry.metric] || 0) + entry.value; + usageBySubscription[entry.subscriptionId] = (usageBySubscription[entry.subscriptionId] || 0) + entry.value; + } + const trends = subscriptionId ? this.getUsageTrends(subscriptionId) : []; + const activeAlerts = this.getActiveAlerts(subscriptionId); + return { totalUsage, usageByMetric, usageBySubscription, usageHistory: history, trends, alertsCount: activeAlerts.length, alerts: activeAlerts }; + } + + calculateUsageBilling(subscriptionId: string, unitPrice: number, currency: string): UsageBillingIntegration { + const metricUsage = this.getUsageByMetric(subscriptionId); + const now = new Date(); + const periodStart = new Date(now.getFullYear(), now.getMonth(), 1); + const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0); + let totalMetered = 0; + for (const usage of Object.values(metricUsage)) totalMetered += usage; + return { subscriptionId, meteredAmount: totalMetered, unitPrice, totalAmount: totalMetered * unitPrice, currency, period: { start: periodStart, end: periodEnd } }; + } } export const meteringService = new MeteringService(); diff --git a/backend/services/billing/proration.ts b/backend/services/billing/proration.ts new file mode 100644 index 00000000..6f72c0f0 --- /dev/null +++ b/backend/services/billing/proration.ts @@ -0,0 +1,283 @@ +import { + ProrationPreview, + CreditMemo, + getPeriodDays, + getRemainingDays, + previewProration as clientPreviewProration, + generateCreditMemo as clientGenerateCreditMemo, + applyCreditMemo as clientApplyCreditMemo, +} from '../../../src/utils/proration'; +import type { Subscription } from '../../../src/types/subscription'; + +export interface ProrationConfiguration { + planId: string; + method: 'daily' | 'hourly' | 'none'; + allowMidCycleChanges: boolean; + maxChangesPerCycle: number; + creditExpirationDays: number; + prorationPrecision: number; +} + +export interface ProrationAnalytics { + totalProrations: number; + totalCreditsIssued: number; + totalChargesApplied: number; + netProrationAmount: number; + averageProrationAmount: number; + prorationCount: number; + creditsUsedCount: number; + chargesCount: number; + disputesCount: number; + disputesResolved: number; + averageResolutionDays: number; +} + +export interface ProrationDispute { + id: string; + subscriptionId: string; + prorationId: string; + amount: number; + reason: string; + status: 'pending' | 'investigating' | 'resolved' | 'rejected'; + createdAt: Date; + resolvedAt?: Date; + resolution?: string; +} + +export interface MidCycleChangeRequest { + subscriptionId: string; + oldPrice: number; + newPrice: number; + effectiveDate: 'immediate' | 'end_of_period' | Date; + reason?: string; + requestedBy: string; +} + +const DEFAULT_CONFIG: ProrationConfiguration = { + planId: 'default', + method: 'daily', + allowMidCycleChanges: true, + maxChangesPerCycle: 3, + creditExpirationDays: 90, + prorationPrecision: 2, +}; + +export class ProrationService { + private configurations = new Map(); + private disputes: ProrationDispute[] = []; + private prorationHistory: Array<{ + subscriptionId: string; + preview: ProrationPreview; + timestamp: Date; + applied: boolean; + }> = []; + + configurePlan(planId: string, config: Partial): ProrationConfiguration { + const existing = this.configurations.get(planId); + const merged: ProrationConfiguration = { + ...DEFAULT_CONFIG, + ...existing, + ...config, + planId, + }; + this.configurations.set(planId, merged); + return merged; + } + + getConfiguration(planId: string): ProrationConfiguration { + return this.configurations.get(planId) ?? DEFAULT_CONFIG; + } + + calculateProration( + subscription: Subscription, + newPrice: number, + effectiveDate: 'immediate' | 'end_of_period' | Date, + planId?: string + ): ProrationPreview { + const config = this.getConfiguration(planId ?? subscription.id); + + if (config.method === 'none') { + return { + amount: 0, + isCredit: false, + remainingDays: 0, + periodDays: 0, + oldDailyRate: 0, + newDailyRate: 0, + description: 'Proration disabled for this plan', + effectiveDate: 'end_of_period', + }; + } + + let effectiveType: 'immediate' | 'end_of_period' = 'immediate'; + if (effectiveDate === 'end_of_period') { + effectiveType = 'end_of_period'; + } else if (effectiveDate instanceof Date) { + const now = new Date(); + const nextBilling = new Date(subscription.nextBillingDate); + if (effectiveDate.getTime() > now.getTime() && effectiveDate.getTime() <= nextBilling.getTime()) { + effectiveType = 'immediate'; + } else { + effectiveType = 'end_of_period'; + } + } + + const preview = clientPreviewProration(subscription, newPrice, effectiveType); + + if (config.method === 'hourly') { + const hoursRemaining = preview.remainingDays * 24; + const hoursInPeriod = preview.periodDays * 24; + const amount = effectiveType === 'end_of_period' + ? 0 + : ((newPrice - subscription.price) * hoursRemaining) / hoursInPeriod; + + const roundedAmount = Math.round(Math.abs(amount) * Math.pow(10, config.prorationPrecision)) + / Math.pow(10, config.prorationPrecision); + + return { + ...preview, + amount: roundedAmount, + isCredit: amount < 0, + description: amount < 0 + ? `Prorated credit of ${roundedAmount} for plan downgrade (${hoursRemaining} hours remaining)` + : amount > 0 + ? `Prorated charge of ${roundedAmount} for plan upgrade (${hoursRemaining} hours remaining)` + : 'No proration required', + }; + } + + return preview; + } + + executeMidCycleChange( + subscription: Subscription, + newPrice: number, + effectiveDate: 'immediate' | 'end_of_period' | Date, + planId?: string + ): { preview: ProrationPreview; creditMemo?: CreditMemo } { + const config = this.getConfiguration(planId ?? subscription.id); + + if (!config.allowMidCycleChanges) { + throw new Error('Mid-cycle changes are not allowed for this plan'); + } + + const recentChanges = this.prorationHistory.filter( + (h) => + h.subscriptionId === subscription.id && + h.applied && + h.timestamp.getTime() > Date.now() - 30 * 24 * 60 * 60 * 1000 + ); + + if (recentChanges.length >= config.maxChangesPerCycle) { + throw new Error(`Maximum ${config.maxChangesPerCycle} plan changes per cycle reached`); + } + + const preview = this.calculateProration(subscription, newPrice, effectiveDate, planId); + + let creditMemo: CreditMemo | undefined; + if (preview.isCredit && preview.amount > 0) { + creditMemo = clientGenerateCreditMemo(subscription.id, preview.amount, preview.description); + } + + this.prorationHistory.push({ + subscriptionId: subscription.id, + preview, + timestamp: new Date(), + applied: true, + }); + + return { preview, creditMemo }; + } + + createDispute( + subscriptionId: string, + prorationId: string, + amount: number, + reason: string + ): ProrationDispute { + const dispute: ProrationDispute = { + id: `disp_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, + subscriptionId, + prorationId, + amount, + reason, + status: 'pending', + createdAt: new Date(), + }; + this.disputes.push(dispute); + return dispute; + } + + resolveDispute(disputeId: string, resolution: string, approved: boolean): ProrationDispute | null { + const dispute = this.disputes.find((d) => d.id === disputeId); + if (!dispute) return null; + + dispute.status = approved ? 'resolved' : 'rejected'; + dispute.resolvedAt = new Date(); + dispute.resolution = resolution; + return dispute; + } + + getDisputes(subscriptionId?: string): ProrationDispute[] { + if (subscriptionId) { + return this.disputes.filter((d) => d.subscriptionId === subscriptionId); + } + return [...this.disputes]; + } + + getAnalytics(subscriptionId?: string): ProrationAnalytics { + const history = subscriptionId + ? this.prorationHistory.filter((h) => h.subscriptionId === subscriptionId) + : this.prorationHistory; + + const disputes = subscriptionId + ? this.disputes.filter((d) => d.subscriptionId === subscriptionId) + : this.disputes; + + const totalCredits = history + .filter((h) => h.preview.isCredit) + .reduce((sum, h) => sum + h.preview.amount, 0); + + const totalCharges = history + .filter((h) => !h.preview.isCredit && h.preview.amount > 0) + .reduce((sum, h) => sum + h.preview.amount, 0); + + const allProrationAmounts = history.map((h) => h.preview.amount).filter((a) => a > 0); + const averageAmount = allProrationAmounts.length > 0 + ? allProrationAmounts.reduce((s, a) => s + a, 0) / allProrationAmounts.length + : 0; + + const resolvedDisputes = disputes.filter((d) => d.status === 'resolved' || d.status === 'rejected'); + const avgResolutionDays = resolvedDisputes.length > 0 + ? resolvedDisputes.reduce((sum, d) => { + const days = d.resolvedAt + ? (d.resolvedAt.getTime() - d.createdAt.getTime()) / (1000 * 60 * 60 * 24) + : 0; + return sum + days; + }, 0) / resolvedDisputes.length + : 0; + + return { + totalProrations: history.length, + totalCreditsIssued: totalCredits, + totalChargesApplied: totalCharges, + netProrationAmount: totalCharges - totalCredits, + averageProrationAmount: Math.round(averageAmount * 100) / 100, + prorationCount: history.length, + creditsUsedCount: history.filter((h) => h.preview.isCredit).length, + chargesCount: history.filter((h) => !h.preview.isCredit).length, + disputesCount: disputes.length, + disputesResolved: resolvedDisputes.length, + averageResolutionDays: Math.round(avgResolutionDays * 10) / 10, + }; + } + + getHistory(subscriptionId?: string) { + if (subscriptionId) { + return this.prorationHistory.filter((h) => h.subscriptionId === subscriptionId); + } + return [...this.prorationHistory]; + } +} + +export const prorationService = new ProrationService(); diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 15cb57d1..0cd7c908 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -68,6 +68,7 @@ export type RootStackParamList = { BillingSettings: undefined; BillingAlignment: undefined; ChangePlan: { subscriptionId: string }; + PauseResume: { id: string }; PaymentMethods: undefined; AnalyticsDashboard: undefined; TrialDetails: { trialId: string } | undefined; diff --git a/src/screens/PauseResumeScreen.tsx b/src/screens/PauseResumeScreen.tsx new file mode 100644 index 00000000..1b1fb560 --- /dev/null +++ b/src/screens/PauseResumeScreen.tsx @@ -0,0 +1,297 @@ +import React, { useState, useMemo, useCallback } from 'react'; +import { + View, + Text, + StyleSheet, + SafeAreaView, + ScrollView, + TouchableOpacity, + Alert, + FlatList, +} from 'react-native'; +import { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { RootStackParamList } from '../navigation/types'; +import { useSubscriptionStore, PauseRecord } from '../store/subscriptionStore'; +import { Card } from '../components/common/Card'; +import { Button } from '../components/common/Button'; +import { useThemeColors } from '../hooks/useThemeColors'; +import { spacing, typography, borderRadius } from '../utils/constants'; +import { formatCurrency } from '../utils/formatting'; + +type Props = NativeStackScreenProps; + +const PAUSE_DURATIONS = [ + { days: 7, label: '1 Week' }, + { days: 14, label: '2 Weeks' }, + { days: 30, label: '1 Month' }, + { days: 60, label: '2 Months' }, + { days: 90, label: '3 Months' }, +]; + +const PauseResumeScreen: React.FC = ({ route }) => { + const { id: subscriptionId } = route.params; + const colors = useThemeColors(); + const styles = useMemo(() => createStyles(colors), [colors]); + + const { + subscriptions, + isLoading, + pauseRecords, + pauseAnalytics, + pauseSubscription, + resumeSubscription, + getPauseHistory, + } = useSubscriptionStore(); + + const subscription = subscriptions.find((s) => s.id === subscriptionId); + const pauseHistory = useMemo( + () => getPauseHistory(subscriptionId), + // eslint-disable-next-line react-hooks/exhaustive-deps + [subscriptionId] + ); + + const [selectedDuration, setSelectedDuration] = useState(null); + const [reason, setReason] = useState(''); + + const activePause = pauseHistory.find((p) => p.status === 'active'); + + const handlePause = useCallback(() => { + if (!selectedDuration) { + Alert.alert('Select Duration', 'Please select a pause duration'); + return; + } + Alert.alert( + 'Pause Subscription', + `This will pause ${subscription?.name} for ${selectedDuration} days and apply a billing adjustment. Continue?`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Pause', + style: 'destructive', + onPress: () => { + try { + pauseSubscription(subscriptionId, selectedDuration, reason || undefined); + Alert.alert('Paused', 'Subscription has been paused'); + } catch (e) { + Alert.alert('Error', (e as Error).message); + } + }, + }, + ] + ); + }, [selectedDuration, reason, subscriptionId, subscription, pauseSubscription]); + + const handleResume = useCallback(() => { + Alert.alert( + 'Resume Subscription', + `This will resume ${subscription?.name} and restart billing. Continue?`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Resume', + onPress: () => { + try { + resumeSubscription(subscriptionId); + Alert.alert('Resumed', 'Subscription has been resumed'); + } catch (e) { + Alert.alert('Error', (e as Error).message); + } + }, + }, + ] + ); + }, [subscriptionId, subscription, resumeSubscription]); + + const renderHistoryItem = ({ item }: { item: PauseRecord }) => ( + + + + {new Date(item.pausedAt).toLocaleDateString()} + {item.resumeAt && ` - ${new Date(item.resumeAt).toLocaleDateString()}`} + + + {item.status.toUpperCase()} + + + {item.reason || 'No reason provided'} + + Billing adjustment:{' '} + {formatCurrency(item.billingAdjustment, subscription?.currency ?? 'USD')} + + + ); + + if (!subscription) { + return ( + + Subscription not found + + ); + } + + return ( + + + + Subscription + {subscription.name} + + {formatCurrency(subscription.price, subscription.currency)} /{' '} + {subscription.billingCycle} + + + + {activePause ? ( + + Currently Paused + + Paused on {new Date(activePause.pausedAt).toLocaleDateString()} + + {activePause.plannedResumeDate && ( + + Planned resume: {new Date(activePause.plannedResumeDate).toLocaleDateString()} + + )} + + Billing adjustment:{' '} + {formatCurrency(activePause.billingAdjustment, subscription.currency)} + + +