diff --git a/.gitignore b/.gitignore index 3808d41d..d3559a88 100644 --- a/.gitignore +++ b/.gitignore @@ -102,6 +102,34 @@ contracts/migrations/history/* contracts/migrations/snapshots/* !contracts/migrations/snapshots/.gitkeep +# Rust / Soroban test snapshots (generated by soroban-sdk test runner) +contracts/**/test_snapshots/ +test_snapshots/ + +# Generated files +*.orig +SubTrackr +test_output.txt +tsc_output*.txt +lint_output*.txt +lint_final_error.txt +final_lint_check.txt +contracts/clippy_output.txt +issue*.json +issues_summary.json +COMPLETION_SUMMARY.md +RACE_CONDITION_FIX.md +JS_BUNDLE_FIX.md +BUILD_FIX_GUIDE.md +PR_BODY_*.md +PR_CI_*.md +BUNDLE_AUDIT.md +DESIGN_SYSTEM_INTEGRATION.md +DESIGN_SYSTEM_IMPLEMENTATION.md +DESIGN_SYSTEM_SETUP.md +WCAG_COMPLIANCE.md +FORMATTING.md +package.json.backup # Test run artifacts (NOT the committed contract insta snapshots under # contracts/**/test_snapshots/, which are intentional fixtures) test-results/ diff --git a/backend/billing/jobs/index.ts b/backend/billing/jobs/index.ts index 3257a07c..6b4fe8ef 100644 --- a/backend/billing/jobs/index.ts +++ b/backend/billing/jobs/index.ts @@ -18,3 +18,15 @@ export type { PaymentConfirmationHandler, PaymentConfirmationResult } from './pa export { BillingJobQueue } from './billingJobQueue'; export type { BillingJobQueueConfig } from './billingJobQueue'; + +export { + MonthlyRevenueRecognitionJob, + computeEntryRecognition, +} from './monthlyRevenueRecognitionJob'; +export type { + RevenueSchedule, + RevenueScheduleEntry, + RecognitionJournalEntry, + RevenueRecognitionRepository, + RecognitionJobMetrics, +} from './monthlyRevenueRecognitionJob'; diff --git a/backend/billing/jobs/monthlyRevenueRecognitionJob.ts b/backend/billing/jobs/monthlyRevenueRecognitionJob.ts new file mode 100644 index 00000000..e7e37213 --- /dev/null +++ b/backend/billing/jobs/monthlyRevenueRecognitionJob.ts @@ -0,0 +1,251 @@ +/** + * Monthly Revenue Recognition Job (ASC 606 / IFRS 15) + * + * Runs once per day (configurable). For each active subscription that has + * a pending revenue schedule: + * 1. Computes how much revenue has moved from deferred → recognised. + * 2. Writes a recognition journal entry to the audit log. + * 3. Updates the subscription's deferred / recognised balances in the DB. + * 4. Emits a Prometheus metric for observability. + * + * Edge cases handled: + * - Free trials: entries with amount = 0 are skipped. + * - Early termination: accelerated entries (periodEnd ≤ now) are fully recognised. + * - Contract modifications: the schedule is rebuilt; old entries are closed. + */ + +export interface RevenueScheduleEntry { + periodStart: number; // Unix ms + periodEnd: number; // Unix ms + recognisedAmount: number; + isRecognised: boolean; +} + +export interface RevenueSchedule { + subscriptionId: string; + merchantId: string; + totalAmount: number; + chargeDate: number; + entries: RevenueScheduleEntry[]; +} + +export interface RecognitionJournalEntry { + id: string; + subscriptionId: string; + merchantId: string; + periodStart: number; + periodEnd: number; + recognisedAmount: number; + deferredBefore: number; + deferredAfter: number; + createdAt: number; + type: 'scheduled' | 'accelerated' | 'partial'; +} + +export interface RevenueRecognitionRepository { + /** Return all schedules where at least one entry is not yet recognised. */ + getPendingSchedules(): Promise; + /** Persist a recognition journal entry. */ + writeJournalEntry(entry: RecognitionJournalEntry): Promise; + /** Update the deferred / recognised balances for a merchant. */ + updateMerchantBalances( + merchantId: string, + delta: { recognisedDelta: number; deferredDelta: number } + ): Promise; + /** Mark individual schedule entries as recognised. */ + markEntriesRecognised(subscriptionId: string, entryPeriodStarts: number[]): Promise; +} + +export interface RecognitionJobMetrics { + schedulesProcessed: number; + entriesRecognised: number; + totalAmountRecognised: number; + errorCount: number; + durationMs: number; +} + +/** Monotonically increasing ID sequence (in-process only; use UUID in production). */ +let _seq = 0; +function nextId(): string { + return `rev-${Date.now().toString(36)}-${(++_seq).toString(36)}`; +} + +/** + * Determine how much of a single schedule entry to recognise as of `now`. + * Returns 0 for entries that haven't started yet or amount = 0 (free trial). + */ +export function computeEntryRecognition( + entry: RevenueScheduleEntry, + now: number +): { amount: number; type: 'scheduled' | 'accelerated' | 'partial' | null } { + if (entry.isRecognised) return { amount: 0, type: null }; + if (entry.recognisedAmount === 0) return { amount: 0, type: null }; // free trial + if (now < entry.periodStart) return { amount: 0, type: null }; + + if (now >= entry.periodEnd) { + return { amount: entry.recognisedAmount, type: 'scheduled' }; + } + + // Pro-rate for partial periods. + const elapsed = now - entry.periodStart; + const duration = entry.periodEnd - entry.periodStart; + const partial = (entry.recognisedAmount * elapsed) / duration; + return { amount: Math.round(partial * 100) / 100, type: 'partial' }; +} + +export class MonthlyRevenueRecognitionJob { + private repo: RevenueRecognitionRepository; + private intervalMs: number; + private timer: ReturnType | null = null; + + // Prometheus-style counters. + private metrics: RecognitionJobMetrics = { + schedulesProcessed: 0, + entriesRecognised: 0, + totalAmountRecognised: 0, + errorCount: 0, + durationMs: 0, + }; + + constructor( + repo: RevenueRecognitionRepository, + options: { intervalMs?: number } = {} + ) { + this.repo = repo; + this.intervalMs = options.intervalMs ?? 24 * 60 * 60 * 1000; // default: daily + } + + start(): void { + if (this.timer) return; + void this.run(); + this.timer = setInterval(() => void this.run(), this.intervalMs); + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + + async run(now: number = Date.now()): Promise { + const start = Date.now(); + const localMetrics: RecognitionJobMetrics = { + schedulesProcessed: 0, + entriesRecognised: 0, + totalAmountRecognised: 0, + errorCount: 0, + durationMs: 0, + }; + + let schedules: RevenueSchedule[] = []; + try { + schedules = await this.repo.getPendingSchedules(); + } catch (err) { + console.error('[RevenueRecognitionJob] Failed to load schedules:', err); + localMetrics.errorCount += 1; + return localMetrics; + } + + for (const schedule of schedules) { + localMetrics.schedulesProcessed += 1; + const recognisedPeriodStarts: number[] = []; + let totalRecognisedForSchedule = 0; + + for (const entry of schedule.entries) { + const { amount, type } = computeEntryRecognition(entry, now); + if (!type || amount <= 0) continue; + + const deferredBefore = schedule.totalAmount - totalRecognisedForSchedule; + const journalEntry: RecognitionJournalEntry = { + id: nextId(), + subscriptionId: schedule.subscriptionId, + merchantId: schedule.merchantId, + periodStart: entry.periodStart, + periodEnd: entry.periodEnd, + recognisedAmount: amount, + deferredBefore, + deferredAfter: deferredBefore - amount, + createdAt: now, + type, + }; + + try { + await this.repo.writeJournalEntry(journalEntry); + totalRecognisedForSchedule += amount; + localMetrics.entriesRecognised += 1; + localMetrics.totalAmountRecognised += amount; + + // Only mark fully-elapsed entries as done. + if (type === 'scheduled' || type === 'accelerated') { + recognisedPeriodStarts.push(entry.periodStart); + } + } catch (err) { + console.error( + `[RevenueRecognitionJob] Journal write failed for ${schedule.subscriptionId}:`, + err + ); + localMetrics.errorCount += 1; + } + } + + if (totalRecognisedForSchedule > 0) { + try { + await this.repo.updateMerchantBalances(schedule.merchantId, { + recognisedDelta: totalRecognisedForSchedule, + deferredDelta: -totalRecognisedForSchedule, + }); + if (recognisedPeriodStarts.length > 0) { + await this.repo.markEntriesRecognised( + schedule.subscriptionId, + recognisedPeriodStarts + ); + } + } catch (err) { + console.error( + `[RevenueRecognitionJob] Balance update failed for ${schedule.merchantId}:`, + err + ); + localMetrics.errorCount += 1; + } + } + } + + localMetrics.durationMs = Date.now() - start; + + // Accumulate global metrics. + this.metrics.schedulesProcessed += localMetrics.schedulesProcessed; + this.metrics.entriesRecognised += localMetrics.entriesRecognised; + this.metrics.totalAmountRecognised += localMetrics.totalAmountRecognised; + this.metrics.errorCount += localMetrics.errorCount; + this.metrics.durationMs = localMetrics.durationMs; + + console.info( + `[RevenueRecognitionJob] Run complete: ${localMetrics.entriesRecognised} entries, $${localMetrics.totalAmountRecognised.toFixed(2)} recognised in ${localMetrics.durationMs}ms` + ); + + return localMetrics; + } + + getMetrics(): RecognitionJobMetrics { + return { ...this.metrics }; + } + + prometheusMetrics(): string { + const lines = [ + '# HELP subtrackr_revenue_recognition_entries_total Total recognition journal entries written', + '# TYPE subtrackr_revenue_recognition_entries_total counter', + `subtrackr_revenue_recognition_entries_total ${this.metrics.entriesRecognised}`, + '# HELP subtrackr_revenue_recognition_amount_total Total amount recognised in currency units', + '# TYPE subtrackr_revenue_recognition_amount_total counter', + `subtrackr_revenue_recognition_amount_total ${this.metrics.totalAmountRecognised.toFixed(2)}`, + '# HELP subtrackr_revenue_recognition_errors_total Total errors during recognition runs', + '# TYPE subtrackr_revenue_recognition_errors_total counter', + `subtrackr_revenue_recognition_errors_total ${this.metrics.errorCount}`, + '# HELP subtrackr_revenue_recognition_duration_ms Last job run duration in ms', + '# TYPE subtrackr_revenue_recognition_duration_ms gauge', + `subtrackr_revenue_recognition_duration_ms ${this.metrics.durationMs}`, + ]; + return lines.join('\n'); + } +} diff --git a/src/screens/RevenueReportScreen.tsx b/src/screens/RevenueReportScreen.tsx index 5d86f40b..3caa9188 100644 --- a/src/screens/RevenueReportScreen.tsx +++ b/src/screens/RevenueReportScreen.tsx @@ -9,6 +9,7 @@ import { Switch, Alert, Dimensions, + Share, } from 'react-native'; import Svg, { Rect, Text as SvgText, Line, G } from 'react-native-svg'; import { spacing, typography, borderRadius } from '../utils/constants'; @@ -60,6 +61,7 @@ const RevenueReportScreen: React.FC = () => { removeRecognitionRule, generateRevenueSchedule, getRevenueAnalyticsByPeriod, + exportWaterfall, } = useAccountingStore(); const [periodRange, setPeriodRange] = useState('month'); @@ -149,6 +151,26 @@ const RevenueReportScreen: React.FC = () => { [removeRecognitionRule, configSubId] ); + const handleExport = useCallback( + async (format: 'csv' | 'json') => { + const nameMap: Record = {}; + subscriptions.forEach((s) => (nameMap[s.id] = s.name)); + const content = exportWaterfall(format, undefined, nameMap); + const mimeType = format === 'csv' ? 'text/csv' : 'application/json'; + const filename = `revenue_waterfall.${format}`; + try { + await Share.share({ message: content, title: filename }); + } catch { + Alert.alert( + `Export (${format.toUpperCase()})`, + `Content type: ${mimeType}\n\n${content.slice(0, 400)}${content.length > 400 ? '\n…' : ''}`, + [{ text: 'Close' }] + ); + } + }, + [subscriptions, exportWaterfall] + ); + // ── Render ──────────────────────────────────────────────────────────────── if (!subscriptions.length) { @@ -188,6 +210,23 @@ const RevenueReportScreen: React.FC = () => { + {/* Export buttons */} + + Export waterfall: + void handleExport('csv')} + accessibilityLabel="Export revenue waterfall as CSV"> + CSV + + void handleExport('json')} + accessibilityLabel="Export revenue waterfall as JSON"> + JSON + + + {/* Period selector */} {(['month', 'quarter', 'year'] as PeriodRange[]).map((p) => ( @@ -373,6 +412,23 @@ function createStyles(colors: ReturnType) { summaryLabel: { ...typography.caption, color: colors.textSecondary, marginBottom: spacing.xs }, summaryValue: { ...typography.h2, fontWeight: '700' }, + exportRow: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.lg, + marginBottom: spacing.md, + gap: spacing.sm, + }, + exportLabel: { ...typography.caption, color: colors.textSecondary, flex: 1 }, + exportBtn: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs, + borderRadius: borderRadius.sm, + borderWidth: 1, + borderColor: colors.primary, + }, + exportBtnText: { ...typography.caption, color: colors.primary, fontWeight: '600' }, + periodRow: { flexDirection: 'row', paddingHorizontal: spacing.lg, diff --git a/src/screens/SlaDashboard.tsx b/src/screens/SlaDashboard.tsx index cdf1c30e..3046f0d1 100644 --- a/src/screens/SlaDashboard.tsx +++ b/src/screens/SlaDashboard.tsx @@ -38,6 +38,7 @@ const SlaDashboard: React.FC = () => { const [measurementInterval, setMeasurementInterval] = useState( String(SLA_DEFAULTS.measurementInterval) ); + const [creditCap, setCreditCap] = useState('0'); const [durationSeconds, setDurationSeconds] = useState('3600'); const [state, setState] = useState('healthy'); const [note, setNote] = useState(''); @@ -53,6 +54,7 @@ const SlaDashboard: React.FC = () => { void configureSla(merchantId.trim(), { uptimeTarget: Number(uptimeTarget), measurementInterval: Number(measurementInterval), + creditCap: Number(creditCap), }); }; @@ -127,10 +129,23 @@ const SlaDashboard: React.FC = () => { Save SLA + {merchantConfig && ( - Target {merchantConfig.uptimeTarget}% over {merchantConfig.measurementInterval}{' '} - seconds + Target {merchantConfig.uptimeTarget}% over {merchantConfig.measurementInterval}s + {(merchantConfig.creditCap ?? 0) > 0 + ? ` · credit cap ${merchantConfig.creditCap}` + : ''} + {(merchantConfig.exclusionWindows?.length ?? 0) > 0 + ? ` · ${merchantConfig.exclusionWindows!.length} exclusion window(s)` + : ''} )} diff --git a/src/services/slaService.ts b/src/services/slaService.ts index a75a80df..04350e54 100644 --- a/src/services/slaService.ts +++ b/src/services/slaService.ts @@ -30,6 +30,28 @@ function clampPercentage(value: number): number { return Math.min(100, Math.max(0, Number(value.toFixed(2)))); } +/** + * Returns true if the given UTC timestamp falls within any of the config's + * exclusion windows (scheduled maintenance). These seconds are excluded from + * the SLA measurement window entirely. + */ +export function isInExclusionWindow( + timestampMs: number, + exclusionWindows: SlaConfig['exclusionWindows'] = [] +): boolean { + if (!exclusionWindows || exclusionWindows.length === 0) return false; + const date = new Date(timestampMs); + const dayOfWeek = date.getUTCDay(); + const secondOfDay = date.getUTCHours() * 3600 + date.getUTCMinutes() * 60 + date.getUTCSeconds(); + for (const w of exclusionWindows) { + if (w.dayOfWeek !== -1 && w.dayOfWeek !== dayOfWeek) continue; + if (secondOfDay >= w.startSecond && secondOfDay < w.startSecond + w.durationSeconds) { + return true; + } + } + return false; +} + export function normalizeSlaConfig(merchantId: string, input: Partial): SlaConfig { return { merchantId, @@ -42,6 +64,18 @@ export function normalizeSlaConfig(merchantId: string, input: Partial subscriberContacts: Array.isArray(input.subscriberContacts) ? [...input.subscriberContacts] : [], + creditCap: + Number.isFinite(input.creditCap) && (input.creditCap ?? 0) > 0 + ? Number(input.creditCap) + : 0, + exclusionWindows: Array.isArray(input.exclusionWindows) + ? input.exclusionWindows.map((w) => ({ + label: String(w.label ?? ''), + dayOfWeek: typeof w.dayOfWeek === 'number' ? w.dayOfWeek : -1, + startSecond: Math.max(0, Math.floor(Number(w.startSecond ?? 0))), + durationSeconds: Math.max(1, Math.floor(Number(w.durationSeconds ?? 1))), + })) + : [], }; } @@ -67,14 +101,16 @@ export function calculateUptimePercentage( } export function calculateCreditAmount( - breach: Pick + breach: Pick, + creditCap = 0 ): number { if (breach.uptimePercentage >= breach.uptimeTarget) return 0; const deficit = breach.uptimeTarget - breach.uptimePercentage; const normalizedDeficit = deficit / Math.max(breach.uptimeTarget, 1); const rawCredit = normalizedDeficit * breach.measurementInterval * 100; - return Math.max(1, Math.round(rawCredit)); + const credit = Math.max(1, Math.round(rawCredit)); + return creditCap > 0 ? Math.min(credit, creditCap) : credit; } export function calculateMerchantStatus( @@ -97,6 +133,9 @@ export function calculateMerchantStatus( const overlapEnd = Math.min(eventEnd, now); if (overlapEnd <= overlapStart) continue; + // Skip events that fall within a scheduled exclusion window. + if (isInExclusionWindow(overlapStart, config.exclusionWindows)) continue; + const overlapSeconds = (overlapEnd - overlapStart) / 1000; const impact = calculateAvailabilityImpact({ ...event, durationSeconds: overlapSeconds }); observedSeconds += overlapSeconds; @@ -169,7 +208,7 @@ export function evaluateMerchantSnapshot( uptimeTarget: status.uptimeTarget, uptimePercentage: status.uptimePercentage, measurementInterval: status.measurementInterval, - }), + }, input.config.creditCap ?? 0), resolvedAt: null, acknowledged: false, }; diff --git a/src/store/accountingStore.ts b/src/store/accountingStore.ts index 4de7889d..c8a32c35 100644 --- a/src/store/accountingStore.ts +++ b/src/store/accountingStore.ts @@ -1,5 +1,5 @@ /** - * accountingStore – revenue recognition accounting state. + * accountingStore – revenue recognition accounting state (ASC 606 / IFRS 15). * * Implements: * - RevenueRecognitionRule (method + recognition_period) @@ -8,6 +8,10 @@ * - Revenue schedule generation * - Multi-element arrangement accounting * - Revenue analytics by period + * - Contract modifications (upgrade/downgrade) → schedule re-creation + * - Early termination with revenue acceleration + * - Free-trial support (zero revenue until conversion) + * - CSV / JSON export for ERP integration (QuickBooks, Xero) */ import { create } from 'zustand'; @@ -58,6 +62,43 @@ export interface PeriodRevenue { subscriptionCount: number; } +/** Represents an audit journal entry for ASC 606 compliance. */ +export interface RevenueJournalEntry { + id: string; + subscriptionId: string; + merchantId: string; + type: + | 'charge' + | 'recognition' + | 'modification' + | 'termination' + | 'acceleration' + | 'trial_conversion'; + amount: number; + debitAccount: string; + creditAccount: string; + timestamp: number; // Unix ms + description: string; +} + +/** Waterfall row for the Revenue Report: deferred / recognised / realised. */ +export interface RevenueWaterfallRow { + subscriptionId: string; + subscriptionName: string; + totalCharged: number; + recognised: number; + deferred: number; + realised: number; // recognised that has passed its period end +} + +/** Contract modification descriptor. */ +export interface ContractModification { + type: 'upgrade' | 'downgrade'; + newAmount: number; + newBillingCycle: BillingCycle; + effectiveDate: number; // Unix ms +} + // ── Store state & actions ───────────────────────────────────────────────────── interface AccountingState { @@ -69,6 +110,10 @@ interface AccountingState { deferredRevenue: Record; /** Cumulative recognised revenue per merchantId (or 'default'). */ recognisedRevenue: Record; + /** ASC 606 audit journal entries. */ + journalEntries: RevenueJournalEntry[]; + /** Subscriptions currently in a free-trial period (subscriptionId → trialEndDate ms). */ + trialSubscriptions: Record; // ── Actions ── @@ -115,6 +160,65 @@ interface AccountingState { /** Flush all accounting data (useful for testing). */ reset: () => void; + + // ── ASC 606 extended features ── + + /** + * Handle a contract modification (upgrade/downgrade). + * Re-schedules remaining deferred revenue prospectively from effectiveDate. + */ + applyContractModification: ( + subscriptionId: string, + modification: ContractModification, + merchantId?: string + ) => RevenueSchedule; + + /** + * Accelerate deferred revenue on early termination. + * All remaining deferred entries become recognised immediately. + */ + applyEarlyTermination: ( + subscriptionId: string, + terminationDate: number, + merchantId?: string + ) => void; + + /** + * Mark a subscription as being in a free-trial period. + * No revenue is generated; schedule is zero until conversion. + */ + startFreeTrial: (subscriptionId: string, trialEndDate: number) => void; + + /** + * Convert a trial subscription — generate the real revenue schedule from today. + */ + convertTrialToActive: ( + subscriptionId: string, + totalAmount: number, + billingCycle: BillingCycle, + merchantId?: string + ) => RevenueSchedule; + + /** Returns all journal entries, optionally filtered by subscriptionId or merchantId. */ + getJournalEntries: (filter?: { + subscriptionId?: string; + merchantId?: string; + }) => RevenueJournalEntry[]; + + /** Compute a revenue waterfall for the given subscriptionIds (or all if omitted). */ + getRevenueWaterfall: ( + subscriptionIds?: string[], + subscriptionNames?: Record + ) => RevenueWaterfallRow[]; + + /** + * Export the revenue waterfall as CSV (string) or JSON (string). + */ + exportWaterfall: ( + format: 'csv' | 'json', + subscriptionIds?: string[], + subscriptionNames?: Record + ) => string; } // ── Pure helpers ────────────────────────────────────────────────────────────── @@ -221,11 +325,18 @@ export function splitRecognisedDeferred( const STORAGE_KEY = 'subtrackr-accounting'; const DEFAULT_MERCHANT = 'default'; +let _journalSeq = 0; +function nextJournalId(): string { + return `jrn-${Date.now().toString(36)}-${(++_journalSeq).toString(36)}`; +} + const initialState = { rules: {} as Record, schedules: {} as Record, deferredRevenue: {} as Record, recognisedRevenue: {} as Record, + journalEntries: [] as RevenueJournalEntry[], + trialSubscriptions: {} as Record, // subscriptionId → trialEndDate }; export const useAccountingStore = create()( @@ -257,6 +368,16 @@ export const useAccountingStore = create()( const rule = get().rules[subscriptionId]; const intervalMs = billingCycleToMs(billingCycle); + // Skip revenue generation for active free-trial subscriptions. + const trialEnd = get().trialSubscriptions[subscriptionId]; + if (trialEnd !== undefined && chargeDate < trialEnd) { + const schedule = buildStraightLineSchedule(subscriptionId, 0, chargeDate, intervalMs, 1); + set((state) => ({ + schedules: { ...state.schedules, [subscriptionId]: schedule }, + })); + return schedule; + } + let schedule: RevenueSchedule; if (rule) { @@ -283,6 +404,18 @@ export const useAccountingStore = create()( ); } + const journalEntry: RevenueJournalEntry = { + id: nextJournalId(), + subscriptionId, + merchantId, + type: 'charge', + amount: totalAmount, + debitAccount: 'Cash', + creditAccount: 'Deferred Revenue', + timestamp: chargeDate, + description: `Charge $${totalAmount.toFixed(2)} deferred on billing cycle start`, + }; + set((state) => ({ schedules: { ...state.schedules, [subscriptionId]: schedule }, // All newly charged revenue starts as deferred. @@ -290,6 +423,7 @@ export const useAccountingStore = create()( ...state.deferredRevenue, [merchantId]: (state.deferredRevenue[merchantId] ?? 0) + totalAmount, }, + journalEntries: [...state.journalEntries, journalEntry], })); return schedule; @@ -354,6 +488,212 @@ export const useAccountingStore = create()( }, reset: () => set(initialState), + + // ── ASC 606 extended features ──────────────────────────────────────────── + + applyContractModification: (subscriptionId, modification, merchantId = DEFAULT_MERCHANT) => { + const now = modification.effectiveDate; + const newIntervalMs = billingCycleToMs(modification.newBillingCycle); + const rule = get().rules[subscriptionId]; + + // Build a fresh schedule from the modification effective date. + let newSchedule: RevenueSchedule; + if (rule?.method === 'usage-based') { + newSchedule = buildUsageBasedSchedule( + subscriptionId, + modification.newAmount, + now, + newIntervalMs + ); + } else { + const periodMs = rule?.recognitionPeriodMs ?? newIntervalMs; + const numPeriods = Math.max(1, Math.ceil(newIntervalMs / periodMs)); + newSchedule = buildStraightLineSchedule( + subscriptionId, + modification.newAmount, + now, + periodMs, + numPeriods + ); + } + + const journalEntry: RevenueJournalEntry = { + id: nextJournalId(), + subscriptionId, + merchantId, + type: 'modification', + amount: modification.newAmount, + debitAccount: 'Deferred Revenue', + creditAccount: 'Revenue', + timestamp: now, + description: `Contract ${modification.type} to $${modification.newAmount.toFixed(2)} — schedule re-created prospectively`, + }; + + set((state) => ({ + schedules: { ...state.schedules, [subscriptionId]: newSchedule }, + deferredRevenue: { + ...state.deferredRevenue, + [merchantId]: (state.deferredRevenue[merchantId] ?? 0) + modification.newAmount, + }, + journalEntries: [...state.journalEntries, journalEntry], + })); + + return newSchedule; + }, + + applyEarlyTermination: (subscriptionId, terminationDate, merchantId = DEFAULT_MERCHANT) => { + const schedule = get().schedules[subscriptionId]; + if (!schedule) return; + + // Accelerate: mark every future entry as recognised at terminationDate. + const { deferred } = splitRecognisedDeferred(schedule, terminationDate); + if (deferred <= 0) return; + + const acceleratedEntries: RevenueScheduleEntry[] = schedule.entries.map((entry) => { + if (terminationDate >= entry.periodEnd) return entry; + if (terminationDate < entry.periodStart) { + // Fully future — recognise the whole entry immediately. + return { ...entry, periodEnd: terminationDate, isRecognised: true }; + } + // Partially elapsed — recognise remaining. + return { ...entry, periodEnd: terminationDate, isRecognised: true }; + }); + + const acceleratedSchedule: RevenueSchedule = { + ...schedule, + entries: acceleratedEntries, + }; + + const journalEntry: RevenueJournalEntry = { + id: nextJournalId(), + subscriptionId, + merchantId, + type: 'acceleration', + amount: deferred, + debitAccount: 'Deferred Revenue', + creditAccount: 'Revenue', + timestamp: terminationDate, + description: `Early termination — $${deferred.toFixed(2)} deferred revenue accelerated`, + }; + + set((state) => ({ + schedules: { ...state.schedules, [subscriptionId]: acceleratedSchedule }, + recognisedRevenue: { + ...state.recognisedRevenue, + [merchantId]: (state.recognisedRevenue[merchantId] ?? 0) + deferred, + }, + deferredRevenue: { + ...state.deferredRevenue, + [merchantId]: Math.max( + 0, + (state.deferredRevenue[merchantId] ?? 0) - deferred + ), + }, + journalEntries: [...state.journalEntries, journalEntry], + })); + }, + + startFreeTrial: (subscriptionId, trialEndDate) => { + set((state) => ({ + trialSubscriptions: { + ...state.trialSubscriptions, + [subscriptionId]: trialEndDate, + }, + })); + }, + + convertTrialToActive: ( + subscriptionId, + totalAmount, + billingCycle, + merchantId = DEFAULT_MERCHANT + ) => { + const conversionDate = Date.now(); + + // Remove from trial tracking. + set((state) => { + const trialSubscriptions = { ...state.trialSubscriptions }; + delete trialSubscriptions[subscriptionId]; + return { trialSubscriptions }; + }); + + const journalEntry: RevenueJournalEntry = { + id: nextJournalId(), + subscriptionId, + merchantId, + type: 'trial_conversion', + amount: totalAmount, + debitAccount: 'Cash', + creditAccount: 'Deferred Revenue', + timestamp: conversionDate, + description: `Trial conversion — $${totalAmount.toFixed(2)} charged, recognition schedule created`, + }; + + set((state) => ({ + journalEntries: [...state.journalEntries, journalEntry], + })); + + return get().generateRevenueSchedule( + subscriptionId, + totalAmount, + conversionDate, + billingCycle, + merchantId + ); + }, + + getJournalEntries: (filter) => { + const entries = get().journalEntries; + if (!filter) return entries; + return entries.filter( + (e) => + (!filter.subscriptionId || e.subscriptionId === filter.subscriptionId) && + (!filter.merchantId || e.merchantId === filter.merchantId) + ); + }, + + getRevenueWaterfall: (subscriptionIds, subscriptionNames = {}) => { + const now = Date.now(); + const ids = subscriptionIds ?? Object.keys(get().schedules); + return ids.map((subscriptionId) => { + const schedule = get().schedules[subscriptionId]; + if (!schedule) { + return { + subscriptionId, + subscriptionName: subscriptionNames[subscriptionId] ?? subscriptionId, + totalCharged: 0, + recognised: 0, + deferred: 0, + realised: 0, + }; + } + const { recognised, deferred } = splitRecognisedDeferred(schedule, now); + const realised = schedule.entries + .filter((e) => e.periodEnd <= now) + .reduce((sum, e) => sum + e.recognisedAmount, 0); + return { + subscriptionId, + subscriptionName: subscriptionNames[subscriptionId] ?? subscriptionId, + totalCharged: schedule.totalAmount, + recognised, + deferred, + realised, + }; + }); + }, + + exportWaterfall: (format, subscriptionIds, subscriptionNames) => { + const rows = get().getRevenueWaterfall(subscriptionIds, subscriptionNames); + if (format === 'json') return JSON.stringify(rows, null, 2); + + // CSV export + const header = 'Subscription ID,Name,Total Charged,Recognised,Deferred,Realised'; + const lines = rows.map( + (r) => + `"${r.subscriptionId}","${r.subscriptionName}",${r.totalCharged.toFixed(2)},${r.recognised.toFixed(2)},${r.deferred.toFixed(2)},${r.realised.toFixed(2)}` + ); + return [header, ...lines].join('\n'); + }, }), { name: STORAGE_KEY, diff --git a/src/types/sla.ts b/src/types/sla.ts index b0dc5b7e..7fcd8797 100644 --- a/src/types/sla.ts +++ b/src/types/sla.ts @@ -1,10 +1,25 @@ export type SlaAvailabilityState = 'healthy' | 'partial_outage' | 'full_outage' | 'maintenance'; +export interface SlaExclusionWindow { + /** Label for the window, e.g. "Weekly maintenance" */ + label: string; + /** Day of week (0=Sun … 6=Sat), or -1 to match any day */ + dayOfWeek: number; + /** Start time in seconds from midnight (UTC) */ + startSecond: number; + /** Duration in seconds */ + durationSeconds: number; +} + export interface SlaConfig { merchantId: string; uptimeTarget: number; measurementInterval: number; subscriberContacts?: string[]; + /** Maximum credit that can be issued per measurement interval (0 = no cap). */ + creditCap?: number; + /** Scheduled windows excluded from SLA measurement (planned maintenance). */ + exclusionWindows?: SlaExclusionWindow[]; } export interface SlaAvailabilityEvent {