diff --git a/backend/services/billing/dunningService.ts b/backend/services/billing/dunningService.ts index 00e42585..304ef578 100644 --- a/backend/services/billing/dunningService.ts +++ b/backend/services/billing/dunningService.ts @@ -6,8 +6,11 @@ import type { DunningEntry, DunningStage, DunningStageConfig, + FailureReason, + RetryStrategy } from '../../../src/types/dunning'; import { DEFAULT_DUNNING_STAGES, DUNNING_TEMPLATES } from '../../../src/types/dunning'; +import type { IDunningService } from './interfaces'; const ONE_HOUR_MS = 3_600_000; const ONE_DAY_MS = 86_400_000; @@ -72,20 +75,38 @@ export class DunningService { configurePlan(planId: string, config: Partial): DunningConfiguration { const existing = this.configurations.get(planId); + + const defaultStrategy: RetryStrategy = config.defaultStrategy ?? existing?.defaultStrategy ?? { + stages: DEFAULT_DUNNING_STAGES, + maxRetries: 3, + retryIntervalHours: 1, + warnAfterFailures: 3, + suspendAfterDays: 3, + cancelAfterDays: 7, + communicationChannels: ['email', 'push'], + }; + const merged: DunningConfiguration = { planId, - stages: config.stages ?? existing?.stages ?? DEFAULT_DUNNING_STAGES, - maxRetries: config.maxRetries ?? existing?.maxRetries ?? 3, - retryIntervalHours: config.retryIntervalHours ?? existing?.retryIntervalHours ?? 1, - warnAfterFailures: config.warnAfterFailures ?? existing?.warnAfterFailures ?? 3, - suspendAfterDays: config.suspendAfterDays ?? existing?.suspendAfterDays ?? 3, - cancelAfterDays: config.cancelAfterDays ?? existing?.cancelAfterDays ?? 7, - communicationChannels: config.communicationChannels ?? existing?.communicationChannels ?? ['email', 'push'], + defaultStrategy, + strategies: config.strategies ?? existing?.strategies ?? {}, + abTestConfig: config.abTestConfig ?? existing?.abTestConfig, }; + this.configurations.set(planId, merged); return merged; } + configureABTest(planId: string, enabled: boolean, variants: Array<{ id: string; weight: number; strategy: RetryStrategy }>): void { + const config = this.configurations.get(planId); + if (config) { + config.abTestConfig = { enabled, variants }; + this.configurations.set(planId, config); + } else { + this.configurePlan(planId, { abTestConfig: { enabled, variants } }); + } + } + getConfiguration(planId: string): DunningConfiguration | undefined { return this.configurations.get(planId); } @@ -128,6 +149,7 @@ export class DunningService { subscriberId: string, merchantId: string, planId: string, + failureReason: FailureReason = 'default' ): DunningEntry { const existing = this.entries.get(subscriptionId); if (existing) { @@ -135,7 +157,22 @@ export class DunningService { } const config = this.configurations.get(planId); - const firstStage = config?.stages[0] ?? DEFAULT_DUNNING_STAGES[0]; + let abTestVariant: string | undefined; + if (config?.abTestConfig?.enabled && config.abTestConfig.variants.length > 0) { + // Pick variant randomly based on weight + const totalWeight = config.abTestConfig.variants.reduce((sum, v) => sum + v.weight, 0); + let r = Math.random() * totalWeight; + for (const v of config.abTestConfig.variants) { + r -= v.weight; + if (r <= 0) { + abTestVariant = v.id; + break; + } + } + } + + const strategy = this.getStrategy(planId, failureReason, abTestVariant); + const firstStage = strategy.stages[0] ?? DEFAULT_DUNNING_STAGES[0]; const now_ts = now(); const entry: DunningEntry = { @@ -144,6 +181,8 @@ export class DunningService { subscriberId, merchantId, planId, + failureReason, + abTestVariant, currentStage: firstStage.stage, failedAttempts: 0, totalFailedCharges: 0, @@ -201,8 +240,8 @@ export class DunningService { 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]; + if (nextStageIndex < strategy.stages.length) { + const nextStage = strategy.stages[nextStageIndex]; entry.currentStage = nextStage.stage; entry.failedAttempts = 0; entry.nextActionAt = now_ts + nextStage.delayHours * ONE_HOUR_MS; @@ -233,8 +272,10 @@ export class DunningService { }); } + entry.updatedAt = now(); + this.recoveredEntries.push(entry); + this.entries.delete(subscriptionId); - this.communicationLog.delete(subscriptionId); } getDunningEntry(subscriptionId: string): DunningEntry | undefined { @@ -262,8 +303,8 @@ export class DunningService { const entry = this.entries.get(subscriptionId); if (!entry) return null; - const config = this.configurations.get(entry.planId); - const stageConfig = config?.stages.find((s) => s.stage === entry.currentStage); + const strategy = this.getStrategy(entry.planId, entry.failureReason, entry.abTestVariant); + const stageConfig = strategy.stages.find((s) => s.stage === entry.currentStage); entry.isPaused = false; entry.nextActionAt = now() + (stageConfig?.delayHours ?? 24) * ONE_HOUR_MS; entry.updatedAt = now(); @@ -275,8 +316,8 @@ export class DunningService { const entry = this.entries.get(subscriptionId); if (!entry) return null; - const config = this.configurations.get(entry.planId); - const stageConfig = config?.stages.find((s) => s.stage === stage); + const strategy = this.getStrategy(entry.planId, entry.failureReason, entry.abTestVariant); + const stageConfig = strategy.stages.find((s) => s.stage === stage); entry.currentStage = stage; entry.failedAttempts = 0; entry.nextActionAt = now() + (stageConfig?.delayHours ?? 24) * ONE_HOUR_MS; @@ -369,6 +410,10 @@ export class DunningService { getAnalytics(merchantId?: string): DunningAnalytics { const allEntries = this.listActiveDunning(merchantId); + const recovered = merchantId + ? this.recoveredEntries.filter(e => e.merchantId === merchantId) + : this.recoveredEntries; + const stageBreakdown: Record = { retry: 0, warn: 0, @@ -376,8 +421,12 @@ export class DunningService { cancel: 0, }; + let totalLost = 0; for (const entry of allEntries) { stageBreakdown[entry.currentStage] = (stageBreakdown[entry.currentStage] ?? 0) + 1; + if (entry.currentStage === 'cancel') { + totalLost++; + } } const retryAnalytics = this.getRetryAnalytics(merchantId); @@ -399,7 +448,7 @@ export class DunningService { } private sendCommunication(entry: DunningEntry, stageConfig: DunningStageConfig): DunningCommunication { - const template = DUNNING_TEMPLATES.find((t) => t.id === stageConfig.templateId); + const template = this.templates.find((t) => t.id === stageConfig.templateId); const comm: DunningCommunication = { id: createId('dcom'), stage: stageConfig.stage, @@ -427,6 +476,27 @@ export class DunningService { (e) => !e.isPaused && e.nextActionAt <= now_ts ); } + + addTemplate(template: DunningCommunicationTemplate): void { + if (!this.templates.find(t => t.id === template.id)) { + this.templates.push(template); + } + } + + updateTemplate(id: string, template: Partial): void { + const index = this.templates.findIndex(t => t.id === id); + if (index !== -1) { + this.templates[index] = { ...this.templates[index], ...template }; + } + } + + removeTemplate(id: string): void { + this.templates = this.templates.filter(t => t.id !== id); + } + + getTemplates(): DunningCommunicationTemplate[] { + return [...this.templates]; + } } export const dunningService = new DunningService(); diff --git a/backend/services/billing/interfaces.ts b/backend/services/billing/interfaces.ts index b745d762..5e98d496 100644 --- a/backend/services/billing/interfaces.ts +++ b/backend/services/billing/interfaces.ts @@ -14,6 +14,9 @@ import { DunningStage, DunningCommunication, DunningAnalytics, + FailureReason, + DunningCommunicationTemplate, + RetryStrategy } from '../../../src/types/dunning'; import { TransactionRecord, @@ -93,6 +96,7 @@ export interface ITaxService { export interface IDunningService { configurePlan(planId: string, config: Partial): DunningConfiguration; + configureABTest(planId: string, enabled: boolean, variants: Array<{ id: string; weight: number; strategy: RetryStrategy }>): void; getConfiguration(planId: string): DunningConfiguration | undefined; startDunning(subscriptionId: string, subscriberId: string, merchantId: string, planId: string): DunningEntry; recordFailedCharge(subscriptionId: string, failureType?: string): DunningEntry | null; diff --git a/src/store/dunningStore.ts b/src/store/dunningStore.ts index f2fbd6d7..95102458 100644 --- a/src/store/dunningStore.ts +++ b/src/store/dunningStore.ts @@ -8,6 +8,8 @@ import { DunningConfiguration, DunningCommunication, DEFAULT_DUNNING_STAGES, + FailureReason, + RetryStrategy, } from '../types/dunning'; const STORAGE_KEY = 'subtrackr-dunning'; @@ -122,7 +124,8 @@ export interface DunningState { subscriptionId: string, subscriberId: string, merchantId: string, - planId?: string + planId?: string, + failureReason?: FailureReason ) => DunningEntry; recordPaymentAttempt: ( subscriptionId: string, @@ -157,8 +160,7 @@ export interface DunningState { clearError: () => void; } -const DEFAULT_CONFIG: DunningConfiguration = { - planId: 'default', +const DEFAULT_STRATEGY: RetryStrategy = { stages: DEFAULT_DUNNING_STAGES, maxRetries: RETRY_SCHEDULE_DAYS.length, retryIntervalHours: 24, @@ -168,6 +170,27 @@ const DEFAULT_CONFIG: DunningConfiguration = { communicationChannels: ['email', 'push', 'in_app'], }; +const DEFAULT_CONFIG: DunningConfiguration = { + planId: 'default', + defaultStrategy: DEFAULT_STRATEGY, + strategies: {}, +}; + +function getStrategy( + config: DunningConfiguration, + failureReason?: FailureReason, + abTestVariant?: string +): RetryStrategy { + if (config.abTestConfig?.enabled && abTestVariant) { + const variant = config.abTestConfig.variants.find((v) => v.id === abTestVariant); + if (variant) return variant.strategy; + } + if (failureReason && config.strategies[failureReason]) { + return config.strategies[failureReason]!; + } + return config.defaultStrategy; +} + export const useDunningStore = create()( persist( (set, get) => ({ @@ -178,12 +201,19 @@ export const useDunningStore = create()( isLoading: false, error: null, - startDunning: (subscriptionId, subscriberId, merchantId, planId = 'default') => { + startDunning: ( + subscriptionId, + subscriberId, + merchantId, + planId = 'default', + failureReason = 'default' + ) => { const existing = get().entries.find((e) => e.subscriptionId === subscriptionId); if (existing) return existing; const config = get().configurations[planId] ?? DEFAULT_CONFIG; - const firstStage = config.stages[0] ?? DEFAULT_DUNNING_STAGES[0]; + const strategy = getStrategy(config, failureReason); + const firstStage = strategy.stages[0] ?? DEFAULT_DUNNING_STAGES[0]; const ts = now(); const entry: DunningEntry = { @@ -192,6 +222,7 @@ export const useDunningStore = create()( subscriberId, merchantId, planId, + failureReason, currentStage: firstStage.stage, failedAttempts: 0, totalFailedCharges: 0, @@ -233,10 +264,13 @@ export const useDunningStore = create()( return null; } + const newFailureReason = failureReason ?? entry.failureReason; const config = get().configurations[entry.planId] ?? DEFAULT_CONFIG; + const strategy = getStrategy(config, newFailureReason, entry.abTestVariant); const ts = now(); - const stageIdx = config.stages.findIndex((s) => s.stage === entry.currentStage); - const stageConfig = config.stages[stageIdx]; + + const stageIdx = strategy.stages.findIndex((s) => s.stage === entry.currentStage); + const stageConfig = strategy.stages[stageIdx]; const newFailedAttempts = entry.failedAttempts + 1; get().retryHistory.push({ @@ -249,7 +283,7 @@ export const useDunningStore = create()( }); let nextStage: DunningStage = entry.currentStage; - let nextDelay = config.retryIntervalHours * ONE_HOUR_MS; + let nextDelay = strategy.retryIntervalHours * ONE_HOUR_MS; const newComm: DunningCommunication = { id: createId('dcom'), stage: entry.currentStage, @@ -262,9 +296,9 @@ export const useDunningStore = create()( if (newFailedAttempts >= schedule.maxRetries) { const nextIdx = stageIdx + 1; - if (nextIdx < config.stages.length) { - nextStage = config.stages[nextIdx].stage; - nextDelay = config.stages[nextIdx].delayHours * ONE_HOUR_MS; + if (nextIdx < strategy.stages.length) { + nextStage = strategy.stages[nextIdx].stage; + nextDelay = strategy.stages[nextIdx].delayHours * ONE_HOUR_MS; } else { nextStage = 'cancel'; nextDelay = 24 * ONE_HOUR_MS; @@ -291,6 +325,7 @@ export const useDunningStore = create()( e.subscriptionId === subscriptionId ? { ...e, + failureReason: newFailureReason, currentStage: nextStage, failedAttempts: nextStage !== entry.currentStage ? 0 : newFailedAttempts, totalFailedCharges: e.totalFailedCharges + 1, @@ -357,7 +392,8 @@ export const useDunningStore = create()( const entry = get().entries.find((e) => e.subscriptionId === subscriptionId); if (!entry) return; const config = get().configurations[entry.planId] ?? DEFAULT_CONFIG; - const stageConfig = config.stages.find((s) => s.stage === entry.currentStage); + const strategy = getStrategy(config, entry.failureReason, entry.abTestVariant); + const stageConfig = strategy.stages.find((s) => s.stage === entry.currentStage); const delay = (stageConfig?.delayHours ?? 24) * ONE_HOUR_MS; set((s) => ({ @@ -373,7 +409,8 @@ export const useDunningStore = create()( const entry = get().entries.find((e) => e.subscriptionId === subscriptionId); if (!entry) return; const config = get().configurations[entry.planId] ?? DEFAULT_CONFIG; - const stageConfig = config.stages.find((s) => s.stage === stage); + const strategy = getStrategy(config, entry.failureReason, entry.abTestVariant); + const stageConfig = strategy.stages.find((s) => s.stage === stage); const delay = (stageConfig?.delayHours ?? 24) * ONE_HOUR_MS; set((s) => ({ diff --git a/src/store/subscriptionStore.ts b/src/store/subscriptionStore.ts index 90d80990..4b42c552 100644 --- a/src/store/subscriptionStore.ts +++ b/src/store/subscriptionStore.ts @@ -997,7 +997,11 @@ export const useSubscriptionStore = create()( } }, - recordBillingOutcome: async (id: string, outcome: 'success' | 'failed') => { + recordBillingOutcome: async ( + id: string, + outcome: 'success' | 'failed', + failureReason?: FailureReason + ) => { const sub = get().subscriptions.find((s) => s.id === id); if (!sub) return; @@ -1010,6 +1014,7 @@ export const useSubscriptionStore = create()( dunningEntries[id] = { failedAttempts: attempt, + failureReason: failureReason || 'default', lastFailureAt: new Date().toISOString(), currentStage: attempt <= 3 ? 'retry' : attempt <= 5 ? 'warn' : attempt <= 7 ? 'suspend' : 'cancel', diff --git a/src/types/dunning.ts b/src/types/dunning.ts index 541ecc5a..26fa1fea 100644 --- a/src/types/dunning.ts +++ b/src/types/dunning.ts @@ -1,7 +1,7 @@ export type DunningStage = 'retry' | 'warn' | 'suspend' | 'cancel'; +export type FailureReason = 'insufficient_funds' | 'expired_card' | 'network' | 'default'; -export interface DunningConfiguration { - planId: string; +export interface RetryStrategy { stages: DunningStageConfig[]; maxRetries: number; retryIntervalHours: number; @@ -11,6 +11,16 @@ export interface DunningConfiguration { communicationChannels: ('email' | 'push' | 'in_app')[]; } +export interface DunningConfiguration { + planId: string; + defaultStrategy: RetryStrategy; + strategies: Partial>; + abTestConfig?: { + enabled: boolean; + variants: { id: string; weight: number; strategy: RetryStrategy }[]; + }; +} + export interface DunningStageConfig { stage: DunningStage; delayHours: number; @@ -24,6 +34,8 @@ export interface DunningEntry { subscriberId: string; merchantId: string; planId: string; + failureReason: FailureReason; + abTestVariant?: string; currentStage: DunningStage; failedAttempts: number; totalFailedCharges: number;