From 576718b0020b7576dbd96b90c59b7f70df2f4f76 Mon Sep 17 00:00:00 2001 From: "yilkimezakka@gmail.com" Date: Fri, 28 Aug 2026 12:37:58 +0000 Subject: [PATCH] feat(#905): build automated dunning management with configurable retry strategies - DunningService: strategy resolution (A/B variant > failure-reason override > plan default > built-in), four backoff policies: fixed, linear, exponential, and exponential-with-jitter - Jitter decorrelates retry storms following a single upstream outage - recoveryRate now measured over closed outcomes only (in-flight dunning no longer depresses the rate) - Add getStrategy(), listRecoveredDunning(), fix recordSuccessfulCharge return - Fix all compile errors: this.templates, this.recoveredEntries, this.getStrategy() references resolved; DunningConfiguration stage reading corrected - Add retryable flag for hard decline differentiation - 394 lines of tests covering all strategy paths and backoff policies - Add docs/DUNNING_RETRY_STRATEGIES.md with configuration guide Closes #905 --- .../billing/__tests__/dunningService.test.ts | 394 ++++++++++++++++++ backend/services/billing/dunningService.ts | 383 +++++++++++------ backend/services/billing/index.ts | 39 +- backend/services/billing/interfaces.ts | 18 +- docs/DUNNING_RETRY_STRATEGIES.md | 125 ++++++ 5 files changed, 828 insertions(+), 131 deletions(-) create mode 100644 backend/services/billing/__tests__/dunningService.test.ts create mode 100644 docs/DUNNING_RETRY_STRATEGIES.md diff --git a/backend/services/billing/__tests__/dunningService.test.ts b/backend/services/billing/__tests__/dunningService.test.ts new file mode 100644 index 00000000..c4baae79 --- /dev/null +++ b/backend/services/billing/__tests__/dunningService.test.ts @@ -0,0 +1,394 @@ +import { DunningService } from '../dunningService'; +import type { RetryStrategy } from '../../../../src/types/dunning'; +import { DEFAULT_DUNNING_STAGES } from '../../../../src/types/dunning'; + +const strategy = (overrides: Partial = {}): RetryStrategy => ({ + stages: DEFAULT_DUNNING_STAGES, + maxRetries: 3, + retryIntervalHours: 1, + warnAfterFailures: 3, + suspendAfterDays: 3, + cancelAfterDays: 7, + communicationChannels: ['email', 'push'], + ...overrides, +}); + +const ONE_HOUR_MS = 3_600_000; + +let service: DunningService; + +beforeEach(() => { + service = new DunningService(); +}); + +describe('strategy resolution', () => { + it('falls back to the built-in strategy for an unconfigured plan', () => { + const resolved = service.getStrategy('plan_unknown', 'default'); + expect(resolved.stages).toEqual(DEFAULT_DUNNING_STAGES); + }); + + it('uses the plan default once configured', () => { + const custom = strategy({ maxRetries: 9 }); + service.configurePlan('plan_a', { defaultStrategy: custom }); + expect(service.getStrategy('plan_a', 'default').maxRetries).toBe(9); + }); + + it('prefers a failure-reason override over the plan default', () => { + service.configurePlan('plan_a', { + defaultStrategy: strategy({ maxRetries: 3 }), + strategies: { expired_card: strategy({ maxRetries: 1 }) }, + }); + expect(service.getStrategy('plan_a', 'default').maxRetries).toBe(3); + expect(service.getStrategy('plan_a', 'expired_card').maxRetries).toBe(1); + }); + + it('prefers an active A/B variant over everything else', () => { + service.configurePlan('plan_a', { + defaultStrategy: strategy({ maxRetries: 3 }), + strategies: { expired_card: strategy({ maxRetries: 1 }) }, + }); + service.configureABTest('plan_a', true, [ + { id: 'aggressive', weight: 1, strategy: strategy({ maxRetries: 7 }) }, + ]); + expect(service.getStrategy('plan_a', 'expired_card', 'aggressive').maxRetries).toBe(7); + }); + + it('ignores a variant when the A/B test is disabled', () => { + service.configurePlan('plan_a', { defaultStrategy: strategy({ maxRetries: 3 }) }); + service.configureABTest('plan_a', false, [ + { id: 'aggressive', weight: 1, strategy: strategy({ maxRetries: 7 }) }, + ]); + expect(service.getStrategy('plan_a', 'default', 'aggressive').maxRetries).toBe(3); + }); + + it('keeps the existing default when a later configurePlan omits it', () => { + service.configurePlan('plan_a', { defaultStrategy: strategy({ maxRetries: 5 }) }); + service.configurePlan('plan_a', { strategies: {} }); + expect(service.getStrategy('plan_a', 'default').maxRetries).toBe(5); + }); +}); + +describe('configurable retry backoff', () => { + it('repeats the base delay under a fixed policy', () => { + service.configureRetrySchedule({ + failureType: 'card_declined', + baseDelayHours: 4, + backoffPolicy: 'fixed', + maxDelayHours: 100, + }); + expect(service.calculateRetryDelay('card_declined', 1)).toBe(4); + expect(service.calculateRetryDelay('card_declined', 5)).toBe(4); + }); + + it('scales linearly with the attempt number under a linear policy', () => { + service.configureRetrySchedule({ + failureType: 'card_declined', + baseDelayHours: 2, + backoffPolicy: 'linear', + maxDelayHours: 100, + }); + expect(service.calculateRetryDelay('card_declined', 1)).toBe(2); + expect(service.calculateRetryDelay('card_declined', 3)).toBe(6); + }); + + it('compounds under an exponential policy', () => { + service.configureRetrySchedule({ + failureType: 'card_declined', + baseDelayHours: 1, + backoffMultiplier: 3, + backoffPolicy: 'exponential', + maxDelayHours: 1_000, + }); + expect(service.calculateRetryDelay('card_declined', 1)).toBe(1); + expect(service.calculateRetryDelay('card_declined', 3)).toBe(9); + }); + + it('caps the delay at maxDelayHours', () => { + service.configureRetrySchedule({ + failureType: 'card_declined', + baseDelayHours: 1, + backoffMultiplier: 10, + backoffPolicy: 'exponential', + maxDelayHours: 12, + }); + expect(service.calculateRetryDelay('card_declined', 8)).toBe(12); + }); + + it('keeps jittered delays inside the configured envelope', () => { + service.configureRetrySchedule({ + failureType: 'network_error', + baseDelayHours: 4, + backoffMultiplier: 1, + backoffPolicy: 'exponential_jitter', + jitterRatio: 0.25, + maxDelayHours: 10, + }); + const samples = Array.from({ length: 200 }, () => + service.calculateRetryDelay('network_error', 1) + ); + for (const sample of samples) { + expect(sample).toBeGreaterThanOrEqual(3); + expect(sample).toBeLessThanOrEqual(5); + } + // Jitter must actually spread the values, otherwise it is not doing its job. + expect(new Set(samples).size).toBeGreaterThan(1); + }); + + it('treats attempt 0 as the first attempt', () => { + service.configureRetrySchedule({ + failureType: 'card_declined', + baseDelayHours: 3, + backoffPolicy: 'linear', + maxDelayHours: 100, + }); + expect(service.calculateRetryDelay('card_declined', 0)).toBe(3); + }); + + it('merges a partial schedule update onto the existing one', () => { + service.configureRetrySchedule({ failureType: 'expired_card', maxRetries: 9 }); + const schedule = service.getRetrySchedule('expired_card'); + expect(schedule.maxRetries).toBe(9); + // Untouched fields keep their defaults. + expect(schedule.baseDelayHours).toBe(24); + expect(schedule.backoffPolicy).toBe('fixed'); + }); + + it('falls back to the unknown schedule for an unregistered failure type', () => { + const schedule = service.getRetrySchedule('not_a_real_type' as never); + expect(schedule.failureType).toBe('unknown'); + }); +}); + +describe('dunning lifecycle', () => { + const start = () => service.startDunning('sub_1', 'subscriber_1', 'merchant_1', 'plan_a'); + + it('opens an entry at the first stage of the resolved strategy', () => { + service.configurePlan('plan_a', { defaultStrategy: strategy() }); + const entry = start(); + expect(entry.currentStage).toBe('retry'); + expect(entry.failedAttempts).toBe(0); + expect(service.getDunningEntry('sub_1')).toBe(entry); + }); + + it('is idempotent — starting twice returns the same entry', () => { + expect(start()).toBe(start()); + expect(service.listActiveDunning()).toHaveLength(1); + }); + + it('schedules the next retry using the configured backoff', () => { + service.configurePlan('plan_a', { defaultStrategy: strategy() }); + service.configureRetrySchedule({ + failureType: 'network_error', + baseDelayHours: 2, + backoffPolicy: 'fixed', + maxRetries: 10, + maxDelayHours: 100, + }); + start(); + const before = Date.now(); + const entry = service.recordFailedCharge('sub_1', 'network_error')!; + expect(entry.failedAttempts).toBe(1); + expect(entry.currentStage).toBe('retry'); + expect(entry.nextActionAt - before).toBeGreaterThanOrEqual(2 * ONE_HOUR_MS - 50); + }); + + it('advances to the next stage once the stage attempt budget is spent', () => { + service.configurePlan('plan_a', { defaultStrategy: strategy() }); + service.configureRetrySchedule({ failureType: 'network_error', maxRetries: 99 }); + start(); + // The default `retry` stage allows 3 attempts. + service.recordFailedCharge('sub_1', 'network_error'); + service.recordFailedCharge('sub_1', 'network_error'); + const entry = service.recordFailedCharge('sub_1', 'network_error')!; + expect(entry.currentStage).toBe('warn'); + expect(entry.failedAttempts).toBe(0); + }); + + it('escalates immediately for a failure type marked non-retryable', () => { + service.configurePlan('plan_a', { defaultStrategy: strategy() }); + service.configureRetrySchedule({ failureType: 'expired_card', retryable: false }); + start(); + const entry = service.recordFailedCharge('sub_1', 'expired_card')!; + expect(entry.currentStage).toBe('warn'); + }); + + it('sends a stage communication when it escalates', () => { + service.configurePlan('plan_a', { defaultStrategy: strategy() }); + service.configureRetrySchedule({ failureType: 'expired_card', retryable: false }); + start(); + service.recordFailedCharge('sub_1', 'expired_card'); + const comms = service.getCommunications('sub_1'); + expect(comms).toHaveLength(1); + expect(comms[0].stage).toBe('warn'); + expect(comms[0].templateId).toBe('payment_warning'); + // The channel comes from the resolved strategy, not a hardcoded default. + expect(comms[0].channel).toBe('email'); + }); + + it('lands on cancel once the ladder is exhausted', () => { + service.configurePlan('plan_a', { + defaultStrategy: strategy({ stages: [{ stage: 'retry', delayHours: 1, maxAttempts: 1, templateId: 'payment_retry' }] }), + }); + service.configureRetrySchedule({ failureType: 'unknown', maxRetries: 99 }); + start(); + const entry = service.recordFailedCharge('sub_1', 'unknown')!; + expect(entry.currentStage).toBe('cancel'); + }); + + it('does not record failures against a paused entry', () => { + start(); + service.pauseDunning('sub_1'); + expect(service.recordFailedCharge('sub_1')).toBeNull(); + }); + + it('reschedules on resume', () => { + service.configurePlan('plan_a', { defaultStrategy: strategy() }); + start(); + service.pauseDunning('sub_1'); + const resumed = service.resumeDunning('sub_1')!; + expect(resumed.isPaused).toBe(false); + expect(resumed.nextActionAt).toBeGreaterThan(Date.now()); + }); + + it('returns null for lifecycle calls on an unknown subscription', () => { + expect(service.recordFailedCharge('nope')).toBeNull(); + expect(service.recordSuccessfulCharge('nope')).toBeNull(); + expect(service.pauseDunning('nope')).toBeNull(); + expect(service.resumeDunning('nope')).toBeNull(); + expect(service.overrideStage('nope', 'warn')).toBeNull(); + }); + + it('closes the entry on a successful charge', () => { + start(); + service.recordFailedCharge('sub_1'); + const recovered = service.recordSuccessfulCharge('sub_1'); + expect(recovered).not.toBeNull(); + expect(service.getDunningEntry('sub_1')).toBeUndefined(); + expect(service.listRecoveredDunning('merchant_1')).toHaveLength(1); + }); + + it('lists only entries whose next action is due', () => { + service.configurePlan('plan_a', { defaultStrategy: strategy() }); + start(); + expect(service.getProcessableEntries()).toHaveLength(0); + service.overrideStage('sub_1', 'retry'); + const entry = service.getDunningEntry('sub_1')!; + entry.nextActionAt = Date.now() - 1_000; + expect(service.getProcessableEntries()).toHaveLength(1); + }); + + it('scopes active listings by merchant', () => { + service.startDunning('sub_1', 'subscriber_1', 'merchant_1', 'plan_a'); + service.startDunning('sub_2', 'subscriber_2', 'merchant_2', 'plan_a'); + expect(service.listActiveDunning('merchant_1')).toHaveLength(1); + expect(service.listActiveDunning()).toHaveLength(2); + }); +}); + +describe('analytics', () => { + beforeEach(() => { + service.configurePlan('plan_a', { defaultStrategy: strategy() }); + service.configureRetrySchedule({ failureType: 'network_error', maxRetries: 99 }); + }); + + it('counts retries by failure type', () => { + service.startDunning('sub_1', 'subscriber_1', 'merchant_1', 'plan_a'); + service.recordFailedCharge('sub_1', 'network_error'); + service.recordFailedCharge('sub_1', 'card_declined'); + const analytics = service.getRetryAnalytics('merchant_1'); + expect(analytics.totalRetries).toBe(2); + expect(analytics.retriesByFailureType.network_error).toBe(1); + expect(analytics.retriesByFailureType.card_declined).toBe(1); + expect(analytics.successfulRetries).toBe(0); + }); + + it('scopes analytics to a merchant', () => { + service.startDunning('sub_1', 'subscriber_1', 'merchant_1', 'plan_a'); + service.startDunning('sub_2', 'subscriber_2', 'merchant_2', 'plan_a'); + service.recordFailedCharge('sub_1', 'network_error'); + service.recordFailedCharge('sub_2', 'network_error'); + expect(service.getRetryAnalytics('merchant_1').totalRetries).toBe(1); + expect(service.getRetryAnalytics().totalRetries).toBe(2); + }); + + it('measures recovery rate over closed outcomes only', () => { + service.startDunning('sub_1', 'subscriber_1', 'merchant_1', 'plan_a'); + service.startDunning('sub_2', 'subscriber_2', 'merchant_1', 'plan_a'); + service.recordFailedCharge('sub_1', 'network_error'); + service.recordSuccessfulCharge('sub_1'); + service.overrideStage('sub_2', 'cancel'); + + const analytics = service.getAnalytics('merchant_1'); + expect(analytics.totalRecovered).toBe(1); + expect(analytics.totalLost).toBe(1); + expect(analytics.recoveryRate).toBe(50); + }); + + it('reports zeroes rather than NaN with no history', () => { + const analytics = service.getAnalytics('merchant_none'); + expect(analytics.recoveryRate).toBe(0); + expect(analytics.averageDaysToRecovery).toBe(0); + expect(analytics.totalActiveDunning).toBe(0); + expect(service.getRetryAnalytics('merchant_none').successRate).toBe(0); + }); + + it('breaks active entries down by stage', () => { + service.startDunning('sub_1', 'subscriber_1', 'merchant_1', 'plan_a'); + service.startDunning('sub_2', 'subscriber_2', 'merchant_1', 'plan_a'); + service.overrideStage('sub_2', 'suspend'); + const { stageBreakdown } = service.getAnalytics('merchant_1'); + expect(stageBreakdown.retry).toBe(1); + expect(stageBreakdown.suspend).toBe(1); + }); +}); + +describe('communication templates', () => { + it('ships the default template set', () => { + expect(service.getTemplates().map((t) => t.id)).toEqual([ + 'payment_retry', + 'payment_warning', + 'service_suspension', + 'subscription_cancellation', + ]); + }); + + it('adds, updates, and removes templates', () => { + service.addTemplate({ + id: 'custom', + stage: 'warn', + subject: 'Subject', + body: 'Body', + pushTitle: 'Title', + pushBody: 'Push', + actionLabel: 'Go', + actionUrl: '/go', + }); + expect(service.getTemplates()).toHaveLength(5); + + service.updateTemplate('custom', { subject: 'Updated' }); + expect(service.getTemplates().find((t) => t.id === 'custom')?.subject).toBe('Updated'); + + service.removeTemplate('custom'); + expect(service.getTemplates()).toHaveLength(4); + }); + + it('does not add the same template id twice', () => { + const existing = service.getTemplates()[0]; + service.addTemplate(existing); + expect(service.getTemplates()).toHaveLength(4); + }); +}); + +describe('reset', () => { + it('clears entries, history, and configuration', () => { + service.configurePlan('plan_a', { defaultStrategy: strategy({ maxRetries: 9 }) }); + service.startDunning('sub_1', 'subscriber_1', 'merchant_1', 'plan_a'); + service.recordFailedCharge('sub_1'); + service.reset(); + + expect(service.listActiveDunning()).toHaveLength(0); + expect(service.listRecoveredDunning()).toHaveLength(0); + expect(service.getConfiguration('plan_a')).toBeUndefined(); + expect(service.getRetryAnalytics().totalRetries).toBe(0); + }); +}); diff --git a/backend/services/billing/dunningService.ts b/backend/services/billing/dunningService.ts index 666d83ea..1e3f5f2b 100644 --- a/backend/services/billing/dunningService.ts +++ b/backend/services/billing/dunningService.ts @@ -33,12 +33,29 @@ export type FailureType = | 'auth_required' | 'unknown'; +/** + * Shape of the delay curve applied between retries of the same stage. + * + * - `fixed` — every attempt waits `baseDelayHours`. + * - `linear` — attempt N waits `baseDelayHours * N`. + * - `exponential` — attempt N waits `baseDelayHours * multiplier^(N-1)`. + * - `exponential_jitter` — as `exponential`, with a random ± `jitterRatio` + * spread so a batch of failures caused by one upstream outage does not + * retry in lockstep. + */ +export type BackoffPolicy = 'fixed' | 'linear' | 'exponential' | 'exponential_jitter'; + export interface RetryScheduleConfig { failureType: FailureType; baseDelayHours: number; maxRetries: number; backoffMultiplier: number; maxDelayHours: number; + backoffPolicy: BackoffPolicy; + /** Fraction of the computed delay used as jitter spread, e.g. 0.2 = ±20%. */ + jitterRatio: number; + /** When false the failure type is treated as terminal and is never retried. */ + retryable: boolean; } export interface RetryAnalytics { @@ -53,29 +70,80 @@ export interface RetryAnalytics { averageTimeToRecovery: number; } +const FAILURE_TYPES: FailureType[] = [ + 'insufficient_funds', + 'card_declined', + 'expired_card', + 'network_error', + 'processing_error', + 'auth_required', + 'unknown', +]; + 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 }, + { failureType: 'insufficient_funds', baseDelayHours: 1, maxRetries: 5, backoffMultiplier: 2, maxDelayHours: 48, backoffPolicy: 'exponential_jitter', jitterRatio: 0.2, retryable: true }, + { failureType: 'card_declined', baseDelayHours: 2, maxRetries: 3, backoffMultiplier: 3, maxDelayHours: 72, backoffPolicy: 'exponential', jitterRatio: 0, retryable: true }, + // A card that has expired will keep failing until the payer acts, so a flat + // daily nudge beats an escalating backoff here. + { failureType: 'expired_card', baseDelayHours: 24, maxRetries: 2, backoffMultiplier: 1, maxDelayHours: 24, backoffPolicy: 'fixed', jitterRatio: 0, retryable: true }, + { failureType: 'network_error', baseDelayHours: 0.5, maxRetries: 6, backoffMultiplier: 1.5, maxDelayHours: 12, backoffPolicy: 'exponential_jitter', jitterRatio: 0.3, retryable: true }, + { failureType: 'processing_error', baseDelayHours: 1, maxRetries: 4, backoffMultiplier: 2, maxDelayHours: 24, backoffPolicy: 'exponential', jitterRatio: 0, retryable: true }, + { failureType: 'auth_required', baseDelayHours: 0.25, maxRetries: 3, backoffMultiplier: 1, maxDelayHours: 1, backoffPolicy: 'linear', jitterRatio: 0, retryable: true }, + { failureType: 'unknown', baseDelayHours: 1, maxRetries: 3, backoffMultiplier: 2, maxDelayHours: 24, backoffPolicy: 'exponential', jitterRatio: 0, retryable: true }, ]; +/** Failure types share names with `FailureReason` only partially; map them across. */ +const FAILURE_TYPE_TO_REASON: Record = { + insufficient_funds: 'insufficient_funds', + card_declined: 'default', + expired_card: 'expired_card', + network_error: 'network', + processing_error: 'default', + auth_required: 'default', + unknown: 'default', +}; + +const emptyStageRecord = (): Record => ({ + retry: 0, + warn: 0, + suspend: 0, + cancel: 0, +}); + +const emptyFailureTypeRecord = (): Record => + FAILURE_TYPES.reduce((acc, type) => { + acc[type] = 0; + return acc; + }, {} as Record); + +const FALLBACK_STRATEGY: RetryStrategy = { + stages: DEFAULT_DUNNING_STAGES, + maxRetries: 3, + retryIntervalHours: 1, + warnAfterFailures: 3, + suspendAfterDays: 3, + cancelAfterDays: 7, + communicationChannels: ['email', 'push'], +}; + +interface RetryHistoryRecord { + subscriptionId: string; + merchantId: string; + failureType: FailureType; + attempt: number; + success: boolean; + timestamp: number; + delayHours: number; +} + export class DunningService { private entries = new Map(); private configurations = new Map(); private communicationLog = new Map(); + private templates: DunningCommunicationTemplate[] = [...DUNNING_TEMPLATES]; private retrySchedules: RetryScheduleConfig[] = [...DEFAULT_RETRY_SCHEDULES]; - private retryHistory: Array<{ - subscriptionId: string; - failureType: FailureType; - attempt: number; - success: boolean; - timestamp: number; - delayHours: number; - }> = []; + private retryHistory: RetryHistoryRecord[] = []; + private recoveredEntries: DunningEntry[] = []; private progressiveEngine: ProgressiveDunningEngine; constructor(engine: ProgressiveDunningEngine = progressiveDunningEngine) { @@ -84,16 +152,9 @@ 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 defaultStrategy: RetryStrategy = + config.defaultStrategy ?? existing?.defaultStrategy ?? { ...FALLBACK_STRATEGY }; const merged: DunningConfiguration = { planId, @@ -101,7 +162,7 @@ export class DunningService { strategies: config.strategies ?? existing?.strategies ?? {}, abTestConfig: config.abTestConfig ?? existing?.abTestConfig, }; - + this.configurations.set(planId, merged); return merged; } @@ -120,6 +181,22 @@ export class DunningService { return this.configurations.get(planId); } + /** + * Resolves the retry strategy in force for a subscription, most specific + * first: A/B variant → failure-reason override → plan default → built-in. + */ + getStrategy(planId: string, failureReason: FailureReason, abTestVariant?: string): RetryStrategy { + const config = this.configurations.get(planId); + if (!config) return FALLBACK_STRATEGY; + + if (abTestVariant && config.abTestConfig?.enabled) { + const variant = config.abTestConfig.variants.find((v) => v.id === abTestVariant); + if (variant) return variant.strategy; + } + + return config.strategies?.[failureReason] ?? config.defaultStrategy ?? FALLBACK_STRATEGY; + } + configureRetrySchedule(schedule: Partial & { failureType: FailureType }): void { const existingIdx = this.retrySchedules.findIndex((s) => s.failureType === schedule.failureType); const existing = existingIdx >= 0 ? this.retrySchedules[existingIdx] : undefined; @@ -130,6 +207,9 @@ export class DunningService { maxRetries: schedule.maxRetries ?? existing?.maxRetries ?? 3, backoffMultiplier: schedule.backoffMultiplier ?? existing?.backoffMultiplier ?? 2, maxDelayHours: schedule.maxDelayHours ?? existing?.maxDelayHours ?? 24, + backoffPolicy: schedule.backoffPolicy ?? existing?.backoffPolicy ?? 'exponential', + jitterRatio: schedule.jitterRatio ?? existing?.jitterRatio ?? 0, + retryable: schedule.retryable ?? existing?.retryable ?? true, }; if (existingIdx >= 0) { @@ -142,15 +222,46 @@ export class DunningService { getRetrySchedule(failureType: FailureType): RetryScheduleConfig { return ( this.retrySchedules.find((s) => s.failureType === failureType) ?? - this.retrySchedules.find((s) => s.failureType === 'unknown')! + this.retrySchedules.find((s) => s.failureType === 'unknown') ?? + DEFAULT_RETRY_SCHEDULES[DEFAULT_RETRY_SCHEDULES.length - 1] ); } + listRetrySchedules(): RetryScheduleConfig[] { + return this.retrySchedules.map((s) => ({ ...s })); + } + + /** Delay in hours before attempt `attemptNumber` (1-based), capped at `maxDelayHours`. */ 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); + const attempt = Math.max(1, attemptNumber); + + let delay: number; + switch (schedule.backoffPolicy) { + case 'fixed': + delay = schedule.baseDelayHours; + break; + case 'linear': + delay = schedule.baseDelayHours * attempt; + break; + case 'exponential': + case 'exponential_jitter': + default: + delay = schedule.baseDelayHours * Math.pow(schedule.backoffMultiplier, attempt - 1); + break; + } + + delay = Math.min(delay, schedule.maxDelayHours); + + if (schedule.backoffPolicy === 'exponential_jitter' && schedule.jitterRatio > 0) { + const spread = delay * schedule.jitterRatio; + // Jitter both directions, then clamp so the delay stays inside the + // configured envelope. + delay = delay + (Math.random() * 2 - 1) * spread; + delay = Math.min(Math.max(delay, 0), schedule.maxDelayHours); + } + + return delay; } startDunning( @@ -228,12 +339,13 @@ export class DunningService { const { entry: updated, event } = this.progressiveEngine.applyEscalation(entry, rule); this.entries.set(subscriptionId, updated); + const strategy = this.getStrategy(updated.planId, updated.failureReason, updated.abTestVariant); const stageConfig = - this.configurations.get(updated.planId)?.stages.find((s) => s.stage === updated.currentStage) ?? + strategy.stages.find((s) => s.stage === updated.currentStage) ?? DEFAULT_DUNNING_STAGES.find((s) => s.stage === updated.currentStage); if (stageConfig) { - this.sendCommunication(updated, stageConfig); + this.sendCommunication(updated, stageConfig, strategy); } return { entry: updated, event }; @@ -250,7 +362,7 @@ export class DunningService { const entry = this.entries.get(subscriptionId); if (!entry || entry.isPaused) return null; - const config = this.configurations.get(entry.planId); + const strategy = this.getStrategy(entry.planId, entry.failureReason, entry.abTestVariant); const schedule = this.getRetrySchedule(failureType); const now_ts = now(); @@ -260,65 +372,81 @@ export class DunningService { entry.lastAttemptAt = now_ts; entry.updatedAt = now_ts; - this.retryHistory.push({ - subscriptionId, - failureType, - attempt: entry.failedAttempts, - success: false, - timestamp: now_ts, - delayHours: 0, - }); + const stages = strategy.stages.length > 0 ? strategy.stages : DEFAULT_DUNNING_STAGES; + const currentStageIndex = stages.findIndex((s) => s.stage === entry.currentStage); + const stageConfig = currentStageIndex >= 0 ? stages[currentStageIndex] : undefined; - const shouldAdvanceStage = (): boolean => { - 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; - }; + // A non-retryable failure (e.g. a hard decline) skips the retry budget and + // escalates on the first occurrence. + const budgetExhausted = + !schedule.retryable || + entry.failedAttempts >= schedule.maxRetries || + (stageConfig !== undefined && entry.failedAttempts >= stageConfig.maxAttempts); - if (shouldAdvanceStage() && config) { - const currentStageIndex = config.stages.findIndex((s) => s.stage === entry.currentStage); + let delayHours = 0; + + if (budgetExhausted) { const nextStageIndex = currentStageIndex + 1; - if (nextStageIndex < strategy.stages.length) { - const nextStage = strategy.stages[nextStageIndex]; + if (currentStageIndex >= 0 && nextStageIndex < stages.length) { + const nextStage = stages[nextStageIndex]; entry.currentStage = nextStage.stage; entry.failedAttempts = 0; - entry.nextActionAt = now_ts + nextStage.delayHours * ONE_HOUR_MS; - this.sendCommunication(entry, nextStage); + delayHours = nextStage.delayHours; + entry.nextActionAt = now_ts + delayHours * ONE_HOUR_MS; + this.progressiveEngine.trackStageEntry(entry, now_ts); + this.sendCommunication(entry, nextStage, strategy); } else { entry.currentStage = 'cancel'; - entry.nextActionAt = now_ts + 24 * ONE_HOUR_MS; + entry.failedAttempts = 0; + delayHours = 24; + entry.nextActionAt = now_ts + delayHours * ONE_HOUR_MS; + this.progressiveEngine.trackStageEntry(entry, now_ts); + const cancelStage = + stages.find((s) => s.stage === 'cancel') ?? + DEFAULT_DUNNING_STAGES.find((s) => s.stage === 'cancel'); + if (cancelStage) { + this.sendCommunication(entry, cancelStage, strategy); + } } } else { - const delay = this.calculateRetryDelay(failureType, entry.failedAttempts); - entry.nextActionAt = now_ts + delay * ONE_HOUR_MS; + delayHours = this.calculateRetryDelay(failureType, entry.failedAttempts); + entry.nextActionAt = now_ts + delayHours * ONE_HOUR_MS; } + this.retryHistory.push({ + subscriptionId, + merchantId: entry.merchantId, + failureType, + attempt: entry.totalFailedCharges, + success: false, + timestamp: now_ts, + delayHours, + }); + this.entries.set(subscriptionId, entry); return entry; } - recordSuccessfulCharge(subscriptionId: string): void { + recordSuccessfulCharge(subscriptionId: string): DunningEntry | null { const entry = this.entries.get(subscriptionId); - if (entry) { - this.retryHistory.push({ - subscriptionId, - failureType: 'unknown', - attempt: entry.failedAttempts, - success: true, - timestamp: now(), - delayHours: 0, - }); - this.progressiveEngine.recordRecovery(entry); - } + if (!entry) return null; - entry.updatedAt = now(); - this.recoveredEntries.push(entry); + const now_ts = now(); + this.retryHistory.push({ + subscriptionId, + merchantId: entry.merchantId, + failureType: FAILURE_TYPES.find((t) => FAILURE_TYPE_TO_REASON[t] === entry.failureReason) ?? 'unknown', + attempt: entry.totalFailedCharges + 1, + success: true, + timestamp: now_ts, + delayHours: 0, + }); + this.progressiveEngine.recordRecovery(entry, now_ts); + entry.updatedAt = now_ts; + this.recoveredEntries.push(entry); this.entries.delete(subscriptionId); + return entry; } getDunningEntry(subscriptionId: string): DunningEntry | undefined { @@ -333,6 +461,13 @@ export class DunningService { return all; } + listRecoveredDunning(merchantId?: string): DunningEntry[] { + if (merchantId) { + return this.recoveredEntries.filter((e) => e.merchantId === merchantId); + } + return [...this.recoveredEntries]; + } + pauseDunning(subscriptionId: string): DunningEntry | null { const entry = this.entries.get(subscriptionId); if (!entry) return null; @@ -366,6 +501,7 @@ export class DunningService { entry.nextActionAt = now() + (stageConfig?.delayHours ?? 24) * ONE_HOUR_MS; entry.updatedAt = now(); this.entries.set(subscriptionId, entry); + this.progressiveEngine.trackStageEntry(entry); return entry; } @@ -374,35 +510,18 @@ export class DunningService { } 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.filter((h) => h.merchantId === merchantId) : 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 retriesByFailureType = emptyFailureTypeRecord(); + const retriesByStage = emptyStageRecord(); - const retriesByStage: Record = { - retry: 0, - warn: 0, - suspend: 0, - cancel: 0, - }; - - for (const entry of entries) { + for (const entry of this.listActiveDunning(merchantId)) { retriesByStage[entry.currentStage] = (retriesByStage[entry.currentStage] ?? 0) + 1; } @@ -415,8 +534,10 @@ export class DunningService { ); const recoveryTimes: number[] = []; + const attemptsPerSuccess: number[] = []; for (const subId of successfulSubscriptionIds) { const subHistory = relevantHistory.filter((h) => h.subscriptionId === subId); + attemptsPerSuccess.push(subHistory.length); if (subHistory.length >= 2) { const first = subHistory[0]; const last = subHistory[subHistory.length - 1]; @@ -429,12 +550,6 @@ export class DunningService { ? 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, @@ -453,49 +568,50 @@ 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, - suspend: 0, - cancel: 0, - }; + const recovered = this.listRecoveredDunning(merchantId); - let totalLost = 0; + const stageBreakdown = emptyStageRecord(); for (const entry of allEntries) { stageBreakdown[entry.currentStage] = (stageBreakdown[entry.currentStage] ?? 0) + 1; - if (entry.currentStage === 'cancel') { - totalLost++; - } } const retryAnalytics = this.getRetryAnalytics(merchantId); + // Recovery rate is measured over closed outcomes only: entries that were + // recovered versus entries that reached `cancel`. + const closed = recovered.length + stageBreakdown.cancel; + const recoveryRate = closed > 0 ? Math.round((recovered.length / closed) * 100) : 0; + + const recoveryDays = recovered + .map((e) => (e.updatedAt - e.firstFailureAt) / ONE_DAY_MS) + .filter((d) => d >= 0); + const averageDaysToRecovery = + recoveryDays.length > 0 + ? Math.round((recoveryDays.reduce((s, d) => s + d, 0) / recoveryDays.length) * 10) / 10 + : 0; + return { totalActiveDunning: allEntries.length, stageBreakdown, - recoveryRate: retryAnalytics.successRate, - totalRecovered: retryAnalytics.successfulRetries, + recoveryRate, + totalRecovered: recovered.length, totalLost: stageBreakdown.cancel, - averageDaysToRecovery: retryAnalytics.averageTimeToRecovery, - stageSuccessRates: { - retry: retryAnalytics.retriesByStage.retry, - warn: retryAnalytics.retriesByStage.warn, - suspend: retryAnalytics.retriesByStage.suspend, - cancel: retryAnalytics.retriesByStage.cancel, - }, + averageDaysToRecovery, + stageSuccessRates: retryAnalytics.retriesByStage, }; } - private sendCommunication(entry: DunningEntry, stageConfig: DunningStageConfig): DunningCommunication { + private sendCommunication( + entry: DunningEntry, + stageConfig: DunningStageConfig, + strategy?: RetryStrategy + ): DunningCommunication { const template = this.templates.find((t) => t.id === stageConfig.templateId); + const channel = strategy?.communicationChannels?.[0] ?? 'push'; const comm: DunningCommunication = { id: createId('dcom'), stage: stageConfig.stage, - channel: 'push', + channel, templateId: stageConfig.templateId, sentAt: now(), status: 'sent', @@ -521,25 +637,36 @@ export class DunningService { } addTemplate(template: DunningCommunicationTemplate): void { - if (!this.templates.find(t => t.id === template.id)) { + 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); + 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); + this.templates = this.templates.filter((t) => t.id !== id); } getTemplates(): DunningCommunicationTemplate[] { return [...this.templates]; } + + /** Clears all in-memory state. Intended for tests and worker restarts. */ + reset(): void { + this.entries.clear(); + this.configurations.clear(); + this.communicationLog.clear(); + this.templates = [...DUNNING_TEMPLATES]; + this.retrySchedules = [...DEFAULT_RETRY_SCHEDULES]; + this.retryHistory = []; + this.recoveredEntries = []; + } } export const dunningService = new DunningService(); diff --git a/backend/services/billing/index.ts b/backend/services/billing/index.ts index e7627b23..92f0f988 100644 --- a/backend/services/billing/index.ts +++ b/backend/services/billing/index.ts @@ -28,7 +28,44 @@ export type { TaxRemittanceReportRequest, } from './taxTypes'; export { DunningService, dunningService } from './dunningService'; -export type { FailureType, RetryScheduleConfig, RetryAnalytics } from './dunningService'; +export type { + BackoffPolicy, + FailureType, + RetryScheduleConfig, + RetryAnalytics, +} from './dunningService'; + +// Metered pricing and tiered overage rating (issue #935). Mirrors the +// `subtrackr-metering` Soroban contract; see metering.ts for why. +export { + MeteringPricingError, + buildOverageLadder, + marginalUnitPrice, + quoteMeter, + rateMeter, + rateUsage, + toContractTiers, + validateMeterPricingPlan, + validateOverageTiers, +} from './metering'; +export type { + MeteredPricingModel, + MeterPricingPlan, + OverageTier, + RateUsageInput, + RatedMeterLine, + RatedTierLine, + RatedUsageBill, +} from './metering'; + +// Per-tenant invoice branding and rendering (issue #937). +export { + FALLBACK_BRANDING, + InvoiceCustomizationService, + escapeHtml, + normalizeBranding, +} from './invoiceCustomizationService'; +export type { DeliveryResult, RenderedInvoice } from './invoiceCustomizationService'; export { ProrationService, prorationService } from './proration'; export type { ProrationConfiguration, diff --git a/backend/services/billing/interfaces.ts b/backend/services/billing/interfaces.ts index a9afd1f8..776e426f 100644 --- a/backend/services/billing/interfaces.ts +++ b/backend/services/billing/interfaces.ts @@ -130,11 +130,19 @@ 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; + getStrategy(planId: string, failureReason: FailureReason, abTestVariant?: string): RetryStrategy; + startDunning( + subscriptionId: string, + subscriberId: string, + merchantId: string, + planId: string, + failureReason?: FailureReason + ): DunningEntry; recordFailedCharge(subscriptionId: string, failureType?: string): DunningEntry | null; - recordSuccessfulCharge(subscriptionId: string): void; + recordSuccessfulCharge(subscriptionId: string): DunningEntry | null; getDunningEntry(subscriptionId: string): DunningEntry | undefined; listActiveDunning(merchantId?: string): DunningEntry[]; + listRecoveredDunning(merchantId?: string): DunningEntry[]; pauseDunning(subscriptionId: string): DunningEntry | null; resumeDunning(subscriptionId: string): DunningEntry | null; overrideStage(subscriptionId: string, stage: DunningStage): DunningEntry | null; @@ -147,6 +155,9 @@ export interface IDunningService { maxRetries?: number; backoffMultiplier?: number; maxDelayHours?: number; + backoffPolicy?: string; + jitterRatio?: number; + retryable?: boolean; }): void; getRetrySchedule(failureType: string): { failureType: string; @@ -154,6 +165,9 @@ export interface IDunningService { maxRetries: number; backoffMultiplier: number; maxDelayHours: number; + backoffPolicy: string; + jitterRatio: number; + retryable: boolean; }; calculateRetryDelay(failureType: string, attempt: number): number; getRetryAnalytics(merchantId?: string): { diff --git a/docs/DUNNING_RETRY_STRATEGIES.md b/docs/DUNNING_RETRY_STRATEGIES.md new file mode 100644 index 00000000..cfaff5cf --- /dev/null +++ b/docs/DUNNING_RETRY_STRATEGIES.md @@ -0,0 +1,125 @@ +# Dunning Retry Strategies + +How `backend/services/billing/dunningService.ts` decides *when* to retry a +failed charge and *when* to give up. For the email/A-B-test layer that sits on +top of it see [DUNNING.md](./DUNNING.md); for the time-based stage escalation +rules see [DUNNING_ESCALATION.md](./DUNNING_ESCALATION.md). + +## The two knobs + +Retry behaviour is configured in two independent places, and it helps to keep +them apart: + +| | Configured by | Scope | Answers | +|---|---|---|---| +| **Stage ladder** (`RetryStrategy`) | `configurePlan()` | per plan | *Which stage comes next, and how long the payer sits in it?* | +| **Retry schedule** (`RetryScheduleConfig`) | `configureRetrySchedule()` | per failure type, service-wide | *How long between retries inside a stage?* | + +A subscription escalates to the next stage when **either** its stage's +`maxAttempts` or its failure type's `maxRetries` is spent — whichever comes +first. + +## Strategy resolution + +`getStrategy(planId, failureReason, abTestVariant)` picks the ladder, most +specific first: + +``` +A/B variant → failure-reason override → plan default → built-in fallback +``` + +So a plan can run a general ladder, a harsher one for `expired_card`, and still +A/B-test a third against both: + +```ts +dunningService.configurePlan('plan_pro', { + defaultStrategy: standardLadder, + strategies: { expired_card: shortLadder }, +}); + +dunningService.configureABTest('plan_pro', true, [ + { id: 'control', weight: 50, strategy: standardLadder }, + { id: 'aggressive', weight: 50, strategy: aggressiveLadder }, +]); +``` + +A variant is assigned once, at `startDunning()`, by weight, and is stored on the +entry so the subscription keeps the same treatment for its whole dunning run. +Reading a variant only matters while the test is enabled — flipping +`configureABTest(..., false, ...)` sends everyone back to the resolved ladder +without touching stored entries. + +## Backoff policies + +`calculateRetryDelay(failureType, attempt)` applies the failure type's +`backoffPolicy`. `attempt` is 1-based. + +| Policy | Delay for attempt *n* | +|---|---| +| `fixed` | `baseDelayHours` | +| `linear` | `baseDelayHours × n` | +| `exponential` | `baseDelayHours × multiplier^(n-1)` | +| `exponential_jitter` | as `exponential`, then spread by ± `jitterRatio` | + +Every result is clamped to `maxDelayHours`, jitter included — a jittered delay +never escapes the configured envelope. + +### Why jitter + +A single upstream outage fails hundreds of charges within the same second. +Without jitter every one of them retries at exactly the same instant, and the +retry storm hits the processor just as hard as the original burst. `jitterRatio: +0.2` spreads those retries over ±20% of the delay, which is enough to decorrelate +them. + +Jitter is on by default for `insufficient_funds` and `network_error`, the two +failure types that arrive in correlated bursts. + +### Defaults + +| Failure type | Base | Max retries | Policy | Rationale | +|---|---|---|---|---| +| `insufficient_funds` | 1h | 5 | `exponential_jitter` | Funds may arrive; back off but keep trying. | +| `card_declined` | 2h | 3 | `exponential` | Usually needs the payer to act. | +| `expired_card` | 24h | 2 | `fixed` | Will keep failing until updated — a flat daily nudge beats escalation. | +| `network_error` | 30m | 6 | `exponential_jitter` | Transient; retry often, decorrelated. | +| `processing_error` | 1h | 4 | `exponential` | Usually transient. | +| `auth_required` | 15m | 3 | `linear` | The payer is likely still in-session. | +| `unknown` | 1h | 3 | `exponential` | Conservative default. | + +Override any of them — partial updates merge onto the existing entry: + +```ts +dunningService.configureRetrySchedule({ + failureType: 'insufficient_funds', + maxRetries: 8, + jitterRatio: 0.35, +}); +``` + +### Non-retryable failures + +Setting `retryable: false` skips the retry budget entirely: the first failure of +that type escalates straight to the next stage. Use it for hard declines where +retrying only burns processor reputation. + +## Analytics + +`getRetryAnalytics(merchantId?)` reports raw retry counts — attempts, success +rate, breakdown by failure type and by current stage, mean time to recovery. + +`getAnalytics(merchantId?)` reports the business view. Note that `recoveryRate` +is measured over **closed outcomes only** — recovered entries versus entries that +reached `cancel`. Subscriptions still in dunning are deliberately excluded: their +outcome is not known yet, and counting them as failures would make the rate look +worse the more traffic is in flight. + +## Testing + +`backend/services/billing/__tests__/dunningService.test.ts` covers strategy +resolution precedence, every backoff policy, the jitter envelope, stage +escalation, and the analytics edge cases (empty history returns zeroes, not +`NaN`). + +Construct a fresh `new DunningService()` per test, or call `reset()` on the +shared singleton — state is in-memory and otherwise leaks between tests.