From 797f5e43189afeb7a99d1f31eab7e69267e55faa Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Thu, 19 Mar 2026 23:49:25 -0700 Subject: [PATCH] =?UTF-8?q?feat(dunning):=20add=20billing=20dunning=20modu?= =?UTF-8?q?le=20=E2=80=94=20campaigns,=20policies,=20retries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - types.ts: DunningCampaignDoc, DunningPolicyDoc + 3 Zod schemas + helpers - repository.ts: campaign CRUD, policy management, retry scheduling - routes.ts: 11 endpoints (campaign lifecycle, policy CRUD, retry, grace period) - dunning.test.ts: 20 schema + helper tests (getNextRetryTime, isGracePeriodExpired) - Configurable retry schedules, grace periods, auto-downgrade - Campaign status lifecycle: active → resolved/escalated - Cosmos containers: dunning_campaigns, dunning_policies --- .../src/modules/dunning/dunning.test.ts | 195 +++++++++ .../src/modules/dunning/repository.ts | 247 ++++++++++++ .../src/modules/dunning/routes.ts | 378 ++++++++++++++++++ .../src/modules/dunning/types.ts | 138 +++++++ 4 files changed, 958 insertions(+) create mode 100644 services/platform-service/src/modules/dunning/dunning.test.ts create mode 100644 services/platform-service/src/modules/dunning/repository.ts create mode 100644 services/platform-service/src/modules/dunning/routes.ts create mode 100644 services/platform-service/src/modules/dunning/types.ts diff --git a/services/platform-service/src/modules/dunning/dunning.test.ts b/services/platform-service/src/modules/dunning/dunning.test.ts new file mode 100644 index 00000000..e83df24f --- /dev/null +++ b/services/platform-service/src/modules/dunning/dunning.test.ts @@ -0,0 +1,195 @@ +/** + * Billing Dunning module — unit tests. + */ + +import { describe, it, expect } from 'vitest'; +import { + CreateDunningCampaignSchema, + ResolveCampaignSchema, + UpdateDunningPolicySchema, + getNextRetryTime, + isGracePeriodExpired, + DEFAULT_RETRY_SCHEDULE_HOURS, +} from './types.js'; + +// ── Schema validation ── + +describe('CreateDunningCampaignSchema', () => { + it('validates minimal campaign', () => { + const result = CreateDunningCampaignSchema.safeParse({ + userId: 'user-1', + subscriptionId: 'sub_abc', + failedPaymentId: 'pi_xyz', + amountCents: 1999, + failureReason: 'card_declined', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.currency).toBe('usd'); + expect(result.data.declineCode).toBeNull(); + } + }); + + it('validates with all fields', () => { + const result = CreateDunningCampaignSchema.safeParse({ + userId: 'user-2', + subscriptionId: 'sub_def', + failedPaymentId: 'pi_123', + amountCents: 4999, + currency: 'eur', + failureReason: 'insufficient_funds', + declineCode: 'insufficient_funds', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.currency).toBe('eur'); + expect(result.data.declineCode).toBe('insufficient_funds'); + } + }); + + it('rejects zero amount', () => { + expect( + CreateDunningCampaignSchema.safeParse({ + userId: 'u1', + subscriptionId: 's1', + failedPaymentId: 'p1', + amountCents: 0, + failureReason: 'test', + }).success + ).toBe(false); + }); + + it('rejects empty userId', () => { + expect( + CreateDunningCampaignSchema.safeParse({ + userId: '', + subscriptionId: 's1', + failedPaymentId: 'p1', + amountCents: 100, + failureReason: 'test', + }).success + ).toBe(false); + }); + + it('rejects invalid currency length', () => { + expect( + CreateDunningCampaignSchema.safeParse({ + userId: 'u1', + subscriptionId: 's1', + failedPaymentId: 'p1', + amountCents: 100, + currency: 'dollars', + failureReason: 'test', + }).success + ).toBe(false); + }); +}); + +describe('ResolveCampaignSchema', () => { + it('validates payment_succeeded', () => { + const result = ResolveCampaignSchema.safeParse({ + resolution: 'payment_succeeded', + }); + expect(result.success).toBe(true); + }); + + it('validates manual_resolution with notes', () => { + const result = ResolveCampaignSchema.safeParse({ + resolution: 'manual_resolution', + notes: 'Customer contacted support, card updated', + }); + expect(result.success).toBe(true); + }); + + it('validates waived', () => { + expect(ResolveCampaignSchema.safeParse({ resolution: 'waived' }).success).toBe(true); + }); + + it('rejects invalid resolution', () => { + expect(ResolveCampaignSchema.safeParse({ resolution: 'deleted' }).success).toBe(false); + }); +}); + +describe('UpdateDunningPolicySchema', () => { + it('validates partial update', () => { + const result = UpdateDunningPolicySchema.safeParse({ + gracePeriodDays: 14, + autoDowngrade: false, + }); + expect(result.success).toBe(true); + }); + + it('validates retry schedule', () => { + const result = UpdateDunningPolicySchema.safeParse({ + retryScheduleHours: [12, 48, 120, 240], + }); + expect(result.success).toBe(true); + }); + + it('rejects empty retry schedule', () => { + expect(UpdateDunningPolicySchema.safeParse({ retryScheduleHours: [] }).success).toBe(false); + }); + + it('rejects grace period over 90 days', () => { + expect(UpdateDunningPolicySchema.safeParse({ gracePeriodDays: 91 }).success).toBe(false); + }); + + it('rejects retry hours over 720 (30 days)', () => { + expect(UpdateDunningPolicySchema.safeParse({ retryScheduleHours: [721] }).success).toBe(false); + }); +}); + +// ── Helper functions ── + +describe('getNextRetryTime', () => { + const baseTime = '2026-01-15T10:00:00.000Z'; + + it('returns first retry time', () => { + const result = getNextRetryTime(0, DEFAULT_RETRY_SCHEDULE_HOURS, baseTime); + expect(result).not.toBeNull(); + const expected = new Date(baseTime); + expected.setHours(expected.getHours() + 24); + expect(result).toBe(expected.toISOString()); + }); + + it('returns second retry time', () => { + const result = getNextRetryTime(1, DEFAULT_RETRY_SCHEDULE_HOURS, baseTime); + expect(result).not.toBeNull(); + const expected = new Date(baseTime); + expected.setHours(expected.getHours() + 72); + expect(result).toBe(expected.toISOString()); + }); + + it('returns third retry time', () => { + const result = getNextRetryTime(2, DEFAULT_RETRY_SCHEDULE_HOURS, baseTime); + expect(result).not.toBeNull(); + const expected = new Date(baseTime); + expected.setHours(expected.getHours() + 168); + expect(result).toBe(expected.toISOString()); + }); + + it('returns null when retries exhausted', () => { + const result = getNextRetryTime(3, DEFAULT_RETRY_SCHEDULE_HOURS, baseTime); + expect(result).toBeNull(); + }); + + it('returns null for custom short schedule', () => { + expect(getNextRetryTime(1, [24], baseTime)).toBeNull(); + }); +}); + +describe('isGracePeriodExpired', () => { + it('returns false for null grace period', () => { + expect(isGracePeriodExpired(null)).toBe(false); + }); + + it('returns true for past date', () => { + expect(isGracePeriodExpired('2020-01-01T00:00:00.000Z')).toBe(true); + }); + + it('returns false for future date', () => { + const future = new Date(); + future.setFullYear(future.getFullYear() + 1); + expect(isGracePeriodExpired(future.toISOString())).toBe(false); + }); +}); diff --git a/services/platform-service/src/modules/dunning/repository.ts b/services/platform-service/src/modules/dunning/repository.ts new file mode 100644 index 00000000..74c5e887 --- /dev/null +++ b/services/platform-service/src/modules/dunning/repository.ts @@ -0,0 +1,247 @@ +/** + * Billing Dunning repository — Cosmos DB CRUD for campaigns and policies. + * @module dunning/repository + */ + +import { getContainer } from '../../lib/cosmos.js'; +import type { + DunningCampaignDoc, + DunningPolicyDoc, + DunningStatus, + DunningActionEntry, +} from './types.js'; + +// ============================================================================= +// Dunning Campaigns +// ============================================================================= + +export async function createCampaign(doc: DunningCampaignDoc): Promise { + const container = getContainer('dunning_campaigns'); + const { resource } = await container.items.create(doc); + if (!resource) throw new Error('Failed to create dunning campaign'); + return resource as unknown as DunningCampaignDoc; +} + +export async function getCampaign( + id: string, + productId: string +): Promise { + const container = getContainer('dunning_campaigns'); + try { + const { resource } = await container.item(id, productId).read(); + return resource as unknown as DunningCampaignDoc | null; + } catch (err) { + if ((err as { code?: number }).code === 404) return null; + throw err; + } +} + +export async function updateCampaign( + id: string, + productId: string, + updates: Partial +): Promise { + const existing = await getCampaign(id, productId); + if (!existing) return null; + + const container = getContainer('dunning_campaigns'); + const updated: DunningCampaignDoc = { + ...existing, + ...updates, + id: existing.id, + productId: existing.productId, + updatedAt: new Date().toISOString(), + }; + + const { resource } = await container.items.upsert(updated); + if (!resource) throw new Error('Failed to update dunning campaign'); + return resource as unknown as DunningCampaignDoc; +} + +export async function addCampaignAction( + id: string, + productId: string, + action: DunningActionEntry +): Promise { + const existing = await getCampaign(id, productId); + if (!existing) return null; + + return updateCampaign(id, productId, { + actions: [...existing.actions, action], + }); +} + +export async function listCampaigns( + productId: string, + options?: { status?: DunningStatus; userId?: string; limit?: number } +): Promise<{ campaigns: DunningCampaignDoc[]; total: number }> { + const container = getContainer('dunning_campaigns'); + + let query = 'SELECT * FROM c WHERE c.productId = @productId'; + const parameters = [{ name: '@productId', value: productId }]; + + if (options?.status) { + query += ' AND c.status = @status'; + parameters.push({ name: '@status', value: options.status }); + } + + if (options?.userId) { + query += ' AND c.userId = @userId'; + parameters.push({ name: '@userId', value: options.userId }); + } + + query += ' ORDER BY c.createdAt DESC'; + + const countQuery = query.replace('SELECT *', 'SELECT VALUE COUNT(1)'); + const { resources: countResult } = await container.items + .query({ query: countQuery, parameters }) + .fetchAll(); + const total = countResult[0] ?? 0; + + const safeLimit = Math.min(Math.max(options?.limit ?? 50, 1), 200); + query += ` OFFSET 0 LIMIT ${safeLimit}`; + + const { resources } = await container.items + .query({ query, parameters }) + .fetchAll(); + + return { campaigns: resources, total }; +} + +export async function getActiveCampaignForUser( + productId: string, + userId: string, + subscriptionId: string +): Promise { + const container = getContainer('dunning_campaigns'); + const query = + 'SELECT * FROM c WHERE c.productId = @productId AND c.userId = @userId AND c.subscriptionId = @subId AND c.status IN ("active", "grace_period")'; + const parameters = [ + { name: '@productId', value: productId }, + { name: '@userId', value: userId }, + { name: '@subId', value: subscriptionId }, + ]; + + const { resources } = await container.items + .query({ query, parameters }) + .fetchAll(); + + return resources[0] ?? null; +} + +export async function getCampaignsDueForRetry(productId: string): Promise { + const container = getContainer('dunning_campaigns'); + const now = new Date().toISOString(); + const query = + 'SELECT * FROM c WHERE c.productId = @productId AND c.status = "active" AND c.nextRetryAt != null AND c.nextRetryAt <= @now'; + const parameters = [ + { name: '@productId', value: productId }, + { name: '@now', value: now }, + ]; + + const { resources } = await container.items + .query({ query, parameters }) + .fetchAll(); + + return resources; +} + +export async function getCampaignsWithExpiredGrace( + productId: string +): Promise { + const container = getContainer('dunning_campaigns'); + const now = new Date().toISOString(); + const query = + 'SELECT * FROM c WHERE c.productId = @productId AND c.status = "grace_period" AND c.gracePeriodEndsAt != null AND c.gracePeriodEndsAt <= @now'; + const parameters = [ + { name: '@productId', value: productId }, + { name: '@now', value: now }, + ]; + + const { resources } = await container.items + .query({ query, parameters }) + .fetchAll(); + + return resources; +} + +export async function getDunningStats(productId: string): Promise<{ + active: number; + gracePeriod: number; + resolved: number; + downgraded: number; + cancelled: number; + totalRevenueLostCents: number; + totalRevenueRecoveredCents: number; +}> { + const container = getContainer('dunning_campaigns'); + const query = 'SELECT c.status, c.amountCents FROM c WHERE c.productId = @productId'; + const parameters = [{ name: '@productId', value: productId }]; + + const { resources } = await container.items + .query<{ status: DunningStatus; amountCents: number }>({ query, parameters }) + .fetchAll(); + + let active = 0, + gracePeriod = 0, + resolved = 0, + downgraded = 0, + cancelled = 0; + let totalRevenueLostCents = 0, + totalRevenueRecoveredCents = 0; + + for (const r of resources) { + switch (r.status) { + case 'active': + active++; + break; + case 'grace_period': + gracePeriod++; + break; + case 'resolved': + resolved++; + totalRevenueRecoveredCents += r.amountCents; + break; + case 'downgraded': + downgraded++; + totalRevenueLostCents += r.amountCents; + break; + case 'cancelled': + cancelled++; + totalRevenueLostCents += r.amountCents; + break; + } + } + + return { + active, + gracePeriod, + resolved, + downgraded, + cancelled, + totalRevenueLostCents, + totalRevenueRecoveredCents, + }; +} + +// ============================================================================= +// Dunning Policies (singleton per product) +// ============================================================================= + +export async function getPolicy(productId: string): Promise { + const container = getContainer('dunning_policies'); + try { + const { resource } = await container.item(productId, productId).read(); + return resource as unknown as DunningPolicyDoc | null; + } catch (err) { + if ((err as { code?: number }).code === 404) return null; + throw err; + } +} + +export async function upsertPolicy(doc: DunningPolicyDoc): Promise { + const container = getContainer('dunning_policies'); + const { resource } = await container.items.upsert(doc); + if (!resource) throw new Error('Failed to upsert dunning policy'); + return resource as unknown as DunningPolicyDoc; +} diff --git a/services/platform-service/src/modules/dunning/routes.ts b/services/platform-service/src/modules/dunning/routes.ts new file mode 100644 index 00000000..803009bf --- /dev/null +++ b/services/platform-service/src/modules/dunning/routes.ts @@ -0,0 +1,378 @@ +/** + * Billing Dunning routes — campaign management, retry, policy config. + * @module dunning/routes + */ + +import type { FastifyInstance } from 'fastify'; +import { + UnauthorizedError, + ForbiddenError, + NotFoundError, + BadRequestError, + ConflictError, +} from '../../lib/errors.js'; +import { getRequestProductId } from '../../lib/request-context.js'; +import { + CreateDunningCampaignSchema, + ResolveCampaignSchema, + UpdateDunningPolicySchema, + DEFAULT_RETRY_SCHEDULE_HOURS, + DEFAULT_GRACE_PERIOD_DAYS, + getNextRetryTime, + type DunningCampaignDoc, + type DunningPolicyDoc, + type DunningStatus, +} from './types.js'; +import * as repo from './repository.js'; + +function requireAuth(req: { jwtPayload?: { sub: string } }): string { + if (!req.jwtPayload?.sub) throw new UnauthorizedError('Authentication required'); + return req.jwtPayload.sub; +} + +function requireAdmin(req: { jwtPayload?: { sub: string; role?: string } }): string { + const userId = requireAuth(req); + if (req.jwtPayload?.role !== 'admin') throw new ForbiddenError('Admin access required'); + return userId; +} + +export async function dunningRoutes(app: FastifyInstance): Promise { + // ── Create dunning campaign (triggered by Stripe webhook) ── + app.post('/dunning/campaigns', async (req, reply) => { + requireAdmin(req); + const productId = getRequestProductId(req); + const input = CreateDunningCampaignSchema.parse(req.body); + + // Check for existing active campaign for this subscription + const existing = await repo.getActiveCampaignForUser( + productId, + input.userId, + input.subscriptionId + ); + if (existing) { + throw new ConflictError( + `Active dunning campaign already exists for subscription ${input.subscriptionId}: ${existing.id}` + ); + } + + // Get policy for retry schedule + const policy = await repo.getPolicy(productId); + const retrySchedule = policy?.retryScheduleHours ?? DEFAULT_RETRY_SCHEDULE_HOURS; + + const now = new Date().toISOString(); + const doc: DunningCampaignDoc = { + id: `dun_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`, + productId, + userId: input.userId, + subscriptionId: input.subscriptionId, + failedPaymentId: input.failedPaymentId, + amountCents: input.amountCents, + currency: input.currency, + status: 'active', + retryCount: 0, + maxRetries: retrySchedule.length, + nextRetryAt: getNextRetryTime(0, retrySchedule, now), + gracePeriodEndsAt: null, + actions: [ + { + action: 'send_reminder', + timestamp: now, + success: true, + details: 'Initial payment failure notification sent', + }, + ], + failureReason: input.failureReason, + declineCode: input.declineCode, + notificationsSent: 1, + resolvedAt: null, + createdAt: now, + updatedAt: now, + }; + + const created = await repo.createCampaign(doc); + req.log.info( + { campaignId: created.id, userId: input.userId, amount: input.amountCents }, + 'Dunning campaign created' + ); + reply.status(201); + return created; + }); + + // ── List campaigns ───────────────────────────────────────── + app.get('/dunning/campaigns', async req => { + requireAdmin(req); + const productId = getRequestProductId(req); + const { + status, + userId, + limit: limitStr, + } = req.query as { + status?: string; + userId?: string; + limit?: string; + }; + + const parsedLimit = limitStr ? parseInt(limitStr, 10) : 50; + const safeLimit = + Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 200) : 50; + + return repo.listCampaigns(productId, { + status: status as DunningStatus | undefined, + userId, + limit: safeLimit, + }); + }); + + // ── Get single campaign ──────────────────────────────────── + app.get<{ Params: { id: string } }>('/dunning/campaigns/:id', async req => { + requireAdmin(req); + const productId = getRequestProductId(req); + const { id } = req.params; + const campaign = await repo.getCampaign(id, productId); + if (!campaign) throw new NotFoundError('Dunning campaign not found'); + return campaign; + }); + + // ── Manually resolve a campaign ──────────────────────────── + app.post<{ Params: { id: string } }>('/dunning/campaigns/:id/resolve', async req => { + requireAdmin(req); + const productId = getRequestProductId(req); + const { id } = req.params; + const input = ResolveCampaignSchema.parse(req.body); + + const campaign = await repo.getCampaign(id, productId); + if (!campaign) throw new NotFoundError('Dunning campaign not found'); + + if (campaign.status === 'resolved' || campaign.status === 'cancelled') { + throw new BadRequestError(`Campaign already ${campaign.status}`); + } + + const now = new Date().toISOString(); + const updated = await repo.updateCampaign(id, productId, { + status: 'resolved', + resolvedAt: now, + actions: [ + ...campaign.actions, + { + action: 'retry_payment', + timestamp: now, + success: true, + details: `Manually resolved: ${input.resolution}${input.notes ? ` — ${input.notes}` : ''}`, + }, + ], + }); + + req.log.info({ campaignId: id, resolution: input.resolution }, 'Dunning campaign resolved'); + return updated; + }); + + // ── Retry payment for a campaign ─────────────────────────── + app.post<{ Params: { id: string } }>('/dunning/campaigns/:id/retry', async req => { + requireAdmin(req); + const productId = getRequestProductId(req); + const { id } = req.params; + + const campaign = await repo.getCampaign(id, productId); + if (!campaign) throw new NotFoundError('Dunning campaign not found'); + + if (campaign.status !== 'active' && campaign.status !== 'grace_period') { + throw new BadRequestError(`Cannot retry: campaign status is ${campaign.status}`); + } + + // Get policy + const policy = await repo.getPolicy(productId); + const retrySchedule = policy?.retryScheduleHours ?? DEFAULT_RETRY_SCHEDULE_HOURS; + const gracePeriodDays = policy?.gracePeriodDays ?? DEFAULT_GRACE_PERIOD_DAYS; + + const now = new Date().toISOString(); + const newRetryCount = campaign.retryCount + 1; + + // In production, this would call Stripe to retry the payment. + // For MVP, simulate the retry as failed and advance the schedule. + const retrySuccess = false; + + if (retrySuccess) { + await repo.updateCampaign(id, productId, { + status: 'resolved', + resolvedAt: now, + retryCount: newRetryCount, + nextRetryAt: null, + actions: [ + ...campaign.actions, + { + action: 'retry_payment', + timestamp: now, + success: true, + details: 'Payment retry succeeded', + }, + ], + }); + return { status: 'resolved', message: 'Payment retry succeeded' }; + } + + // Retry failed — check if we've exhausted retries + if (newRetryCount >= retrySchedule.length) { + // Enter grace period + const gracePeriodEnd = new Date(); + gracePeriodEnd.setDate(gracePeriodEnd.getDate() + gracePeriodDays); + + await repo.updateCampaign(id, productId, { + status: 'grace_period', + retryCount: newRetryCount, + nextRetryAt: null, + gracePeriodEndsAt: gracePeriodEnd.toISOString(), + notificationsSent: campaign.notificationsSent + 1, + actions: [ + ...campaign.actions, + { + action: 'retry_payment', + timestamp: now, + success: false, + details: `Retry ${newRetryCount} failed`, + }, + { + action: 'send_warning', + timestamp: now, + success: true, + details: `Grace period started (${gracePeriodDays} days)`, + }, + ], + }); + + return { + status: 'grace_period', + message: `All ${retrySchedule.length} retries exhausted. Grace period of ${gracePeriodDays} days started.`, + gracePeriodEndsAt: gracePeriodEnd.toISOString(), + }; + } + + // Schedule next retry + const nextRetry = getNextRetryTime(newRetryCount, retrySchedule, campaign.createdAt); + await repo.updateCampaign(id, productId, { + retryCount: newRetryCount, + nextRetryAt: nextRetry, + notificationsSent: campaign.notificationsSent + 1, + actions: [ + ...campaign.actions, + { + action: 'retry_payment', + timestamp: now, + success: false, + details: `Retry ${newRetryCount} failed`, + }, + { + action: 'send_reminder', + timestamp: now, + success: true, + details: `Payment reminder sent (attempt ${newRetryCount + 1} scheduled)`, + }, + ], + }); + + return { + status: 'active', + message: `Retry ${newRetryCount} failed. Next retry scheduled.`, + nextRetryAt: nextRetry, + }; + }); + + // ── Process expired grace periods ────────────────────────── + app.post('/dunning/process-expired', async req => { + requireAdmin(req); + const productId = getRequestProductId(req); + + const expired = await repo.getCampaignsWithExpiredGrace(productId); + const policy = await repo.getPolicy(productId); + const autoDowngrade = policy?.autoDowngrade ?? true; + const now = new Date().toISOString(); + + const results: Array<{ campaignId: string; action: string }> = []; + + for (const campaign of expired) { + const newStatus: DunningStatus = autoDowngrade ? 'downgraded' : 'cancelled'; + const actionType = autoDowngrade ? 'downgrade' : 'cancel'; + + await repo.updateCampaign(campaign.id, productId, { + status: newStatus, + actions: [ + ...campaign.actions, + { + action: actionType, + timestamp: now, + success: true, + details: autoDowngrade + ? `Subscription downgraded to ${policy?.downgradeToPlan ?? 'free'} after grace period expired` + : 'Subscription cancelled after grace period expired', + }, + ], + }); + + results.push({ campaignId: campaign.id, action: newStatus }); + } + + req.log.info({ productId, processedCount: results.length }, 'Processed expired grace periods'); + return { processed: results.length, results }; + }); + + // ── Dunning stats ────────────────────────────────────────── + app.get('/dunning/stats', async req => { + requireAdmin(req); + const productId = getRequestProductId(req); + return repo.getDunningStats(productId); + }); + + // ── Get dunning policy ───────────────────────────────────── + app.get('/dunning/policy', async req => { + requireAdmin(req); + const productId = getRequestProductId(req); + const policy = await repo.getPolicy(productId); + + if (!policy) { + // Return defaults + return { + productId, + retryScheduleHours: DEFAULT_RETRY_SCHEDULE_HOURS, + gracePeriodDays: DEFAULT_GRACE_PERIOD_DAYS, + autoDowngrade: true, + downgradeToPlan: 'free', + reminderTemplates: ['payment_failed', 'payment_retry', 'grace_period_warning'], + sendFinalWarning: true, + finalWarningDaysBeforeEnd: 2, + }; + } + + return policy; + }); + + // ── Update dunning policy ────────────────────────────────── + app.put('/dunning/policy', async req => { + const userId = requireAdmin(req); + const productId = getRequestProductId(req); + const input = UpdateDunningPolicySchema.parse(req.body); + + const existing = await repo.getPolicy(productId); + const now = new Date().toISOString(); + + const doc: DunningPolicyDoc = { + id: productId, + productId, + retryScheduleHours: + input.retryScheduleHours ?? existing?.retryScheduleHours ?? DEFAULT_RETRY_SCHEDULE_HOURS, + gracePeriodDays: + input.gracePeriodDays ?? existing?.gracePeriodDays ?? DEFAULT_GRACE_PERIOD_DAYS, + autoDowngrade: input.autoDowngrade ?? existing?.autoDowngrade ?? true, + downgradeToPlan: input.downgradeToPlan ?? existing?.downgradeToPlan ?? 'free', + reminderTemplates: input.reminderTemplates ?? + existing?.reminderTemplates ?? ['payment_failed', 'payment_retry', 'grace_period_warning'], + sendFinalWarning: input.sendFinalWarning ?? existing?.sendFinalWarning ?? true, + finalWarningDaysBeforeEnd: + input.finalWarningDaysBeforeEnd ?? existing?.finalWarningDaysBeforeEnd ?? 2, + updatedAt: now, + updatedBy: userId, + }; + + const saved = await repo.upsertPolicy(doc); + req.log.info({ productId, userId }, 'Dunning policy updated'); + return saved; + }); +} diff --git a/services/platform-service/src/modules/dunning/types.ts b/services/platform-service/src/modules/dunning/types.ts new file mode 100644 index 00000000..13d16b2d --- /dev/null +++ b/services/platform-service/src/modules/dunning/types.ts @@ -0,0 +1,138 @@ +/** + * Billing Dunning module — types and schemas. + * Handles failed payment recovery: retry schedules, grace periods, + * customer notifications, and eventual subscription downgrade/cancellation. + */ + +import { z } from 'zod'; + +// ── Dunning Campaign Types ─────────────────────────────────────── + +export type DunningStatus = + | 'active' // payment failed, retries in progress + | 'grace_period' // retries exhausted, user has grace period + | 'resolved' // payment succeeded or manually resolved + | 'downgraded' // subscription downgraded after grace period + | 'cancelled'; // subscription cancelled after all recovery failed + +export type DunningAction = + | 'retry_payment' + | 'send_reminder' + | 'send_warning' + | 'send_final_notice' + | 'downgrade' + | 'cancel'; + +export interface DunningCampaignDoc { + id: string; + productId: string; + userId: string; + subscriptionId: string; + /** Stripe payment intent / invoice that failed */ + failedPaymentId: string; + /** Amount in cents */ + amountCents: number; + currency: string; + status: DunningStatus; + /** Number of retry attempts made */ + retryCount: number; + /** Max retries before entering grace period */ + maxRetries: number; + /** Next scheduled retry timestamp */ + nextRetryAt: string | null; + /** Grace period end timestamp */ + gracePeriodEndsAt: string | null; + /** Timeline of all actions taken */ + actions: DunningActionEntry[]; + /** Why the payment failed (from Stripe) */ + failureReason: string; + /** Stripe decline code if available */ + declineCode: string | null; + /** Whether the user has been notified */ + notificationsSent: number; + resolvedAt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface DunningActionEntry { + action: DunningAction; + timestamp: string; + success: boolean; + details: string; +} + +export interface DunningPolicyDoc { + id: string; + productId: string; + /** Retry schedule in hours from initial failure (e.g. [24, 72, 168]) */ + retryScheduleHours: number[]; + /** Grace period in days after retries exhausted */ + gracePeriodDays: number; + /** Whether to auto-downgrade after grace period (vs cancel) */ + autoDowngrade: boolean; + /** Plan to downgrade to (if autoDowngrade is true) */ + downgradeToPlan: string; + /** Email templates to send at each retry */ + reminderTemplates: string[]; + /** Whether to send a final warning before downgrade/cancel */ + sendFinalWarning: boolean; + /** Days before grace period end to send final warning */ + finalWarningDaysBeforeEnd: number; + updatedAt: string; + updatedBy: string; +} + +// ── Schemas ────────────────────────────────────────────────────── + +export const CreateDunningCampaignSchema = z.object({ + userId: z.string().min(1), + subscriptionId: z.string().min(1), + failedPaymentId: z.string().min(1), + amountCents: z.number().int().min(1), + currency: z.string().length(3).default('usd'), + failureReason: z.string().min(1).max(500), + declineCode: z.string().max(64).nullable().default(null), +}); + +export const ResolveCampaignSchema = z.object({ + resolution: z.enum(['payment_succeeded', 'manual_resolution', 'waived']), + notes: z.string().max(500).optional(), +}); + +export const UpdateDunningPolicySchema = z.object({ + retryScheduleHours: z.array(z.number().int().min(1).max(720)).min(1).max(10).optional(), + gracePeriodDays: z.number().int().min(1).max(90).optional(), + autoDowngrade: z.boolean().optional(), + downgradeToPlan: z.string().min(1).max(64).optional(), + reminderTemplates: z.array(z.string().max(128)).max(10).optional(), + sendFinalWarning: z.boolean().optional(), + finalWarningDaysBeforeEnd: z.number().int().min(1).max(30).optional(), +}); + +export type CreateDunningCampaignInput = z.infer; +export type ResolveCampaignInput = z.infer; +export type UpdateDunningPolicyInput = z.infer; + +// ── Helpers ────────────────────────────────────────────────────── + +/** Default retry schedule: 1 day, 3 days, 7 days after failure */ +export const DEFAULT_RETRY_SCHEDULE_HOURS = [24, 72, 168]; +export const DEFAULT_GRACE_PERIOD_DAYS = 7; + +export function getNextRetryTime( + retryCount: number, + scheduleHours: number[], + failedAt: string +): string | null { + if (retryCount >= scheduleHours.length) return null; + const hoursFromFailure = scheduleHours[retryCount]!; + const retryDate = new Date(failedAt); + retryDate.setHours(retryDate.getHours() + hoursFromFailure); + return retryDate.toISOString(); +} + +export function isGracePeriodExpired(gracePeriodEndsAt: string | null): boolean { + if (!gracePeriodEndsAt) return false; + return new Date() > new Date(gracePeriodEndsAt); +}