feat(dunning): add billing dunning module — campaigns, policies, retries
- 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
This commit is contained in:
parent
e9cb6b2a38
commit
797f5e4318
195
services/platform-service/src/modules/dunning/dunning.test.ts
Normal file
195
services/platform-service/src/modules/dunning/dunning.test.ts
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
247
services/platform-service/src/modules/dunning/repository.ts
Normal file
247
services/platform-service/src/modules/dunning/repository.ts
Normal file
@ -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<DunningCampaignDoc> {
|
||||||
|
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<DunningCampaignDoc | null> {
|
||||||
|
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<DunningCampaignDoc>
|
||||||
|
): Promise<DunningCampaignDoc | null> {
|
||||||
|
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<DunningCampaignDoc | null> {
|
||||||
|
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<number>({ 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<DunningCampaignDoc>({ query, parameters })
|
||||||
|
.fetchAll();
|
||||||
|
|
||||||
|
return { campaigns: resources, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActiveCampaignForUser(
|
||||||
|
productId: string,
|
||||||
|
userId: string,
|
||||||
|
subscriptionId: string
|
||||||
|
): Promise<DunningCampaignDoc | null> {
|
||||||
|
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<DunningCampaignDoc>({ query, parameters })
|
||||||
|
.fetchAll();
|
||||||
|
|
||||||
|
return resources[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCampaignsDueForRetry(productId: string): Promise<DunningCampaignDoc[]> {
|
||||||
|
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<DunningCampaignDoc>({ query, parameters })
|
||||||
|
.fetchAll();
|
||||||
|
|
||||||
|
return resources;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCampaignsWithExpiredGrace(
|
||||||
|
productId: string
|
||||||
|
): Promise<DunningCampaignDoc[]> {
|
||||||
|
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<DunningCampaignDoc>({ 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<DunningPolicyDoc | null> {
|
||||||
|
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<DunningPolicyDoc> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
378
services/platform-service/src/modules/dunning/routes.ts
Normal file
378
services/platform-service/src/modules/dunning/routes.ts
Normal file
@ -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<void> {
|
||||||
|
// ── 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
138
services/platform-service/src/modules/dunning/types.ts
Normal file
138
services/platform-service/src/modules/dunning/types.ts
Normal file
@ -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<typeof CreateDunningCampaignSchema>;
|
||||||
|
export type ResolveCampaignInput = z.infer<typeof ResolveCampaignSchema>;
|
||||||
|
export type UpdateDunningPolicyInput = z.infer<typeof UpdateDunningPolicySchema>;
|
||||||
|
|
||||||
|
// ── 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);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user