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:
saravanakumardb1 2026-03-19 23:49:25 -07:00
parent e9cb6b2a38
commit 797f5e4318
4 changed files with 958 additions and 0 deletions

View 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);
});
});

View 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;
}

View 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;
});
}

View 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);
}