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