From 3639b0f8728e2e280ddf8a3ad5e2ace405bc8a4b Mon Sep 17 00:00:00 2001 From: olathedev Date: Mon, 28 Sep 2026 09:34:57 +0100 Subject: [PATCH] feat(billing): proration, dunning, promo codes and metered pricing - Prorate mid-cycle plan changes with create_prorations, always_invoice and none behaviours, plus a change preview (#812) - Dunning with merchant-configurable retry schedules, payment attempts, automated retry processing and final actions (#813) - Promo codes with percent/fixed discounts, once/repeating/forever durations and redemption limits (#814) - Per-metric metered pricing with sum/max/last aggregation and per-unit, package, graduated and volume models (#815) Closes #812, closes #813, closes #814, closes #815 --- backend/src/routes/subscription-billing.ts | 190 ++++ .../subscription-billing-lifecycle.test.ts | 507 ++++++++++ .../billing/__tests__/discounts.test.ts | 123 +++ .../billing/__tests__/dunning.test.ts | 26 + .../billing/__tests__/metered-pricing.test.ts | 147 +++ .../billing/__tests__/proration.test.ts | 90 ++ backend/src/services/billing/discounts.ts | 200 ++++ backend/src/services/billing/dunning.ts | 72 ++ .../src/services/billing/metered-pricing.ts | 192 ++++ backend/src/services/billing/money.ts | 5 + backend/src/services/billing/proration.ts | 107 +++ backend/src/services/subscription-billing.ts | 870 ++++++++++++++++-- docs/SUBSCRIPTION_USAGE_BILLING.md | 146 ++- 13 files changed, 2613 insertions(+), 62 deletions(-) create mode 100644 backend/src/services/__tests__/subscription-billing-lifecycle.test.ts create mode 100644 backend/src/services/billing/__tests__/discounts.test.ts create mode 100644 backend/src/services/billing/__tests__/dunning.test.ts create mode 100644 backend/src/services/billing/__tests__/metered-pricing.test.ts create mode 100644 backend/src/services/billing/__tests__/proration.test.ts create mode 100644 backend/src/services/billing/discounts.ts create mode 100644 backend/src/services/billing/dunning.ts create mode 100644 backend/src/services/billing/metered-pricing.ts create mode 100644 backend/src/services/billing/money.ts create mode 100644 backend/src/services/billing/proration.ts diff --git a/backend/src/routes/subscription-billing.ts b/backend/src/routes/subscription-billing.ts index e320084a..807fe418 100644 --- a/backend/src/routes/subscription-billing.ts +++ b/backend/src/routes/subscription-billing.ts @@ -11,6 +11,24 @@ const pricingTierSchema = z.object({ unitPrice: z.number().nonnegative(), }); +const meterTierSchema = z.object({ + upTo: z.number().positive().nullable(), + unitPrice: z.number().nonnegative(), + flatFee: z.number().nonnegative().optional(), +}); + +const meteredPriceSchema = z.object({ + metric: z.string().min(1).max(80), + displayName: z.string().min(1).max(120).optional(), + aggregation: z.enum(['sum', 'max', 'last']).optional(), + model: z.enum(['per_unit', 'package', 'graduated', 'volume']), + includedUnits: z.number().nonnegative().optional(), + unitPrice: z.number().nonnegative().optional(), + packageSize: z.number().int().positive().optional(), + packagePrice: z.number().nonnegative().optional(), + tiers: z.array(meterTierSchema).min(1).optional(), +}); + const createPlanSchema = z.object({ id: z.string().min(1).optional(), name: z.string().min(1).max(120), @@ -19,6 +37,7 @@ const createPlanSchema = z.object({ includedUnits: z.number().nonnegative().optional(), overageUnitPrice: z.number().nonnegative().optional(), tiers: z.array(pricingTierSchema).min(1).optional(), + meters: z.array(meteredPriceSchema).min(1).optional(), billingInterval: z.enum(['monthly', 'annual']).optional(), features: z.array(z.string()).optional(), }); @@ -28,6 +47,7 @@ const createSubscriptionSchema = z.object({ customerId: z.string().min(1), planId: z.string().min(1), trialDays: z.number().int().nonnegative().optional(), + promoCode: z.string().min(1).max(40).optional(), }); const recordUsageSchema = z.object({ @@ -41,6 +61,50 @@ const cancelSubscriptionSchema = z.object({ atPeriodEnd: z.boolean().optional(), }); +const changePlanSchema = z.object({ + planId: z.string().min(1), + prorationBehavior: z.enum(['create_prorations', 'always_invoice', 'none']).optional(), +}); + +const createPromoCodeSchema = z.object({ + code: z.string().min(3).max(40), + description: z.string().max(200).optional(), + merchantId: z.string().min(1).optional(), + discountType: z.enum(['percent', 'fixed']), + percentOff: z.number().positive().max(100).optional(), + amountOff: z.number().positive().optional(), + currency: z.string().length(3).optional(), + duration: z.enum(['once', 'repeating', 'forever']).optional(), + durationInPeriods: z.number().int().positive().optional(), + maxRedemptions: z.number().int().positive().optional(), + perCustomerLimit: z.number().int().positive().optional(), + appliesToPlanIds: z.array(z.string().min(1)).optional(), + minimumAmount: z.number().nonnegative().optional(), + startsAt: z.string().datetime().optional(), + expiresAt: z.string().datetime().optional(), +}); + +const validatePromoCodeSchema = z.object({ + code: z.string().min(1).max(40), + merchantId: z.string().min(1), + customerId: z.string().min(1), + planId: z.string().min(1), +}); + +const applyPromoCodeSchema = z.object({ + code: z.string().min(1).max(40), +}); + +const dunningConfigSchema = z.object({ + retryScheduleDays: z.array(z.number().positive()).min(1).max(10).optional(), + finalAction: z.enum(['cancel_subscription', 'mark_uncollectible']).optional(), +}); + +const paymentAttemptSchema = z.object({ + success: z.boolean(), + failureReason: z.string().max(200).optional(), +}); + /** * Translate errors thrown by the in-memory billing service into AppError so the * shared error handler preserves status codes and error codes. @@ -133,6 +197,40 @@ subscriptionBillingRouter.post( }), ); +subscriptionBillingRouter.get( + '/subscriptions/:id/change-plan/preview', + serviceHandler((req: Request, res: Response) => { + const planId = req.query.planId; + if (typeof planId !== 'string' || !planId) { + throw new AppError(400, 'planId query parameter is required', 'VALIDATION_ERROR'); + } + res.json({ data: subscriptionBillingService.previewPlanChange(String(req.params.id), planId) }); + }), +); + +subscriptionBillingRouter.post( + '/subscriptions/:id/change-plan', + validate(changePlanSchema), + serviceHandler((req: Request, res: Response) => { + res.json({ data: subscriptionBillingService.changePlan(String(req.params.id), req.body) }); + }), +); + +subscriptionBillingRouter.post( + '/subscriptions/:id/discount', + validate(applyPromoCodeSchema), + serviceHandler((req: Request, res: Response) => { + res.json({ data: subscriptionBillingService.applyPromoCode(String(req.params.id), req.body.code) }); + }), +); + +subscriptionBillingRouter.delete( + '/subscriptions/:id/discount', + serviceHandler((req: Request, res: Response) => { + res.json({ data: subscriptionBillingService.removeDiscount(String(req.params.id)) }); + }), +); + // ---------------------------------------------------------------- metering subscriptionBillingRouter.post( @@ -202,3 +300,95 @@ subscriptionBillingRouter.post( res.json({ data: subscriptionBillingService.voidInvoice(String(req.params.id)) }); }), ); + +subscriptionBillingRouter.post( + '/invoices/:id/payment-attempts', + validate(paymentAttemptSchema), + serviceHandler((req: Request, res: Response) => { + res.json({ data: subscriptionBillingService.recordPaymentAttempt(String(req.params.id), req.body) }); + }), +); + +// ------------------------------------------------------------- promo codes + +subscriptionBillingRouter.post( + '/promo-codes', + validate(createPromoCodeSchema), + serviceHandler((req: Request, res: Response) => { + res.status(201).json({ data: subscriptionBillingService.createPromoCode(req.body) }); + }), +); + +subscriptionBillingRouter.get( + '/promo-codes', + serviceHandler((req: Request, res: Response) => { + const { merchantId, active } = req.query; + res.json({ + data: subscriptionBillingService.listPromoCodes({ + merchantId: merchantId as string | undefined, + active: active === undefined ? undefined : active === 'true', + }), + }); + }), +); + +subscriptionBillingRouter.post( + '/promo-codes/validate', + validate(validatePromoCodeSchema), + serviceHandler((req: Request, res: Response) => { + res.json({ data: subscriptionBillingService.validatePromoCode(req.body) }); + }), +); + +subscriptionBillingRouter.get( + '/promo-codes/:code', + serviceHandler((req: Request, res: Response) => { + const promo = subscriptionBillingService.getPromoCode(String(req.params.code)); + if (!promo) throw new AppError(404, 'Promo code not found', 'NOT_FOUND'); + res.json({ data: promo }); + }), +); + +subscriptionBillingRouter.post( + '/promo-codes/:code/deactivate', + serviceHandler((req: Request, res: Response) => { + res.json({ data: subscriptionBillingService.deactivatePromoCode(String(req.params.code)) }); + }), +); + +// ----------------------------------------------------------------- dunning + +subscriptionBillingRouter.get( + '/dunning/config/:merchantId', + serviceHandler((req: Request, res: Response) => { + res.json({ data: subscriptionBillingService.getDunningConfig(String(req.params.merchantId)) }); + }), +); + +subscriptionBillingRouter.put( + '/dunning/config/:merchantId', + validate(dunningConfigSchema), + serviceHandler((req: Request, res: Response) => { + res.json({ data: subscriptionBillingService.configureDunning(String(req.params.merchantId), req.body) }); + }), +); + +subscriptionBillingRouter.get( + '/dunning/invoices', + serviceHandler((req: Request, res: Response) => { + const { merchantId, status } = req.query; + res.json({ + data: subscriptionBillingService.listDunningInvoices({ + merchantId: merchantId as string | undefined, + status: status as never, + }), + }); + }), +); + +subscriptionBillingRouter.post( + '/dunning/process', + serviceHandler(async (_req: Request, res: Response) => { + res.json({ data: await subscriptionBillingService.processDunning() }); + }), +); diff --git a/backend/src/services/__tests__/subscription-billing-lifecycle.test.ts b/backend/src/services/__tests__/subscription-billing-lifecycle.test.ts new file mode 100644 index 00000000..e6d49493 --- /dev/null +++ b/backend/src/services/__tests__/subscription-billing-lifecycle.test.ts @@ -0,0 +1,507 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { SubscriptionBillingService } from '../subscription-billing.js'; + +const DAY = 24 * 60 * 60 * 1000; + +describe('SubscriptionBillingService — billing lifecycle (#812, #813, #814, #815)', () => { + let service: SubscriptionBillingService; + let now: number; + + beforeEach(() => { + now = new Date('2026-02-01T00:00:00.000Z').getTime(); + service = new SubscriptionBillingService(() => now); + }); + + const expectError = (fn: () => unknown, statusCode: number, message?: RegExp) => { + try { + fn(); + throw new Error('Expected function to throw'); + } catch (err) { + const error = err as Error & { statusCode?: number }; + expect(error.statusCode).toBe(statusCode); + if (message) expect(error.message).toMatch(message); + } + }; + + const seedPlans = () => { + const basic = service.createPlan({ id: 'basic', name: 'Basic', basePrice: 30 }); + const pro = service.createPlan({ id: 'pro', name: 'Pro', basePrice: 60 }); + return { basic, pro }; + }; + + const subscribe = (planId: string, extra: { customerId?: string; promoCode?: string; trialDays?: number } = {}) => + service.subscribe({ merchantId: 'm1', customerId: extra.customerId ?? 'c1', planId, ...extra }); + + // --------------------------------------------------------------- #815 + + describe('usage-based billing with metered pricing (#815)', () => { + const seedMeteredPlan = () => + service.createPlan({ + id: 'usage', + name: 'Usage', + basePrice: 0, + meters: [ + { metric: 'api_calls', model: 'per_unit', includedUnits: 1_000, unitPrice: 0.002 }, + { metric: 'seats', displayName: 'Seats', aggregation: 'max', model: 'per_unit', unitPrice: 10 }, + { + metric: 'storage_gb', + aggregation: 'last', + model: 'volume', + tiers: [ + { upTo: 100, unitPrice: 0.5 }, + { upTo: null, unitPrice: 0.25 }, + ], + }, + ], + }); + + it('normalises meter defaults on the plan', () => { + const plan = seedMeteredPlan(); + expect(plan.meters?.[0]).toMatchObject({ aggregation: 'sum', includedUnits: 1_000 }); + }); + + it('aggregates and prices each meter independently', () => { + seedMeteredPlan(); + const { id } = subscribe('usage'); + + service.recordUsage({ subscriptionId: id, metric: 'api_calls', quantity: 700 }); + service.recordUsage({ subscriptionId: id, metric: 'api_calls', quantity: 800 }); + for (const seats of [3, 5, 4]) service.recordUsage({ subscriptionId: id, metric: 'seats', quantity: seats }); + service.recordUsage({ subscriptionId: id, metric: 'storage_gb', quantity: 300 }); + service.recordUsage({ subscriptionId: id, metric: 'storage_gb', quantity: 80 }); + + const usage = service.getUsage(id); + const byMetric = Object.fromEntries(usage.meters!.map((m) => [m.metric, m])); + expect(byMetric.api_calls).toMatchObject({ quantity: 1_500, billableUnits: 500, amount: 1 }); + expect(byMetric.seats).toMatchObject({ quantity: 5, amount: 50 }); + expect(byMetric.storage_gb).toMatchObject({ quantity: 80, amount: 40 }); + expect(usage.usageAmount).toBe(91); + + const invoice = service.generateInvoice(id); + expect(invoice.usageAmount).toBe(91); + expect(invoice.total).toBe(91); + expect(invoice.lineItems.map((l) => l.description)).toEqual([ + 'Base plan — Usage', + 'Metered usage — api_calls', + 'Metered usage — Seats', + 'Metered usage — storage_gb', + ]); + }); + + it('rejects usage for metrics the plan does not meter', () => { + seedMeteredPlan(); + const { id } = subscribe('usage'); + expectError( + () => service.recordUsage({ subscriptionId: id, metric: 'bandwidth', quantity: 1 }), + 400, + /not metered on plan/, + ); + }); + + it('resets meter readings when the period closes', () => { + seedMeteredPlan(); + const { id } = subscribe('usage'); + service.recordUsage({ subscriptionId: id, metric: 'seats', quantity: 5 }); + + service.closeBillingPeriod(id); + expect(service.getUsage(id).usageAmount).toBe(0); + expect(service.getSubscription(id)!.meterReadings).toEqual({}); + }); + + it('rejects invalid or duplicate meters', () => { + expectError( + () => service.createPlan({ name: 'Bad', basePrice: 0, meters: [{ metric: 'x', model: 'per_unit' }] }), + 400, + /unitPrice/, + ); + expectError( + () => + service.createPlan({ + name: 'Dup', + basePrice: 0, + meters: [ + { metric: 'x', model: 'per_unit', unitPrice: 1 }, + { metric: 'x', model: 'per_unit', unitPrice: 2 }, + ], + }), + 400, + /Duplicate meter/, + ); + }); + }); + + // --------------------------------------------------------------- #812 + + describe('proration for mid-cycle plan changes (#812)', () => { + it('previews a plan change without mutating the subscription', () => { + seedPlans(); + const { id } = subscribe('basic'); + now += 10 * DAY; + + const preview = service.previewPlanChange(id, 'pro'); + expect(preview).toMatchObject({ unusedCredit: 20, remainingCharge: 40, net: 20, intervalChange: false }); + expect(service.getSubscription(id)!.planId).toBe('basic'); + }); + + it('splits the closing invoice between plans with create_prorations', () => { + seedPlans(); + const { id } = subscribe('basic'); + now += 10 * DAY; + + const result = service.changePlan(id, { planId: 'pro' }); + expect(result.proration?.net).toBe(20); + expect(result.invoice).toBeUndefined(); + expect(result.subscription.planId).toBe('pro'); + + now += 20 * DAY; + const { invoice } = service.closeBillingPeriod(id); + // 30 * 1/3 + 60 * 2/3 + expect(invoice.baseAmount).toBe(50); + expect(invoice.lineItems).toHaveLength(2); + expect(invoice.lineItems[0].description).toMatch(/Basic \(prorated/); + expect(invoice.lineItems[1].description).toMatch(/Pro \(prorated/); + + // The next period is billed in full on the new plan. + expect(service.closeBillingPeriod(id).invoice.baseAmount).toBe(60); + }); + + it('credits a downgrade', () => { + seedPlans(); + const { id } = subscribe('pro'); + now += 15 * DAY; + + const { proration } = service.changePlan(id, { planId: 'basic' }); + expect(proration?.net).toBe(-15); + expect(service.closeBillingPeriod(id).invoice.baseAmount).toBe(45); + }); + + it('invoices the elapsed time and usage immediately with always_invoice', () => { + service.createPlan({ id: 'basic', name: 'Basic', basePrice: 30, overageUnitPrice: 1 }); + service.createPlan({ id: 'pro', name: 'Pro', basePrice: 60 }); + const { id } = subscribe('basic'); + service.recordUsage({ subscriptionId: id, metric: 'api_calls', quantity: 5 }); + now += 10 * DAY; + + const { invoice } = service.changePlan(id, { planId: 'pro', prorationBehavior: 'always_invoice' }); + expect(invoice).toMatchObject({ kind: 'plan_change', baseAmount: 10, usageAmount: 5, total: 15 }); + expect(invoice!.periodEnd).toBe(new Date(now).toISOString()); + expect(service.getUsage(id).totalUnits).toBe(0); + + now += 20 * DAY; + expect(service.closeBillingPeriod(id).invoice.baseAmount).toBe(40); + }); + + it('bills the whole period on the new plan with none', () => { + seedPlans(); + const { id } = subscribe('basic'); + now += 10 * DAY; + + expect(service.changePlan(id, { planId: 'pro', prorationBehavior: 'none' }).proration).toBeNull(); + const { invoice } = service.closeBillingPeriod(id); + expect(invoice.baseAmount).toBe(60); + expect(invoice.lineItems).toHaveLength(1); + }); + + it('settles and restarts the period on a billing interval change', () => { + seedPlans(); + service.createPlan({ id: 'pro-annual', name: 'Pro Annual', basePrice: 600, billingInterval: 'annual' }); + const { id } = subscribe('basic'); + now += 10 * DAY; + + expectError( + () => service.changePlan(id, { planId: 'pro-annual', prorationBehavior: 'none' }), + 400, + /requires proration/, + ); + + const { invoice, subscription } = service.changePlan(id, { planId: 'pro-annual' }); + expect(invoice).toMatchObject({ kind: 'plan_change', baseAmount: 10 }); + expect(subscription.currentPeriodStart).toBe(new Date(now).toISOString()); + expect(subscription.currentPeriodEnd).toBe(new Date(now + 365 * DAY).toISOString()); + }); + + it('switches plans without proration during a trial', () => { + seedPlans(); + const { id } = subscribe('basic', { trialDays: 14 }); + now += 5 * DAY; + + const result = service.changePlan(id, { planId: 'pro' }); + expect(result.proration).toBeNull(); + expect(result.subscription.planSegments).toEqual([ + { planId: 'pro', startedAt: result.subscription.currentPeriodStart }, + ]); + }); + + it('rejects invalid plan changes', () => { + seedPlans(); + service.createPlan({ id: 'euro', name: 'Euro', basePrice: 30, currency: 'EUR' }); + const { id } = subscribe('basic'); + + expectError(() => service.changePlan(id, { planId: 'basic' }), 409, /already on this plan/); + expectError(() => service.changePlan(id, { planId: 'euro' }), 400, /USD plan to a EUR plan/); + expectError(() => service.changePlan(id, { planId: 'missing' }), 404, /Billing plan not found/); + service.cancelSubscription(id); + expectError(() => service.changePlan(id, { planId: 'pro' }), 409, /cancelled subscription/); + }); + }); + + // --------------------------------------------------------------- #814 + + describe('promotional codes and discounts (#814)', () => { + it('applies a one-period percentage discount at subscription time', () => { + service.createPlan({ id: 'pro', name: 'Pro', basePrice: 49 }); + service.createPromoCode({ code: 'save20', discountType: 'percent', percentOff: 20 }); + + const { id } = subscribe('pro', { promoCode: 'SAVE20' }); + const first = service.closeBillingPeriod(id).invoice; + expect(first).toMatchObject({ subtotal: 49, discountAmount: 9.8, discountCode: 'SAVE20', total: 39.2 }); + expect(first.lineItems.at(-1)).toMatchObject({ description: 'Discount — SAVE20', amount: -9.8 }); + + expect(service.getSubscription(id)!.discount).toBeUndefined(); + expect(service.closeBillingPeriod(id).invoice.total).toBe(49); + }); + + it('limits repeating discounts to the configured number of periods', () => { + service.createPlan({ id: 'pro', name: 'Pro', basePrice: 50 }); + service.createPromoCode({ + code: 'TENOFF', + discountType: 'fixed', + amountOff: 10, + currency: 'usd', + duration: 'repeating', + durationInPeriods: 2, + }); + + const { id } = subscribe('pro', { promoCode: 'tenoff' }); + const totals = [1, 2, 3].map(() => service.closeBillingPeriod(id).invoice.total); + expect(totals).toEqual([40, 40, 50]); + }); + + it('keeps forever discounts across periods', () => { + service.createPlan({ id: 'pro', name: 'Pro', basePrice: 50 }); + service.createPromoCode({ code: 'LOYAL', discountType: 'percent', percentOff: 10, duration: 'forever' }); + const { id } = subscribe('pro', { promoCode: 'LOYAL' }); + + const totals = [1, 2, 3].map(() => service.closeBillingPeriod(id).invoice.total); + expect(totals).toEqual([45, 45, 45]); + }); + + it('does not create a subscription when the promo code is invalid', () => { + service.createPlan({ id: 'pro', name: 'Pro', basePrice: 49 }); + service.createPromoCode({ code: 'OLD', discountType: 'percent', percentOff: 10 }); + service.deactivatePromoCode('old'); + + expectError(() => subscribe('pro', { promoCode: 'OLD' }), 400, /inactive/); + expectError(() => subscribe('pro', { promoCode: 'NOPE' }), 404, /Promo code not found/); + expect(service.listSubscriptions()).toHaveLength(0); + }); + + it('enforces per-customer and total redemption limits', () => { + service.createPlan({ id: 'pro', name: 'Pro', basePrice: 49 }); + const promo = service.createPromoCode({ + code: 'LAUNCH', + discountType: 'percent', + percentOff: 50, + maxRedemptions: 2, + }); + + subscribe('pro', { customerId: 'c1', promoCode: 'LAUNCH' }); + expectError(() => subscribe('pro', { customerId: 'c1', promoCode: 'LAUNCH' }), 400, /already redeemed/); + subscribe('pro', { customerId: 'c2', promoCode: 'LAUNCH' }); + expectError(() => subscribe('pro', { customerId: 'c3', promoCode: 'LAUNCH' }), 400, /redemption limit/); + + expect(service.getPromoCode(promo.id)!.timesRedeemed).toBe(2); + expect(service.listPromoRedemptions(promo.id)).toHaveLength(2); + }); + + it('validates a code without redeeming it', () => { + service.createPlan({ id: 'pro', name: 'Pro', basePrice: 49 }); + service.createPlan({ id: 'basic', name: 'Basic', basePrice: 19 }); + service.createPromoCode({ code: 'PROONLY', discountType: 'percent', percentOff: 20, appliesToPlanIds: ['pro'] }); + + const ok = service.validatePromoCode({ code: 'proonly', merchantId: 'm1', customerId: 'c1', planId: 'pro' }); + expect(ok).toMatchObject({ valid: true, estimatedDiscount: 9.8 }); + + const wrongPlan = service.validatePromoCode({ code: 'PROONLY', merchantId: 'm1', customerId: 'c1', planId: 'basic' }); + expect(wrongPlan).toMatchObject({ valid: false, reason: 'Promo code does not apply to this plan' }); + + expect(service.validatePromoCode({ code: 'X', merchantId: 'm1', customerId: 'c1', planId: 'pro' }).valid).toBe(false); + expect(service.getPromoCode('PROONLY')!.timesRedeemed).toBe(0); + }); + + it('applies and removes discounts on an existing subscription', () => { + service.createPlan({ id: 'pro', name: 'Pro', basePrice: 50 }); + service.createPromoCode({ code: 'FIRST', discountType: 'percent', percentOff: 10, duration: 'forever' }); + service.createPromoCode({ code: 'SECOND', discountType: 'percent', percentOff: 20, duration: 'forever' }); + const { id } = subscribe('pro'); + + service.applyPromoCode(id, 'FIRST'); + expectError(() => service.applyPromoCode(id, 'SECOND'), 409, /already has an active discount/); + + service.removeDiscount(id); + service.applyPromoCode(id, 'SECOND'); + expect(service.generateInvoice(id).total).toBe(40); + expectError(() => service.removeDiscount('missing'), 404); + }); + + it('rejects duplicate codes and unknown plan restrictions', () => { + service.createPromoCode({ code: 'DUP', discountType: 'percent', percentOff: 10 }); + expectError(() => service.createPromoCode({ code: 'dup', discountType: 'percent', percentOff: 5 }), 409); + expectError( + () => service.createPromoCode({ code: 'GHOST', discountType: 'percent', percentOff: 5, appliesToPlanIds: ['x'] }), + 404, + ); + expectError(() => service.createPromoCode({ code: 'BAD', discountType: 'percent', percentOff: 0 }), 400); + }); + }); + + // --------------------------------------------------------------- #813 + + describe('dunning management (#813)', () => { + const openInvoice = () => { + seedPlans(); + const subscription = subscribe('basic'); + const invoice = service.generateInvoice(subscription.id); + return { subscriptionId: subscription.id, invoiceId: invoice.id }; + }; + + it('starts dunning on the first failure and marks the subscription past due', () => { + const { subscriptionId, invoiceId } = openInvoice(); + + const invoice = service.recordPaymentAttempt(invoiceId, { success: false, failureReason: 'card_declined' }); + expect(invoice.dunning).toMatchObject({ + status: 'retrying', + retriesAttempted: 0, + retryScheduleDays: [1, 3, 5, 7], + nextRetryAt: new Date(now + DAY).toISOString(), + }); + expect(invoice.dunning!.history[0]).toMatchObject({ attempt: 0, success: false, failureReason: 'card_declined' }); + expect(service.getSubscription(subscriptionId)!.status).toBe('past_due'); + + // Service keeps running while dunning is in progress. + expect(() => service.recordUsage({ subscriptionId, metric: 'api_calls', quantity: 1 })).not.toThrow(); + }); + + it('recovers when a retry succeeds', () => { + const { subscriptionId, invoiceId } = openInvoice(); + service.recordPaymentAttempt(invoiceId, { success: false }); + now += DAY; + + const invoice = service.recordPaymentAttempt(invoiceId, { success: true }); + expect(invoice.status).toBe('paid'); + expect(invoice.dunning).toMatchObject({ status: 'recovered', retriesAttempted: 1 }); + expect(invoice.dunning!.nextRetryAt).toBeUndefined(); + expect(service.getSubscription(subscriptionId)!.status).toBe('active'); + }); + + it('cancels the subscription once the default schedule is exhausted', () => { + const { subscriptionId, invoiceId } = openInvoice(); + service.recordPaymentAttempt(invoiceId, { success: false }); + + const expectedRetries = [3, 5, 7].map((d) => new Date(now + d * DAY).toISOString()); + for (const retryAt of expectedRetries) { + expect(service.recordPaymentAttempt(invoiceId, { success: false }).dunning!.nextRetryAt).toBe(retryAt); + } + + const invoice = service.recordPaymentAttempt(invoiceId, { success: false }); + expect(invoice.status).toBe('uncollectible'); + expect(invoice.dunning).toMatchObject({ status: 'exhausted', retriesAttempted: 4 }); + expect(invoice.dunning!.history).toHaveLength(5); + expect(service.getSubscription(subscriptionId)!.status).toBe('cancelled'); + }); + + it('honours a merchant schedule and mark_uncollectible final action', () => { + service.configureDunning('m1', { retryScheduleDays: [2], finalAction: 'mark_uncollectible' }); + const { subscriptionId, invoiceId } = openInvoice(); + + expect(service.recordPaymentAttempt(invoiceId, { success: false }).dunning!.nextRetryAt).toBe( + new Date(now + 2 * DAY).toISOString(), + ); + // Later config changes do not affect dunning already in progress. + service.configureDunning('m1', { retryScheduleDays: [1, 2, 3] }); + + const invoice = service.recordPaymentAttempt(invoiceId, { success: false }); + expect(invoice.status).toBe('uncollectible'); + expect(service.getSubscription(subscriptionId)!.status).toBe('past_due'); + + // An uncollectible invoice can still be paid later, which reactivates the subscription. + expect(service.markInvoicePaid(invoiceId).status).toBe('paid'); + expect(service.getSubscription(subscriptionId)!.status).toBe('active'); + }); + + it('returns defaults and rejects invalid configuration', () => { + expect(service.getDunningConfig('m9')).toMatchObject({ + retryScheduleDays: [1, 3, 5, 7], + finalAction: 'cancel_subscription', + }); + expectError(() => service.configureDunning('m1', { retryScheduleDays: [5, 2] }), 400, /ascending/); + expectError(() => service.configureDunning('m1', { retryScheduleDays: [] }), 400, /at least one/); + }); + + it('stops dunning when the invoice is paid manually or voided', () => { + const first = openInvoice(); + service.recordPaymentAttempt(first.invoiceId, { success: false }); + expect(service.markInvoicePaid(first.invoiceId).dunning!.status).toBe('recovered'); + expect(service.getSubscription(first.subscriptionId)!.status).toBe('active'); + + const second = service.generateInvoice(first.subscriptionId); + service.recordPaymentAttempt(second.id, { success: false }); + expect(service.voidInvoice(second.id).dunning!.status).toBe('stopped'); + expect(service.getSubscription(first.subscriptionId)!.status).toBe('active'); + }); + + it('refuses attempts on settled invoices', () => { + const { invoiceId } = openInvoice(); + service.markInvoicePaid(invoiceId); + expectError(() => service.recordPaymentAttempt(invoiceId, { success: false }), 409, /paid invoice/); + expectError(() => service.recordPaymentAttempt('missing', { success: true }), 404); + }); + + describe('processDunning', () => { + it('retries only due invoices using the payment attempter', async () => { + seedPlans(); + const a = service.generateInvoice(subscribe('basic', { customerId: 'a' }).id); + const b = service.generateInvoice(subscribe('basic', { customerId: 'b' }).id); + service.recordPaymentAttempt(a.id, { success: false }); + now += 12 * 60 * 60 * 1000; + service.recordPaymentAttempt(b.id, { success: false }); + now += 12 * 60 * 60 * 1000; // a is due, b is not yet + + const attempted: string[] = []; + const result = await service.processDunning((invoice) => { + attempted.push(invoice.id); + return { success: true }; + }); + + expect(attempted).toEqual([a.id]); + expect(result).toEqual({ processed: 1, recovered: [a.id], failed: [], exhausted: [] }); + }); + + it('treats a throwing attempter as a failed retry', async () => { + service.configureDunning('m1', { retryScheduleDays: [1, 2] }); + const { invoiceId } = openInvoice(); + service.recordPaymentAttempt(invoiceId, { success: false }); + now += DAY; + + service.setPaymentAttempter(() => { + throw new Error('gateway timeout'); + }); + const first = await service.processDunning(); + expect(first.failed).toEqual([invoiceId]); + expect(service.getInvoice(invoiceId)!.dunning!.history.at(-1)).toMatchObject({ + success: false, + failureReason: 'gateway timeout', + }); + + now += DAY; + const second = await service.processDunning(); + expect(second.exhausted).toEqual([invoiceId]); + expect(service.listDunningInvoices({ status: 'exhausted' })).toHaveLength(1); + }); + + it('requires a payment attempter', async () => { + await expect(service.processDunning()).rejects.toMatchObject({ statusCode: 409 }); + }); + }); + }); +}); diff --git a/backend/src/services/billing/__tests__/discounts.test.ts b/backend/src/services/billing/__tests__/discounts.test.ts new file mode 100644 index 00000000..a6668945 --- /dev/null +++ b/backend/src/services/billing/__tests__/discounts.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; +import { + PromoCode, + RedemptionContext, + discountAmount, + isDiscountActive, + redemptionError, + toAppliedDiscount, + validatePromoCodeInput, +} from '../discounts.js'; + +const now = new Date('2026-02-01T00:00:00.000Z').getTime(); + +const promo = (overrides: Partial = {}): PromoCode => ({ + id: 'promo_1', + code: 'SAVE20', + discountType: 'percent', + percentOff: 20, + duration: 'once', + perCustomerLimit: 1, + active: true, + timesRedeemed: 0, + createdAt: new Date(now).toISOString(), + ...overrides, +}); + +const ctx = (overrides: Partial = {}): RedemptionContext => ({ + now, + merchantId: 'm1', + planId: 'plan_pro', + currency: 'USD', + planPrice: 49, + customerRedemptions: 0, + ...overrides, +}); + +describe('promo code discounts (#814)', () => { + describe('validatePromoCodeInput', () => { + it('accepts valid percent and fixed codes', () => { + expect(validatePromoCodeInput({ code: 'save20', discountType: 'percent', percentOff: 20 })).toBeNull(); + expect( + validatePromoCodeInput({ + code: 'TEN-OFF', + discountType: 'fixed', + amountOff: 10, + currency: 'USD', + duration: 'repeating', + durationInPeriods: 3, + }), + ).toBeNull(); + }); + + it('rejects malformed input', () => { + expect(validatePromoCodeInput({ code: 'a!', discountType: 'percent', percentOff: 10 })).toMatch(/code must be/); + expect(validatePromoCodeInput({ code: 'BIG', discountType: 'percent', percentOff: 120 })).toMatch(/percentOff/); + expect(validatePromoCodeInput({ code: 'FLAT', discountType: 'fixed', amountOff: 5 })).toMatch( + /currency is required/, + ); + expect( + validatePromoCodeInput({ code: 'REP', discountType: 'percent', percentOff: 10, duration: 'repeating' }), + ).toMatch(/durationInPeriods/); + expect( + validatePromoCodeInput({ code: 'ONCE', discountType: 'percent', percentOff: 10, durationInPeriods: 2 }), + ).toMatch(/only valid for a repeating/); + expect( + validatePromoCodeInput({ + code: 'WINDOW', + discountType: 'percent', + percentOff: 10, + startsAt: '2026-03-01T00:00:00.000Z', + expiresAt: '2026-02-01T00:00:00.000Z', + }), + ).toMatch(/expiresAt must be after startsAt/); + }); + }); + + describe('redemptionError', () => { + it('allows a valid redemption', () => { + expect(redemptionError(promo(), ctx())).toBeNull(); + }); + + it.each([ + [{ active: false }, {}, /inactive/], + [{ startsAt: '2026-03-01T00:00:00.000Z' }, {}, /not yet valid/], + [{ expiresAt: '2026-02-01T00:00:00.000Z' }, {}, /expired/], + [{ maxRedemptions: 5, timesRedeemed: 5 }, {}, /redemption limit/], + [{}, { customerRedemptions: 1 }, /already redeemed/], + [{ merchantId: 'm2' }, {}, /not valid for this merchant/], + [{ appliesToPlanIds: ['plan_basic'] }, {}, /does not apply to this plan/], + [{ discountType: 'fixed', percentOff: undefined, amountOff: 5, currency: 'EUR' }, {}, /currency EUR/], + [{ minimumAmount: 100 }, {}, /below the promo code minimum/], + ] as const)('rejects %o with context %o', (promoOverrides, ctxOverrides, message) => { + expect(redemptionError(promo(promoOverrides as Partial), ctx(ctxOverrides))).toMatch(message); + }); + }); + + describe('discountAmount', () => { + it('applies a percentage to the subtotal', () => { + expect(discountAmount({ discountType: 'percent', percentOff: 20 }, 49)).toBe(9.8); + }); + + it('caps a fixed discount at the subtotal', () => { + expect(discountAmount({ discountType: 'fixed', amountOff: 10 }, 5)).toBe(5); + expect(discountAmount({ discountType: 'fixed', amountOff: 10 }, 0)).toBe(0); + }); + }); + + describe('applied discounts', () => { + it('derives the number of periods from the duration', () => { + const at = new Date(now).toISOString(); + expect(toAppliedDiscount(promo(), at).remainingPeriods).toBe(1); + expect(toAppliedDiscount(promo({ duration: 'repeating', durationInPeriods: 3 }), at).remainingPeriods).toBe(3); + expect(toAppliedDiscount(promo({ duration: 'forever' }), at).remainingPeriods).toBeUndefined(); + }); + + it('reports whether a discount is still active', () => { + const at = new Date(now).toISOString(); + expect(isDiscountActive(undefined)).toBe(false); + expect(isDiscountActive(toAppliedDiscount(promo({ duration: 'forever' }), at))).toBe(true); + expect(isDiscountActive({ ...toAppliedDiscount(promo(), at), remainingPeriods: 0 })).toBe(false); + }); + }); +}); diff --git a/backend/src/services/billing/__tests__/dunning.test.ts b/backend/src/services/billing/__tests__/dunning.test.ts new file mode 100644 index 00000000..35c56649 --- /dev/null +++ b/backend/src/services/billing/__tests__/dunning.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { nextRetryAt, validateRetrySchedule } from '../dunning.js'; + +const DAY = 24 * 60 * 60 * 1000; + +describe('dunning schedules (#813)', () => { + it('accepts an ascending schedule', () => { + expect(validateRetrySchedule([1, 3, 5, 7])).toBeNull(); + expect(validateRetrySchedule([0.5, 2])).toBeNull(); + }); + + it('rejects invalid schedules', () => { + expect(validateRetrySchedule([])).toMatch(/at least one retry/); + expect(validateRetrySchedule([1, 1])).toMatch(/strictly ascending/); + expect(validateRetrySchedule([3, 2])).toMatch(/strictly ascending/); + expect(validateRetrySchedule([-1])).toMatch(/positive numbers/); + expect(validateRetrySchedule([1, 90])).toMatch(/beyond 60 days/); + expect(validateRetrySchedule(Array.from({ length: 11 }, (_, i) => i + 1))).toMatch(/at most 10/); + }); + + it('computes retries relative to the initial failure', () => { + expect(nextRetryAt(0, [1, 3], 0)).toBe(DAY); + expect(nextRetryAt(0, [1, 3], 1)).toBe(3 * DAY); + expect(nextRetryAt(0, [1, 3], 2)).toBeUndefined(); + }); +}); diff --git a/backend/src/services/billing/__tests__/metered-pricing.test.ts b/backend/src/services/billing/__tests__/metered-pricing.test.ts new file mode 100644 index 00000000..b97d3e24 --- /dev/null +++ b/backend/src/services/billing/__tests__/metered-pricing.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest'; +import { + MeteredPrice, + aggregateReading, + applyReading, + priceMeteredUsage, + validateMeteredPrice, +} from '../metered-pricing.js'; + +const meter = (overrides: Partial = {}): MeteredPrice => ({ + metric: 'api_calls', + aggregation: 'sum', + model: 'per_unit', + includedUnits: 0, + unitPrice: 0.01, + ...overrides, +}); + +const tiers = [ + { upTo: 100, unitPrice: 0.1 }, + { upTo: 500, unitPrice: 0.05 }, + { upTo: null, unitPrice: 0.01 }, +]; + +describe('metered pricing (#815)', () => { + describe('priceMeteredUsage', () => { + it('charges per unit beyond the included allowance', () => { + const charge = priceMeteredUsage(meter({ includedUnits: 100, unitPrice: 0.5 }), 150); + expect(charge).toMatchObject({ quantity: 150, billableUnits: 50, amount: 25 }); + }); + + it('charges nothing when usage is within the allowance', () => { + const charge = priceMeteredUsage(meter({ includedUnits: 100 }), 80); + expect(charge.billableUnits).toBe(0); + expect(charge.amount).toBe(0); + }); + + it('rounds package pricing up to whole packages', () => { + const charge = priceMeteredUsage( + meter({ model: 'package', unitPrice: undefined, packageSize: 100, packagePrice: 5 }), + 250, + ); + expect(charge.amount).toBe(15); + }); + + it('prices graduated tiers progressively', () => { + // 100 * 0.1 + 400 * 0.05 + 500 * 0.01 = 10 + 20 + 5 + const charge = priceMeteredUsage(meter({ model: 'graduated', tiers }), 1_000); + expect(charge.amount).toBe(35); + }); + + it('applies tier flat fees once per tier entered', () => { + const charge = priceMeteredUsage( + meter({ + model: 'graduated', + tiers: [ + { upTo: 10, unitPrice: 0, flatFee: 5 }, + { upTo: null, unitPrice: 1 }, + ], + }), + 15, + ); + expect(charge.amount).toBe(10); + }); + + it('prices every unit at the tier reached for volume pricing', () => { + const price = meter({ model: 'volume', tiers }); + expect(priceMeteredUsage(price, 100).amount).toBe(10); // boundary is inclusive + expect(priceMeteredUsage(price, 300).amount).toBe(15); + expect(priceMeteredUsage(price, 1_000).amount).toBe(10); + }); + + it('counts tier boundaries in billable units after the allowance', () => { + const charge = priceMeteredUsage(meter({ model: 'graduated', includedUnits: 50, tiers }), 150); + expect(charge.billableUnits).toBe(100); + expect(charge.amount).toBe(10); + }); + + it('uses the display name in the line description', () => { + expect(priceMeteredUsage(meter({ displayName: 'API calls' }), 1).description).toBe( + 'Metered usage — API calls', + ); + }); + }); + + describe('readings', () => { + it('tracks sum, max, last and count', () => { + let reading = applyReading(undefined, 5); + reading = applyReading(reading, 12); + reading = applyReading(reading, 3); + + expect(reading).toEqual({ sum: 20, max: 12, last: 3, count: 3 }); + expect(aggregateReading(reading, 'sum')).toBe(20); + expect(aggregateReading(reading, 'max')).toBe(12); + expect(aggregateReading(reading, 'last')).toBe(3); + }); + + it('treats a missing reading as zero usage', () => { + expect(aggregateReading(undefined, 'max')).toBe(0); + }); + }); + + describe('validateMeteredPrice', () => { + it('accepts valid prices for every model', () => { + expect(validateMeteredPrice(meter())).toBeNull(); + expect(validateMeteredPrice(meter({ model: 'package', packageSize: 10, packagePrice: 1 }))).toBeNull(); + expect(validateMeteredPrice(meter({ model: 'graduated', tiers }))).toBeNull(); + expect(validateMeteredPrice(meter({ model: 'volume', tiers }))).toBeNull(); + }); + + it('rejects invalid prices', () => { + expect(validateMeteredPrice(meter({ metric: ' ' }))).toMatch(/metric is required/); + expect(validateMeteredPrice(meter({ includedUnits: -1 }))).toMatch(/includedUnits/); + expect(validateMeteredPrice(meter({ unitPrice: undefined }))).toMatch(/requires a non-negative unitPrice/); + expect(validateMeteredPrice(meter({ model: 'package', packageSize: 0, packagePrice: 1 }))).toMatch( + /packageSize/, + ); + expect(validateMeteredPrice(meter({ model: 'graduated', tiers: [] }))).toMatch(/at least one tier/); + expect( + validateMeteredPrice(meter({ model: 'graduated', tiers: [{ upTo: 100, unitPrice: 0.1 }] })), + ).toMatch(/must be open-ended/); + expect( + validateMeteredPrice( + meter({ + model: 'volume', + tiers: [ + { upTo: null, unitPrice: 0.1 }, + { upTo: null, unitPrice: 0.05 }, + ], + }), + ), + ).toMatch(/only the last tier/); + expect( + validateMeteredPrice( + meter({ + model: 'graduated', + tiers: [ + { upTo: 500, unitPrice: 0.1 }, + { upTo: 100, unitPrice: 0.05 }, + { upTo: null, unitPrice: 0.01 }, + ], + }), + ), + ).toMatch(/ascending/); + }); + }); +}); diff --git a/backend/src/services/billing/__tests__/proration.test.ts b/backend/src/services/billing/__tests__/proration.test.ts new file mode 100644 index 00000000..961782b2 --- /dev/null +++ b/backend/src/services/billing/__tests__/proration.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import { quoteProration, remainingFraction, segmentShares } from '../proration.js'; + +const DAY = 24 * 60 * 60 * 1000; +const start = new Date('2026-02-01T00:00:00.000Z').getTime(); +const end = start + 30 * DAY; + +describe('proration (#812)', () => { + describe('remainingFraction', () => { + it('returns the unused share of the period', () => { + expect(remainingFraction(0, 100, 25)).toBe(0.75); + }); + + it('clamps changes outside the period', () => { + expect(remainingFraction(0, 100, -10)).toBe(1); + expect(remainingFraction(0, 100, 150)).toBe(0); + }); + + it('rejects an empty period', () => { + expect(() => remainingFraction(100, 100, 100)).toThrow(RangeError); + }); + }); + + describe('quoteProration', () => { + it('credits unused time and charges the new plan for an upgrade', () => { + const quote = quoteProration({ + currentPrice: 30, + newPrice: 60, + periodStart: start, + periodEnd: end, + changeAt: start + 10 * DAY, + }); + + expect(quote.fractionRemaining).toBeCloseTo(2 / 3, 5); + expect(quote.fractionElapsed).toBeCloseTo(1 / 3, 5); + expect(quote.unusedCredit).toBe(20); + expect(quote.remainingCharge).toBe(40); + expect(quote.net).toBe(20); + }); + + it('produces a negative net for a downgrade', () => { + const quote = quoteProration({ + currentPrice: 60, + newPrice: 30, + periodStart: start, + periodEnd: end, + changeAt: start + 15 * DAY, + }); + expect(quote.net).toBe(-15); + }); + + it('produces no adjustment at the very end of the period', () => { + const quote = quoteProration({ currentPrice: 30, newPrice: 60, periodStart: start, periodEnd: end, changeAt: end }); + expect(quote.net).toBe(0); + }); + }); + + describe('segmentShares', () => { + const segments = [ + { planId: 'basic', startedAt: new Date(start).toISOString() }, + { planId: 'pro', startedAt: new Date(start + 10 * DAY).toISOString() }, + ]; + + it('splits the period between consecutive plan segments', () => { + const shares = segmentShares(segments, start, end); + expect(shares.map((s) => s.planId)).toEqual(['basic', 'pro']); + expect(shares[0].fraction).toBeCloseTo(1 / 3, 5); + expect(shares[1].fraction).toBeCloseTo(2 / 3, 5); + }); + + it('stops at the requested cut-off', () => { + const shares = segmentShares(segments, start, end, start + 15 * DAY); + expect(shares[1].fraction).toBeCloseTo(1 / 6, 5); + expect(shares[1].endedAt).toBe(new Date(start + 15 * DAY).toISOString()); + }); + + it('drops segments that cover no time', () => { + const shares = segmentShares( + [ + { planId: 'basic', startedAt: new Date(start).toISOString() }, + { planId: 'pro', startedAt: new Date(start).toISOString() }, + ], + start, + end, + ); + expect(shares).toHaveLength(1); + expect(shares[0]).toMatchObject({ planId: 'pro', fraction: 1 }); + }); + }); +}); diff --git a/backend/src/services/billing/discounts.ts b/backend/src/services/billing/discounts.ts new file mode 100644 index 00000000..7e5be14c --- /dev/null +++ b/backend/src/services/billing/discounts.ts @@ -0,0 +1,200 @@ +/** + * discounts.ts — Issue #814 + * + * Promotional codes and the discounts they grant. + * + * A promo code grants either a percentage or a fixed amount off an invoice's + * subtotal. The discount lasts for a single billing period (`once`), a fixed + * number of periods (`repeating`) or for the life of the subscription + * (`forever`). Codes can be restricted by merchant, plan, validity window, + * total redemptions, redemptions per customer and a minimum plan price. + */ + +import { roundMoney } from './money.js'; + +export type DiscountType = 'percent' | 'fixed'; +export type DiscountDuration = 'once' | 'repeating' | 'forever'; + +export interface PromoCode { + id: string; + /** Normalised (upper-case) redemption code, unique across the platform. */ + code: string; + description?: string; + merchantId?: string; + discountType: DiscountType; + percentOff?: number; + amountOff?: number; + currency?: string; + duration: DiscountDuration; + durationInPeriods?: number; + maxRedemptions?: number; + perCustomerLimit: number; + appliesToPlanIds?: string[]; + minimumAmount?: number; + startsAt?: string; + expiresAt?: string; + active: boolean; + timesRedeemed: number; + createdAt: string; +} + +export interface PromoCodeInput { + code: string; + description?: string; + merchantId?: string; + discountType: DiscountType; + percentOff?: number; + amountOff?: number; + currency?: string; + duration?: DiscountDuration; + durationInPeriods?: number; + maxRedemptions?: number; + perCustomerLimit?: number; + appliesToPlanIds?: string[]; + minimumAmount?: number; + startsAt?: string; + expiresAt?: string; +} + +/** A discount attached to a subscription after a promo code is redeemed. */ +export interface AppliedDiscount { + promoCodeId: string; + code: string; + discountType: DiscountType; + percentOff?: number; + amountOff?: number; + currency?: string; + duration: DiscountDuration; + /** Billing periods left, including the current one. Undefined for `forever`. */ + remainingPeriods?: number; + appliedAt: string; +} + +export interface RedemptionContext { + now: number; + merchantId: string; + planId: string; + currency: string; + planPrice: number; + customerRedemptions: number; +} + +export const PROMO_CODE_PATTERN = /^[A-Z0-9_-]{3,40}$/; + +export function normalizeCode(code: string): string { + return code.trim().toUpperCase(); +} + +/** Returns a validation error for a new promo code, or `null` when valid. */ +export function validatePromoCodeInput(input: PromoCodeInput): string | null { + if (!PROMO_CODE_PATTERN.test(normalizeCode(input.code ?? ''))) { + return 'code must be 3-40 characters of letters, digits, "-" or "_"'; + } + + if (input.discountType === 'percent') { + if (!(typeof input.percentOff === 'number' && input.percentOff > 0 && input.percentOff <= 100)) { + return 'percentOff must be greater than 0 and at most 100'; + } + if (input.amountOff !== undefined) return 'amountOff cannot be combined with a percent discount'; + } else if (input.discountType === 'fixed') { + if (!(typeof input.amountOff === 'number' && input.amountOff > 0)) { + return 'amountOff must be greater than 0'; + } + if (!input.currency) return 'currency is required for a fixed amount discount'; + if (input.percentOff !== undefined) return 'percentOff cannot be combined with a fixed discount'; + } else { + return `Unknown discountType ${String(input.discountType)}`; + } + + const duration = input.duration ?? 'once'; + if (duration === 'repeating') { + if (!(Number.isInteger(input.durationInPeriods) && (input.durationInPeriods ?? 0) > 0)) { + return 'durationInPeriods must be a positive integer for a repeating discount'; + } + } else if (input.durationInPeriods !== undefined) { + return 'durationInPeriods is only valid for a repeating discount'; + } + + if (input.maxRedemptions !== undefined && !(Number.isInteger(input.maxRedemptions) && input.maxRedemptions > 0)) { + return 'maxRedemptions must be a positive integer'; + } + if ( + input.perCustomerLimit !== undefined && + !(Number.isInteger(input.perCustomerLimit) && input.perCustomerLimit > 0) + ) { + return 'perCustomerLimit must be a positive integer'; + } + if (input.minimumAmount !== undefined && !(input.minimumAmount >= 0)) { + return 'minimumAmount cannot be negative'; + } + + const startsAt = input.startsAt ? Date.parse(input.startsAt) : undefined; + const expiresAt = input.expiresAt ? Date.parse(input.expiresAt) : undefined; + if (startsAt !== undefined && Number.isNaN(startsAt)) return 'startsAt must be a valid date'; + if (expiresAt !== undefined && Number.isNaN(expiresAt)) return 'expiresAt must be a valid date'; + if (startsAt !== undefined && expiresAt !== undefined && expiresAt <= startsAt) { + return 'expiresAt must be after startsAt'; + } + + return null; +} + +/** Returns why a promo code cannot be redeemed in `ctx`, or `null` if it can. */ +export function redemptionError(promo: PromoCode, ctx: RedemptionContext): string | null { + if (!promo.active) return 'Promo code is inactive'; + if (promo.startsAt && ctx.now < Date.parse(promo.startsAt)) return 'Promo code is not yet valid'; + if (promo.expiresAt && ctx.now >= Date.parse(promo.expiresAt)) return 'Promo code has expired'; + if (promo.maxRedemptions !== undefined && promo.timesRedeemed >= promo.maxRedemptions) { + return 'Promo code has reached its redemption limit'; + } + if (ctx.customerRedemptions >= promo.perCustomerLimit) { + return 'Customer has already redeemed this promo code'; + } + if (promo.merchantId && promo.merchantId !== ctx.merchantId) { + return 'Promo code is not valid for this merchant'; + } + if (promo.appliesToPlanIds && promo.appliesToPlanIds.length > 0 && !promo.appliesToPlanIds.includes(ctx.planId)) { + return 'Promo code does not apply to this plan'; + } + if (promo.discountType === 'fixed' && promo.currency !== ctx.currency) { + return `Promo code currency ${promo.currency} does not match plan currency ${ctx.currency}`; + } + if (promo.minimumAmount !== undefined && ctx.planPrice < promo.minimumAmount) { + return `Plan price is below the promo code minimum of ${promo.minimumAmount}`; + } + return null; +} + +export function toAppliedDiscount(promo: PromoCode, appliedAt: string): AppliedDiscount { + return { + promoCodeId: promo.id, + code: promo.code, + discountType: promo.discountType, + percentOff: promo.percentOff, + amountOff: promo.amountOff, + currency: promo.currency, + duration: promo.duration, + remainingPeriods: + promo.duration === 'once' ? 1 : promo.duration === 'repeating' ? promo.durationInPeriods : undefined, + appliedAt, + }; +} + +/** Discount granted on `subtotal`; never negative and never more than the subtotal. */ +export function discountAmount( + discount: Pick, + subtotal: number, +): number { + if (subtotal <= 0) return 0; + const raw = + discount.discountType === 'percent' + ? subtotal * ((discount.percentOff ?? 0) / 100) + : discount.amountOff ?? 0; + return roundMoney(Math.min(subtotal, Math.max(0, raw))); +} + +/** Whether the discount still applies to invoices in the current period. */ +export function isDiscountActive(discount: AppliedDiscount | undefined): discount is AppliedDiscount { + if (!discount) return false; + return discount.duration === 'forever' || (discount.remainingPeriods ?? 0) > 0; +} diff --git a/backend/src/services/billing/dunning.ts b/backend/src/services/billing/dunning.ts new file mode 100644 index 00000000..49f4344f --- /dev/null +++ b/backend/src/services/billing/dunning.ts @@ -0,0 +1,72 @@ +/** + * dunning.ts — Issue #813 + * + * Dunning management: when a subscription invoice fails to collect, payment + * is retried on a merchant-configurable schedule. The schedule is a list of + * day offsets measured from the *initial* failure, e.g. `[1, 3, 5, 7]` retries + * one, three, five and seven days later. When every retry has failed the + * configured final action is taken. + */ + +export type DunningFinalAction = 'cancel_subscription' | 'mark_uncollectible'; +export type DunningStatus = 'retrying' | 'recovered' | 'exhausted' | 'stopped'; + +export interface DunningConfig { + merchantId: string; + retryScheduleDays: number[]; + finalAction: DunningFinalAction; + updatedAt?: string; +} + +export interface DunningAttempt { + /** 0 is the original collection attempt, 1..n are retries. */ + attempt: number; + attemptedAt: string; + success: boolean; + failureReason?: string; +} + +export interface DunningState { + status: DunningStatus; + startedAt: string; + /** Snapshot of the schedule when dunning started; later config edits don't apply. */ + retryScheduleDays: number[]; + finalAction: DunningFinalAction; + retriesAttempted: number; + nextRetryAt?: string; + history: DunningAttempt[]; + endedAt?: string; +} + +export const DEFAULT_RETRY_SCHEDULE_DAYS: readonly number[] = [1, 3, 5, 7]; +export const DEFAULT_FINAL_ACTION: DunningFinalAction = 'cancel_subscription'; +export const MAX_RETRY_ATTEMPTS = 10; +export const MAX_RETRY_WINDOW_DAYS = 60; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** Returns a validation error for a retry schedule, or `null` when valid. */ +export function validateRetrySchedule(days: number[]): string | null { + if (!Array.isArray(days) || days.length === 0) return 'retryScheduleDays must contain at least one retry'; + if (days.length > MAX_RETRY_ATTEMPTS) return `retryScheduleDays supports at most ${MAX_RETRY_ATTEMPTS} retries`; + + let previous = 0; + for (const day of days) { + if (!(typeof day === 'number' && Number.isFinite(day) && day > 0)) { + return 'retryScheduleDays entries must be positive numbers of days'; + } + if (day <= previous) return 'retryScheduleDays must be strictly ascending'; + if (day > MAX_RETRY_WINDOW_DAYS) return `retryScheduleDays cannot extend beyond ${MAX_RETRY_WINDOW_DAYS} days`; + previous = day; + } + return null; +} + +/** + * When the next retry is due, given how many retries have already run. + * Returns `undefined` once the schedule is exhausted. + */ +export function nextRetryAt(startedAt: number, scheduleDays: number[], retriesAttempted: number): number | undefined { + const offset = scheduleDays[retriesAttempted]; + return offset === undefined ? undefined : startedAt + offset * DAY_MS; +} diff --git a/backend/src/services/billing/metered-pricing.ts b/backend/src/services/billing/metered-pricing.ts new file mode 100644 index 00000000..2e515db3 --- /dev/null +++ b/backend/src/services/billing/metered-pricing.ts @@ -0,0 +1,192 @@ +/** + * metered-pricing.ts — Issue #815 + * + * Usage-based pricing for individual meters. A plan can attach one metered + * price per metric; each meter aggregates the period's usage events + * (sum / max / last), subtracts its free allowance and prices the remaining + * billable units with one of four models: + * + * - `per_unit` — every billable unit costs `unitPrice`. + * - `package` — units are sold in bundles of `packageSize`, rounded up. + * - `graduated` — each tier prices only the units that fall inside it. + * - `volume` — the tier reached by the total prices *all* units. + * + * Tier boundaries (`upTo`) count billable units, i.e. usage after the meter's + * `includedUnits` have been consumed. The last tier must be open-ended + * (`upTo: null`) so every quantity has a price. + */ + +import { roundMoney } from './money.js'; + +export type MeterAggregation = 'sum' | 'max' | 'last'; +export type MeteredPricingModel = 'per_unit' | 'package' | 'graduated' | 'volume'; + +export interface MeterTier { + /** Inclusive upper bound in billable units; `null` means "and above". */ + upTo: number | null; + unitPrice: number; + /** Optional flat fee charged once when usage enters this tier. */ + flatFee?: number; +} + +export interface MeteredPrice { + metric: string; + displayName?: string; + aggregation: MeterAggregation; + model: MeteredPricingModel; + includedUnits: number; + unitPrice?: number; + packageSize?: number; + packagePrice?: number; + tiers?: MeterTier[]; +} + +export interface MeterReading { + sum: number; + max: number; + last: number; + count: number; +} + +export interface MeterCharge { + metric: string; + description: string; + aggregation: MeterAggregation; + model: MeteredPricingModel; + /** Aggregated quantity for the period. */ + quantity: number; + includedUnits: number; + billableUnits: number; + amount: number; +} + +export const METER_AGGREGATIONS: readonly MeterAggregation[] = ['sum', 'max', 'last']; +export const METERED_PRICING_MODELS: readonly MeteredPricingModel[] = [ + 'per_unit', + 'package', + 'graduated', + 'volume', +]; + +/** Returns a human readable validation error, or `null` when the price is valid. */ +export function validateMeteredPrice(price: MeteredPrice): string | null { + if (!price.metric?.trim()) return 'Meter metric is required'; + if (!METER_AGGREGATIONS.includes(price.aggregation)) { + return `Meter ${price.metric}: unknown aggregation ${price.aggregation}`; + } + if (!(price.includedUnits >= 0)) return `Meter ${price.metric}: includedUnits cannot be negative`; + + switch (price.model) { + case 'per_unit': + if (!(typeof price.unitPrice === 'number' && price.unitPrice >= 0)) { + return `Meter ${price.metric}: per_unit pricing requires a non-negative unitPrice`; + } + return null; + case 'package': + if (!(typeof price.packageSize === 'number' && Number.isInteger(price.packageSize) && price.packageSize > 0)) { + return `Meter ${price.metric}: packageSize must be a positive integer`; + } + if (!(typeof price.packagePrice === 'number' && price.packagePrice >= 0)) { + return `Meter ${price.metric}: packagePrice cannot be negative`; + } + return null; + case 'graduated': + case 'volume': + return validateTiers(price.metric, price.tiers); + default: + return `Meter ${price.metric}: unknown pricing model ${String(price.model)}`; + } +} + +function validateTiers(metric: string, tiers: MeterTier[] | undefined): string | null { + if (!tiers || tiers.length === 0) return `Meter ${metric}: tiered pricing requires at least one tier`; + + let previous = 0; + for (let i = 0; i < tiers.length; i++) { + const tier = tiers[i]; + const isLast = i === tiers.length - 1; + if (!(tier.unitPrice >= 0)) return `Meter ${metric}: tier unitPrice cannot be negative`; + if ((tier.flatFee ?? 0) < 0) return `Meter ${metric}: tier flatFee cannot be negative`; + if (tier.upTo === null) { + if (!isLast) return `Meter ${metric}: only the last tier can be open-ended`; + continue; + } + if (isLast) return `Meter ${metric}: the last tier must be open-ended (upTo: null)`; + if (!(tier.upTo > previous)) return `Meter ${metric}: tiers must be sorted by ascending upTo`; + previous = tier.upTo; + } + return null; +} + +/** Fold a usage event into the running reading for a meter. */ +export function applyReading(reading: MeterReading | undefined, quantity: number): MeterReading { + if (!reading) return { sum: quantity, max: quantity, last: quantity, count: 1 }; + return { + sum: reading.sum + quantity, + max: Math.max(reading.max, quantity), + last: quantity, + count: reading.count + 1, + }; +} + +export function aggregateReading(reading: MeterReading | undefined, aggregation: MeterAggregation): number { + if (!reading) return 0; + return reading[aggregation]; +} + +/** Price a meter's aggregated quantity for one billing period. */ +export function priceMeteredUsage(price: MeteredPrice, quantity: number): MeterCharge { + const billableUnits = Math.max(0, quantity - price.includedUnits); + + let amount = 0; + if (billableUnits > 0) { + switch (price.model) { + case 'per_unit': + amount = billableUnits * (price.unitPrice ?? 0); + break; + case 'package': + amount = Math.ceil(billableUnits / (price.packageSize ?? 1)) * (price.packagePrice ?? 0); + break; + case 'graduated': + amount = priceGraduated(price.tiers ?? [], billableUnits); + break; + case 'volume': + amount = priceVolume(price.tiers ?? [], billableUnits); + break; + } + } + + return { + metric: price.metric, + description: `Metered usage — ${price.displayName ?? price.metric}`, + aggregation: price.aggregation, + model: price.model, + quantity, + includedUnits: price.includedUnits, + billableUnits, + amount: roundMoney(amount), + }; +} + +function priceGraduated(tiers: MeterTier[], units: number): number { + let remaining = units; + let cursor = 0; + let amount = 0; + + for (const tier of tiers) { + if (remaining <= 0) break; + const ceiling = tier.upTo ?? Number.POSITIVE_INFINITY; + const span = Math.min(remaining, ceiling - cursor); + if (span <= 0) continue; + amount += span * tier.unitPrice + (tier.flatFee ?? 0); + remaining -= span; + cursor += span; + } + + return amount; +} + +function priceVolume(tiers: MeterTier[], units: number): number { + const tier = tiers.find((t) => t.upTo === null || units <= t.upTo) ?? tiers[tiers.length - 1]; + return units * tier.unitPrice + (tier.flatFee ?? 0); +} diff --git a/backend/src/services/billing/money.ts b/backend/src/services/billing/money.ts new file mode 100644 index 00000000..33538e5f --- /dev/null +++ b/backend/src/services/billing/money.ts @@ -0,0 +1,5 @@ +/** Round a monetary value, avoiding binary floating point drift (1.005 → 1.01). */ +export function roundMoney(value: number, decimals = 2): number { + const factor = 10 ** decimals; + return Math.round((value + Number.EPSILON) * factor) / factor; +} diff --git a/backend/src/services/billing/proration.ts b/backend/src/services/billing/proration.ts new file mode 100644 index 00000000..b34cc42c --- /dev/null +++ b/backend/src/services/billing/proration.ts @@ -0,0 +1,107 @@ +/** + * proration.ts — Issue #812 + * + * Proration maths for mid-cycle plan changes. + * + * Subscriptions are billed in arrears: the invoice raised when a period closes + * covers the period that just ended. A mid-cycle plan change therefore splits + * the period into *plan segments* and each segment's base price is charged in + * proportion to the share of the period it covered. + * + * Expressed the conventional way, the adjustment for a change is + * `remainingCharge - unusedCredit`: the customer is credited for the unused + * time on the old plan and charged for the same time on the new one. + */ + +import { roundMoney } from './money.js'; + +export type ProrationBehavior = 'create_prorations' | 'always_invoice' | 'none'; + +export const PRORATION_BEHAVIORS: readonly ProrationBehavior[] = [ + 'create_prorations', + 'always_invoice', + 'none', +]; + +export interface PlanSegment { + planId: string; + /** ISO timestamp the plan became effective within the current period. */ + startedAt: string; +} + +export interface SegmentShare extends PlanSegment { + endedAt: string; + /** Share of the full billing period covered by this segment (0..1). */ + fraction: number; +} + +export interface ProrationQuote { + fractionElapsed: number; + fractionRemaining: number; + /** Credit for the unused remainder of the current plan. */ + unusedCredit: number; + /** Charge for the remainder of the period on the new plan. */ + remainingCharge: number; + /** Net adjustment (positive = customer owes more, negative = customer saves). */ + net: number; +} + +/** Share of `[periodStart, periodEnd]` that remains after `at` (clamped to 0..1). */ +export function remainingFraction(periodStart: number, periodEnd: number, at: number): number { + const length = periodEnd - periodStart; + if (!(length > 0)) throw new RangeError('Billing period must have a positive length'); + const clamped = Math.min(Math.max(at, periodStart), periodEnd); + return (periodEnd - clamped) / length; +} + +export function quoteProration(input: { + currentPrice: number; + newPrice: number; + periodStart: number; + periodEnd: number; + changeAt: number; +}): ProrationQuote { + const fractionRemaining = remainingFraction(input.periodStart, input.periodEnd, input.changeAt); + const unusedCredit = roundMoney(input.currentPrice * fractionRemaining); + const remainingCharge = roundMoney(input.newPrice * fractionRemaining); + + return { + fractionElapsed: roundMoney(1 - fractionRemaining, 6), + fractionRemaining: roundMoney(fractionRemaining, 6), + unusedCredit, + remainingCharge, + net: roundMoney(remainingCharge - unusedCredit), + }; +} + +/** + * Split `[from, periodEnd]` between the recorded plan segments. Segments are + * ordered by start time; each one runs until the next segment starts. + */ +export function segmentShares( + segments: PlanSegment[], + periodStart: number, + periodEnd: number, + until: number = periodEnd, +): SegmentShare[] { + const length = periodEnd - periodStart; + if (!(length > 0)) throw new RangeError('Billing period must have a positive length'); + + const ordered = [...segments].sort( + (a, b) => new Date(a.startedAt).getTime() - new Date(b.startedAt).getTime(), + ); + + return ordered + .map((segment, index) => { + const start = Math.max(new Date(segment.startedAt).getTime(), periodStart); + const nextStart = index + 1 < ordered.length ? new Date(ordered[index + 1].startedAt).getTime() : until; + const end = Math.min(nextStart, until, periodEnd); + return { + planId: segment.planId, + startedAt: new Date(start).toISOString(), + endedAt: new Date(Math.max(end, start)).toISOString(), + fraction: Math.max(0, end - start) / length, + }; + }) + .filter((share) => share.fraction > 0); +} diff --git a/backend/src/services/subscription-billing.ts b/backend/src/services/subscription-billing.ts index 2ee77a6b..655532ec 100644 --- a/backend/src/services/subscription-billing.ts +++ b/backend/src/services/subscription-billing.ts @@ -1,5 +1,5 @@ /** - * subscription-billing.ts — Issue #914 + * subscription-billing.ts — Issues #914, #812, #813, #814, #815 * * Subscription billing with usage metering. * @@ -8,14 +8,71 @@ * usage events (idempotent on a caller supplied key), and at the end of each * billing period an invoice is generated that combines the base fee with * tiered overage charges. + * + * On top of that core: + * - #815 plans can attach per-metric metered prices (per-unit, package, + * graduated or volume pricing with sum/max/last aggregation). + * - #812 mid-cycle plan changes are prorated across the billing period. + * - #814 promo codes grant percentage or fixed discounts for one, several or + * all billing periods. + * - #813 failed invoice payments enter dunning and are retried on a + * merchant-configurable schedule before a final action is taken. */ import { randomUUID } from 'node:crypto'; import { BaseService } from './BaseService.js'; +import { roundMoney } from './billing/money.js'; +import { + MeterCharge, + MeterReading, + MeteredPrice, + MeterAggregation, + MeteredPricingModel, + MeterTier, + aggregateReading, + applyReading, + priceMeteredUsage, + validateMeteredPrice, +} from './billing/metered-pricing.js'; +import { + PRORATION_BEHAVIORS, + PlanSegment, + ProrationBehavior, + ProrationQuote, + quoteProration, + segmentShares, +} from './billing/proration.js'; +import { + AppliedDiscount, + PromoCode, + PromoCodeInput, + discountAmount, + isDiscountActive, + normalizeCode, + redemptionError, + toAppliedDiscount, + validatePromoCodeInput, +} from './billing/discounts.js'; +import { + DEFAULT_FINAL_ACTION, + DEFAULT_RETRY_SCHEDULE_DAYS, + DunningConfig, + DunningFinalAction, + DunningState, + DunningStatus, + nextRetryAt, + validateRetrySchedule, +} from './billing/dunning.js'; + +export type { MeterCharge, MeterReading, MeteredPrice, MeterTier } from './billing/metered-pricing.js'; +export type { PlanSegment, ProrationBehavior, ProrationQuote } from './billing/proration.js'; +export type { AppliedDiscount, PromoCode, PromoCodeInput } from './billing/discounts.js'; +export type { DunningConfig, DunningFinalAction, DunningState, DunningStatus } from './billing/dunning.js'; export type BillingInterval = 'monthly' | 'annual'; export type SubscriptionStatus = 'trialing' | 'active' | 'past_due' | 'cancelled'; -export type InvoiceStatus = 'draft' | 'open' | 'paid' | 'void'; +export type InvoiceStatus = 'draft' | 'open' | 'paid' | 'void' | 'uncollectible'; +export type InvoiceKind = 'period' | 'plan_change'; export interface PricingTier { /** Cumulative unit boundary for this tier (e.g. first 10_000 units). */ @@ -24,6 +81,18 @@ export interface PricingTier { unitPrice: number; } +export interface MeteredPriceInput { + metric: string; + displayName?: string; + aggregation?: MeterAggregation; + model: MeteredPricingModel; + includedUnits?: number; + unitPrice?: number; + packageSize?: number; + packagePrice?: number; + tiers?: MeterTier[]; +} + export interface BillingPlan { id: string; name: string; @@ -32,6 +101,8 @@ export interface BillingPlan { includedUnits: number; overageUnitPrice: number; tiers?: PricingTier[]; + /** Per-metric usage pricing (#815). When set, it replaces the plan-wide overage model. */ + meters?: MeteredPrice[]; billingInterval: BillingInterval; features?: string[]; createdAt: string; @@ -49,6 +120,12 @@ export interface Subscription { cancelAtPeriodEnd: boolean; canceledAt?: string; usage: Record; + /** Running aggregates per metric for the current period (#815). */ + meterReadings: Record; + /** Plans in effect during the current period, used for proration (#812). */ + planSegments: PlanSegment[]; + /** Discount granted by a redeemed promo code (#814). */ + discount?: AppliedDiscount; createdAt: string; updatedAt: string; } @@ -71,6 +148,7 @@ export interface InvoiceLineItem { export interface Invoice { id: string; + kind: InvoiceKind; subscriptionId: string; merchantId: string; customerId: string; @@ -82,7 +160,11 @@ export interface Invoice { lineItems: InvoiceLineItem[]; baseAmount: number; usageAmount: number; + subtotal: number; + discountAmount: number; + discountCode?: string; total: number; + dunning?: DunningState; paidAt?: string; createdAt: string; } @@ -94,11 +176,69 @@ export interface UsageSummary { includedUnits: number; overageUnits: number; usageAmount: number; + /** Per-meter breakdown, present when the plan uses metered prices. */ + meters?: MeterCharge[]; currency: string; periodStart: string; periodEnd: string; } +export interface PlanChangePreview extends ProrationQuote { + subscriptionId: string; + currentPlanId: string; + newPlanId: string; + currency: string; + changeAt: string; + intervalChange: boolean; +} + +export interface PlanChangeResult { + subscription: Subscription; + proration: PlanChangePreview | null; + /** Invoice raised immediately for `always_invoice` or billing interval changes. */ + invoice?: Invoice; +} + +export interface PromoCodeValidation { + valid: boolean; + reason?: string; + promoCode?: PromoCode; + /** Discount the code would grant on the plan's base price. */ + estimatedDiscount?: number; +} + +export interface PromoRedemption { + promoCodeId: string; + customerId: string; + subscriptionId: string; + redeemedAt: string; +} + +export interface PaymentAttemptResult { + success: boolean; + failureReason?: string; +} + +export type PaymentAttempter = ( + invoice: Invoice, +) => PaymentAttemptResult | Promise; + +export interface DunningRunResult { + processed: number; + recovered: string[]; + failed: string[]; + exhausted: string[]; +} + +interface UsageCharges { + totalUnits: number; + includedUnits: number; + overageUnits: number; + usageAmount: number; + meters?: MeterCharge[]; + lineItems: InvoiceLineItem[]; +} + const DAY_MS = 24 * 60 * 60 * 1000; const INTERVAL_DAYS: Record = { monthly: 30, annual: 365 }; @@ -108,6 +248,11 @@ export class SubscriptionBillingService extends BaseService { private usageEvents: UsageEvent[] = []; private usageIdempotency = new Map(); private invoices = new Map(); + private promoCodes = new Map(); + private promoCodeIds = new Map(); + private promoRedemptions: PromoRedemption[] = []; + private dunningConfigs = new Map(); + private paymentAttempter?: PaymentAttempter; constructor(private readonly now: () => number = Date.now) { super(); @@ -123,6 +268,7 @@ export class SubscriptionBillingService extends BaseService { includedUnits?: number; overageUnitPrice?: number; tiers?: PricingTier[]; + meters?: MeteredPriceInput[]; billingInterval?: BillingInterval; features?: string[]; }): BillingPlan { @@ -142,14 +288,33 @@ export class SubscriptionBillingService extends BaseService { } } + let meters: MeteredPrice[] | undefined; + if (input.meters) { + this.validate(input.meters.length > 0, 'meters cannot be empty'); + meters = input.meters.map((meter) => ({ + ...meter, + metric: meter.metric?.trim(), + aggregation: meter.aggregation ?? 'sum', + includedUnits: meter.includedUnits ?? 0, + })); + const seen = new Set(); + for (const meter of meters) { + const error = validateMeteredPrice(meter); + this.validate(error === null, error ?? ''); + this.validate(!seen.has(meter.metric), `Duplicate meter for metric ${meter.metric}`); + seen.add(meter.metric); + } + } + const plan: BillingPlan = { id: input.id ?? `plan_${randomUUID()}`, name: input.name.trim(), currency: (input.currency ?? 'USD').toUpperCase(), - basePrice: this.round(input.basePrice), + basePrice: roundMoney(input.basePrice), includedUnits: input.includedUnits ?? 0, overageUnitPrice: input.overageUnitPrice ?? 0, tiers: input.tiers ? [...input.tiers].sort((a, b) => a.upTo - b.upTo) : undefined, + meters, billingInterval: input.billingInterval ?? 'monthly', features: input.features, createdAt: new Date(this.now()).toISOString(), @@ -174,6 +339,7 @@ export class SubscriptionBillingService extends BaseService { customerId: string; planId: string; trialDays?: number; + promoCode?: string; }): Subscription { this.validate(!!input.merchantId, 'merchantId is required'); this.validate(!!input.customerId, 'customerId is required'); @@ -184,8 +350,18 @@ export class SubscriptionBillingService extends BaseService { const trialDays = input.trialDays ?? 0; this.validate(trialDays >= 0, 'trialDays cannot be negative'); + // Resolve the promo code before creating anything so a bad code has no side effects. + const promo = input.promoCode + ? this.requireRedeemable(input.promoCode, { + merchantId: input.merchantId, + customerId: input.customerId, + plan, + }) + : undefined; + const start = this.now(); const periodEnd = start + INTERVAL_DAYS[plan.billingInterval] * DAY_MS; + const periodStart = new Date(start).toISOString(); const subscription: Subscription = { id: `sub_${randomUUID()}`, @@ -193,16 +369,19 @@ export class SubscriptionBillingService extends BaseService { customerId: input.customerId, planId: plan.id, status: trialDays > 0 ? 'trialing' : 'active', - currentPeriodStart: new Date(start).toISOString(), + currentPeriodStart: periodStart, currentPeriodEnd: new Date(periodEnd).toISOString(), trialEndsAt: trialDays > 0 ? new Date(start + trialDays * DAY_MS).toISOString() : undefined, cancelAtPeriodEnd: false, usage: {}, - createdAt: new Date(start).toISOString(), - updatedAt: new Date(start).toISOString(), + meterReadings: {}, + planSegments: [{ planId: plan.id, startedAt: periodStart }], + createdAt: periodStart, + updatedAt: periodStart, }; this.subscriptions.set(subscription.id, subscription); + if (promo) this.redeem(subscription, promo); return subscription; } @@ -240,6 +419,86 @@ export class SubscriptionBillingService extends BaseService { return subscription; } + // ------------------------------------------------------ plan changes (#812) + + /** Quote the proration for moving a subscription to another plan right now. */ + previewPlanChange(subscriptionId: string, planId: string): PlanChangePreview { + const { subscription, currentPlan, newPlan } = this.resolvePlanChange(subscriptionId, planId); + return this.buildPlanChangePreview(subscription, currentPlan, newPlan); + } + + /** + * Move a subscription to another plan mid-cycle. + * + * - `create_prorations` (default): the period is split between the plans and + * each is charged for the share it covered on the closing invoice. + * - `always_invoice`: the time used on the old plan (and its metered usage) + * is invoiced immediately; the rest of the period is billed on the new plan. + * - `none`: no proration, the whole period is billed on the new plan. + * + * Changing the billing interval always settles the current period + * immediately and starts a fresh period on the new plan. + */ + changePlan( + subscriptionId: string, + input: { planId: string; prorationBehavior?: ProrationBehavior }, + ): PlanChangeResult { + const behavior = input.prorationBehavior ?? 'create_prorations'; + this.validate(PRORATION_BEHAVIORS.includes(behavior), `Unknown prorationBehavior ${behavior}`); + + const { subscription, currentPlan, newPlan } = this.resolvePlanChange(subscriptionId, input.planId); + const now = this.now(); + const nowIso = new Date(now).toISOString(); + const intervalChange = currentPlan.billingInterval !== newPlan.billingInterval; + + // Nothing has been consumed during a trial, so the switch is free. + if (subscription.status === 'trialing') { + subscription.planId = newPlan.id; + subscription.planSegments = [{ planId: newPlan.id, startedAt: subscription.currentPeriodStart }]; + if (intervalChange) { + subscription.currentPeriodEnd = new Date( + new Date(subscription.currentPeriodStart).getTime() + INTERVAL_DAYS[newPlan.billingInterval] * DAY_MS, + ).toISOString(); + } + subscription.updatedAt = nowIso; + return { subscription, proration: null }; + } + + this.validate( + !(intervalChange && behavior === 'none'), + 'Changing billing interval requires proration (create_prorations or always_invoice)', + ); + + const proration = + behavior === 'none' ? null : this.buildPlanChangePreview(subscription, currentPlan, newPlan); + + let invoice: Invoice | undefined; + if (intervalChange || behavior === 'always_invoice') { + invoice = this.createInvoice(subscription, currentPlan, 'plan_change', now); + subscription.usage = {}; + subscription.meterReadings = {}; + + if (intervalChange) { + subscription.currentPeriodStart = nowIso; + subscription.currentPeriodEnd = new Date( + now + INTERVAL_DAYS[newPlan.billingInterval] * DAY_MS, + ).toISOString(); + this.consumeDiscountPeriod(subscription); + } + subscription.planSegments = [{ planId: newPlan.id, startedAt: nowIso }]; + } else if (behavior === 'create_prorations') { + subscription.planSegments.push({ planId: newPlan.id, startedAt: nowIso }); + } else { + subscription.planSegments = [{ planId: newPlan.id, startedAt: subscription.currentPeriodStart }]; + } + + subscription.planId = newPlan.id; + subscription.updatedAt = nowIso; + this.subscriptions.set(subscription.id, subscription); + + return { subscription, proration, invoice }; + } + // ---------------------------------------------------------------- metering recordUsage(input: { @@ -253,8 +512,9 @@ export class SubscriptionBillingService extends BaseService { const subscription = this.subscriptions.get(input.subscriptionId); if (!subscription) this.notFound('Subscription', input.subscriptionId); + // Past-due subscriptions keep receiving service while dunning runs, so keep metering them. this.validate( - subscription.status === 'active' || subscription.status === 'trialing', + subscription.status !== 'cancelled', `Cannot meter usage for a ${subscription.status} subscription`, ); @@ -266,6 +526,14 @@ export class SubscriptionBillingService extends BaseService { } } + const plan = this.plans.get(subscription.planId); + if (plan?.meters) { + this.validate( + plan.meters.some((meter) => meter.metric === input.metric), + `Metric ${input.metric} is not metered on plan ${plan.id}`, + ); + } + const event: UsageEvent = { id: `usg_${randomUUID()}`, subscriptionId: subscription.id, @@ -276,6 +544,10 @@ export class SubscriptionBillingService extends BaseService { }; subscription.usage[input.metric] = (subscription.usage[input.metric] ?? 0) + input.quantity; + subscription.meterReadings[input.metric] = applyReading( + subscription.meterReadings[input.metric], + input.quantity, + ); subscription.updatedAt = event.recordedAt; this.usageEvents.push(event); @@ -295,16 +567,16 @@ export class SubscriptionBillingService extends BaseService { const plan = this.plans.get(subscription.planId); if (!plan) this.notFound('Billing plan', subscription.planId); - const totalUnits = Object.values(subscription.usage).reduce((sum, qty) => sum + qty, 0); - const overageUnits = Math.max(0, totalUnits - plan.includedUnits); + const charges = this.computeUsageCharges(subscription, plan); return { subscriptionId: subscription.id, metrics: { ...subscription.usage }, - totalUnits, - includedUnits: plan.includedUnits, - overageUnits, - usageAmount: this.priceOverage(plan, overageUnits), + totalUnits: charges.totalUnits, + includedUnits: charges.includedUnits, + overageUnits: charges.overageUnits, + usageAmount: charges.usageAmount, + meters: charges.meters, currency: plan.currency, periodStart: subscription.currentPeriodStart, periodEnd: subscription.currentPeriodEnd, @@ -319,42 +591,7 @@ export class SubscriptionBillingService extends BaseService { const plan = this.plans.get(subscription.planId); if (!plan) this.notFound('Billing plan', subscription.planId); - const totalUnits = Object.values(subscription.usage).reduce((sum, qty) => sum + qty, 0); - const overageUnits = Math.max(0, totalUnits - plan.includedUnits); - const usageAmount = this.priceOverage(plan, overageUnits); - const baseAmount = plan.basePrice; - - const lineItems: InvoiceLineItem[] = [ - { description: `Base plan — ${plan.name}`, quantity: 1, unitPrice: baseAmount, amount: baseAmount }, - ]; - if (overageUnits > 0) { - lineItems.push({ - description: 'Metered overage', - quantity: overageUnits, - unitPrice: this.round(usageAmount / overageUnits, 6), - amount: usageAmount, - }); - } - - const invoice: Invoice = { - id: `inv_${randomUUID()}`, - subscriptionId: subscription.id, - merchantId: subscription.merchantId, - customerId: subscription.customerId, - planId: plan.id, - periodStart: subscription.currentPeriodStart, - periodEnd: subscription.currentPeriodEnd, - currency: plan.currency, - status: 'open', - lineItems, - baseAmount, - usageAmount, - total: this.round(baseAmount + usageAmount), - createdAt: new Date(this.now()).toISOString(), - }; - - this.invoices.set(invoice.id, invoice); - return invoice; + return this.createInvoice(subscription, plan, 'period'); } getInvoice(id: string): Invoice | undefined { @@ -373,11 +610,22 @@ export class SubscriptionBillingService extends BaseService { markInvoicePaid(id: string): Invoice { const invoice = this.invoices.get(id); if (!invoice) this.notFound('Invoice', id); - this.validate(invoice.status === 'open', `Invoice in status ${invoice.status} cannot be paid`, 'CONFLICT'); + this.validate( + invoice.status === 'open' || invoice.status === 'uncollectible', + `Invoice in status ${invoice.status} cannot be paid`, + 'CONFLICT', + ); + const nowIso = new Date(this.now()).toISOString(); invoice.status = 'paid'; - invoice.paidAt = new Date(this.now()).toISOString(); + invoice.paidAt = nowIso; + if (invoice.dunning?.status === 'retrying') { + invoice.dunning.status = 'recovered'; + invoice.dunning.nextRetryAt = undefined; + invoice.dunning.endedAt = nowIso; + } this.invoices.set(id, invoice); + this.reactivateIfSettled(invoice.subscriptionId); return invoice; } @@ -387,7 +635,13 @@ export class SubscriptionBillingService extends BaseService { if (invoice.status === 'paid') this.conflict('A paid invoice cannot be voided'); invoice.status = 'void'; + if (invoice.dunning?.status === 'retrying') { + invoice.dunning.status = 'stopped'; + invoice.dunning.nextRetryAt = undefined; + invoice.dunning.endedAt = new Date(this.now()).toISOString(); + } this.invoices.set(id, invoice); + this.reactivateIfSettled(invoice.subscriptionId); return invoice; } @@ -409,7 +663,10 @@ export class SubscriptionBillingService extends BaseService { periodStart + INTERVAL_DAYS[plan.billingInterval] * DAY_MS, ).toISOString(); subscription.usage = {}; + subscription.meterReadings = {}; + subscription.planSegments = [{ planId: plan.id, startedAt: subscription.currentPeriodStart }]; subscription.updatedAt = new Date(this.now()).toISOString(); + this.consumeDiscountPeriod(subscription); if (subscription.status === 'trialing' && subscription.trialEndsAt && subscription.trialEndsAt <= invoice.periodEnd) { subscription.status = 'active'; @@ -423,22 +680,425 @@ export class SubscriptionBillingService extends BaseService { return { invoice, subscription }; } + // ------------------------------------------------------ promo codes (#814) + + createPromoCode(input: PromoCodeInput): PromoCode { + const error = validatePromoCodeInput(input); + this.validate(error === null, error ?? ''); + + const code = normalizeCode(input.code); + if (this.promoCodeIds.has(code)) this.conflict(`Promo code ${code} already exists`); + for (const planId of input.appliesToPlanIds ?? []) { + if (!this.plans.has(planId)) this.notFound('Billing plan', planId); + } + + const promo: PromoCode = { + id: `promo_${randomUUID()}`, + code, + description: input.description, + merchantId: input.merchantId, + discountType: input.discountType, + percentOff: input.percentOff, + amountOff: input.amountOff !== undefined ? roundMoney(input.amountOff) : undefined, + currency: input.currency?.toUpperCase(), + duration: input.duration ?? 'once', + durationInPeriods: input.durationInPeriods, + maxRedemptions: input.maxRedemptions, + perCustomerLimit: input.perCustomerLimit ?? 1, + appliesToPlanIds: input.appliesToPlanIds, + minimumAmount: input.minimumAmount, + startsAt: input.startsAt ? new Date(input.startsAt).toISOString() : undefined, + expiresAt: input.expiresAt ? new Date(input.expiresAt).toISOString() : undefined, + active: true, + timesRedeemed: 0, + createdAt: new Date(this.now()).toISOString(), + }; + + this.promoCodes.set(promo.id, promo); + this.promoCodeIds.set(code, promo.id); + return promo; + } + + /** Look up a promo code by id or (case-insensitive) code. */ + getPromoCode(idOrCode: string): PromoCode | undefined { + const byId = this.promoCodes.get(idOrCode); + if (byId) return byId; + const id = this.promoCodeIds.get(normalizeCode(idOrCode)); + return id ? this.promoCodes.get(id) : undefined; + } + + listPromoCodes(filter: { merchantId?: string; active?: boolean } = {}): PromoCode[] { + return Array.from(this.promoCodes.values()).filter( + (promo) => + (!filter.merchantId || promo.merchantId === filter.merchantId) && + (filter.active === undefined || promo.active === filter.active), + ); + } + + deactivatePromoCode(idOrCode: string): PromoCode { + const promo = this.getPromoCode(idOrCode); + if (!promo) this.notFound('Promo code', idOrCode); + promo.active = false; + return promo; + } + + /** Check whether a code can be redeemed without redeeming it. */ + validatePromoCode(input: { + code: string; + merchantId: string; + customerId: string; + planId: string; + }): PromoCodeValidation { + const plan = this.plans.get(input.planId); + if (!plan) this.notFound('Billing plan', input.planId); + + const promo = this.getPromoCode(input.code); + if (!promo) return { valid: false, reason: 'Promo code not found' }; + + const reason = redemptionError(promo, this.redemptionContext(promo, input.merchantId, input.customerId, plan)); + if (reason) return { valid: false, reason, promoCode: promo }; + + return { valid: true, promoCode: promo, estimatedDiscount: discountAmount(promo, plan.basePrice) }; + } + + applyPromoCode(subscriptionId: string, code: string): Subscription { + const subscription = this.subscriptions.get(subscriptionId); + if (!subscription) this.notFound('Subscription', subscriptionId); + if (subscription.status === 'cancelled') this.conflict('Cannot apply a promo code to a cancelled subscription'); + if (isDiscountActive(subscription.discount)) { + this.conflict('Subscription already has an active discount; remove it first'); + } + const plan = this.plans.get(subscription.planId); + if (!plan) this.notFound('Billing plan', subscription.planId); + + const promo = this.requireRedeemable(code, { + merchantId: subscription.merchantId, + customerId: subscription.customerId, + plan, + }); + this.redeem(subscription, promo); + return subscription; + } + + removeDiscount(subscriptionId: string): Subscription { + const subscription = this.subscriptions.get(subscriptionId); + if (!subscription) this.notFound('Subscription', subscriptionId); + if (!subscription.discount) this.notFound('Discount on subscription', subscriptionId); + + subscription.discount = undefined; + subscription.updatedAt = new Date(this.now()).toISOString(); + return subscription; + } + + listPromoRedemptions(promoCodeId: string): PromoRedemption[] { + return this.promoRedemptions.filter((r) => r.promoCodeId === promoCodeId); + } + + // ---------------------------------------------------------- dunning (#813) + + configureDunning( + merchantId: string, + input: { retryScheduleDays?: number[]; finalAction?: DunningFinalAction }, + ): DunningConfig { + this.validate(!!merchantId, 'merchantId is required'); + const current = this.getDunningConfig(merchantId); + + const retryScheduleDays = input.retryScheduleDays ?? current.retryScheduleDays; + const scheduleError = validateRetrySchedule(retryScheduleDays); + this.validate(scheduleError === null, scheduleError ?? ''); + + const finalAction = input.finalAction ?? current.finalAction; + this.validate( + finalAction === 'cancel_subscription' || finalAction === 'mark_uncollectible', + `Unknown finalAction ${String(finalAction)}`, + ); + + const config: DunningConfig = { + merchantId, + retryScheduleDays: [...retryScheduleDays], + finalAction, + updatedAt: new Date(this.now()).toISOString(), + }; + this.dunningConfigs.set(merchantId, config); + return config; + } + + getDunningConfig(merchantId: string): DunningConfig { + return ( + this.dunningConfigs.get(merchantId) ?? { + merchantId, + retryScheduleDays: [...DEFAULT_RETRY_SCHEDULE_DAYS], + finalAction: DEFAULT_FINAL_ACTION, + } + ); + } + + /** Register the function used by `processDunning` to retry a charge. */ + setPaymentAttempter(attempter: PaymentAttempter | undefined): void { + this.paymentAttempter = attempter; + } + + /** + * Record the outcome of a collection attempt for an open invoice. The first + * failure starts dunning and moves the subscription to `past_due`; later + * failures advance the retry schedule until it is exhausted. + */ + recordPaymentAttempt(invoiceId: string, result: PaymentAttemptResult): Invoice { + const invoice = this.invoices.get(invoiceId); + if (!invoice) this.notFound('Invoice', invoiceId); + if (invoice.status !== 'open') { + this.conflict(`Cannot record a payment attempt for a ${invoice.status} invoice`); + } + + const now = this.now(); + const nowIso = new Date(now).toISOString(); + const dunning = invoice.dunning?.status === 'retrying' ? invoice.dunning : undefined; + + if (result.success) { + if (dunning) { + dunning.retriesAttempted += 1; + dunning.history.push({ attempt: dunning.retriesAttempted, attemptedAt: nowIso, success: true }); + } + return this.markInvoicePaid(invoiceId); + } + + const subscription = this.subscriptions.get(invoice.subscriptionId); + + if (!dunning) { + const config = this.getDunningConfig(invoice.merchantId); + invoice.dunning = { + status: 'retrying', + startedAt: nowIso, + retryScheduleDays: [...config.retryScheduleDays], + finalAction: config.finalAction, + retriesAttempted: 0, + nextRetryAt: new Date(nextRetryAt(now, config.retryScheduleDays, 0)!).toISOString(), + history: [{ attempt: 0, attemptedAt: nowIso, success: false, failureReason: result.failureReason }], + }; + if (subscription && (subscription.status === 'active' || subscription.status === 'trialing')) { + subscription.status = 'past_due'; + subscription.updatedAt = nowIso; + } + return invoice; + } + + dunning.retriesAttempted += 1; + dunning.history.push({ + attempt: dunning.retriesAttempted, + attemptedAt: nowIso, + success: false, + failureReason: result.failureReason, + }); + + const next = nextRetryAt(new Date(dunning.startedAt).getTime(), dunning.retryScheduleDays, dunning.retriesAttempted); + if (next !== undefined) { + dunning.nextRetryAt = new Date(next).toISOString(); + return invoice; + } + + dunning.status = 'exhausted'; + dunning.nextRetryAt = undefined; + dunning.endedAt = nowIso; + invoice.status = 'uncollectible'; + if (dunning.finalAction === 'cancel_subscription' && subscription && subscription.status !== 'cancelled') { + subscription.status = 'cancelled'; + subscription.canceledAt = nowIso; + subscription.updatedAt = nowIso; + } + return invoice; + } + + /** Invoices currently (or previously) in dunning. */ + listDunningInvoices(filter: { merchantId?: string; status?: DunningStatus } = {}): Invoice[] { + return Array.from(this.invoices.values()).filter( + (invoice) => + !!invoice.dunning && + (!filter.merchantId || invoice.merchantId === filter.merchantId) && + (!filter.status || invoice.dunning.status === filter.status), + ); + } + + /** Retry every invoice whose next scheduled retry is due. */ + async processDunning(attempter: PaymentAttempter | undefined = this.paymentAttempter): Promise { + if (!attempter) this.conflict('No payment attempter is configured for dunning retries'); + + const now = this.now(); + const due = this.listDunningInvoices({ status: 'retrying' }).filter((invoice) => { + const retryAt = invoice.dunning?.nextRetryAt; + return invoice.status === 'open' && retryAt !== undefined && new Date(retryAt).getTime() <= now; + }); + + const result: DunningRunResult = { processed: 0, recovered: [], failed: [], exhausted: [] }; + for (const invoice of due) { + let outcome: PaymentAttemptResult; + try { + outcome = await attempter(invoice); + } catch (err) { + outcome = { success: false, failureReason: err instanceof Error ? err.message : 'Payment attempt failed' }; + } + + const updated = this.recordPaymentAttempt(invoice.id, outcome); + result.processed += 1; + if (updated.status === 'paid') result.recovered.push(updated.id); + else if (updated.dunning?.status === 'exhausted') result.exhausted.push(updated.id); + else result.failed.push(updated.id); + } + return result; + } + resetForTests(): void { this.plans.clear(); this.subscriptions.clear(); this.usageEvents = []; this.usageIdempotency.clear(); this.invoices.clear(); + this.promoCodes.clear(); + this.promoCodeIds.clear(); + this.promoRedemptions = []; + this.dunningConfigs.clear(); + this.paymentAttempter = undefined; } // --------------------------------------------------------------- internals + /** + * Build and store an invoice for `subscription`. Base charges cover the plan + * segments up to `until` (the period end by default); usage is priced with + * `usagePlan`. + */ + private createInvoice( + subscription: Subscription, + usagePlan: BillingPlan, + kind: InvoiceKind, + until?: number, + ): Invoice { + const periodStartMs = new Date(subscription.currentPeriodStart).getTime(); + const periodEndMs = new Date(subscription.currentPeriodEnd).getTime(); + const invoiceEndMs = Math.min(until ?? periodEndMs, periodEndMs); + + const lineItems: InvoiceLineItem[] = []; + let baseAmount = 0; + for (const share of segmentShares(subscription.planSegments, periodStartMs, periodEndMs, invoiceEndMs)) { + const plan = this.plans.get(share.planId); + if (!plan) this.notFound('Billing plan', share.planId); + + if (share.fraction >= 1) { + lineItems.push({ + description: `Base plan — ${plan.name}`, + quantity: 1, + unitPrice: plan.basePrice, + amount: plan.basePrice, + }); + baseAmount += plan.basePrice; + } else { + const amount = roundMoney(plan.basePrice * share.fraction); + const percent = roundMoney(share.fraction * 100); + lineItems.push({ + description: `Base plan — ${plan.name} (prorated, ${percent}% of period)`, + quantity: roundMoney(share.fraction, 6), + unitPrice: plan.basePrice, + amount, + }); + baseAmount += amount; + } + } + baseAmount = roundMoney(baseAmount); + + const usage = this.computeUsageCharges(subscription, usagePlan); + lineItems.push(...usage.lineItems); + + const subtotal = roundMoney(baseAmount + usage.usageAmount); + let discount = 0; + let discountCode: string | undefined; + if (isDiscountActive(subscription.discount)) { + discount = discountAmount(subscription.discount, subtotal); + if (discount > 0) { + discountCode = subscription.discount.code; + lineItems.push({ + description: `Discount — ${subscription.discount.code}`, + quantity: 1, + unitPrice: -discount, + amount: -discount, + }); + } + } + + const invoice: Invoice = { + id: `inv_${randomUUID()}`, + kind, + subscriptionId: subscription.id, + merchantId: subscription.merchantId, + customerId: subscription.customerId, + planId: usagePlan.id, + periodStart: subscription.currentPeriodStart, + periodEnd: new Date(invoiceEndMs).toISOString(), + currency: usagePlan.currency, + status: 'open', + lineItems, + baseAmount, + usageAmount: usage.usageAmount, + subtotal, + discountAmount: discount, + discountCode, + total: roundMoney(subtotal - discount), + createdAt: new Date(this.now()).toISOString(), + }; + + this.invoices.set(invoice.id, invoice); + return invoice; + } + + private computeUsageCharges(subscription: Subscription, plan: BillingPlan): UsageCharges { + if (plan.meters && plan.meters.length > 0) { + const meters = plan.meters.map((meter) => + priceMeteredUsage(meter, aggregateReading(subscription.meterReadings[meter.metric], meter.aggregation)), + ); + return { + totalUnits: meters.reduce((sum, m) => sum + m.quantity, 0), + includedUnits: meters.reduce((sum, m) => sum + m.includedUnits, 0), + overageUnits: meters.reduce((sum, m) => sum + m.billableUnits, 0), + usageAmount: roundMoney(meters.reduce((sum, m) => sum + m.amount, 0)), + meters, + lineItems: meters + .filter((m) => m.amount > 0) + .map((m) => ({ + description: m.description, + quantity: m.billableUnits, + unitPrice: roundMoney(m.amount / m.billableUnits, 6), + amount: m.amount, + })), + }; + } + + const totalUnits = Object.values(subscription.usage).reduce((sum, qty) => sum + qty, 0); + const overageUnits = Math.max(0, totalUnits - plan.includedUnits); + const usageAmount = this.priceOverage(plan, overageUnits); + + return { + totalUnits, + includedUnits: plan.includedUnits, + overageUnits, + usageAmount, + lineItems: + overageUnits > 0 + ? [ + { + description: 'Metered overage', + quantity: overageUnits, + unitPrice: roundMoney(usageAmount / overageUnits, 6), + amount: usageAmount, + }, + ] + : [], + }; + } + /** Price overage units, honouring graduated tiers when configured. */ private priceOverage(plan: BillingPlan, overageUnits: number): number { if (overageUnits <= 0) return 0; if (!plan.tiers || plan.tiers.length === 0) { - return this.round(overageUnits * plan.overageUnitPrice); + return roundMoney(overageUnits * plan.overageUnitPrice); } let remaining = overageUnits; @@ -457,12 +1117,110 @@ export class SubscriptionBillingService extends BaseService { // Usage beyond the last tier is charged at the plan's flat overage rate. if (remaining > 0) amount += remaining * plan.overageUnitPrice; - return this.round(amount); + return roundMoney(amount); + } + + private resolvePlanChange(subscriptionId: string, planId: string) { + const subscription = this.subscriptions.get(subscriptionId); + if (!subscription) this.notFound('Subscription', subscriptionId); + if (subscription.status === 'cancelled') this.conflict('Cannot change the plan of a cancelled subscription'); + + const currentPlan = this.plans.get(subscription.planId); + if (!currentPlan) this.notFound('Billing plan', subscription.planId); + const newPlan = this.plans.get(planId); + if (!newPlan) this.notFound('Billing plan', planId); + + if (newPlan.id === currentPlan.id) this.conflict('Subscription is already on this plan'); + this.validate( + newPlan.currency === currentPlan.currency, + `Cannot change from a ${currentPlan.currency} plan to a ${newPlan.currency} plan`, + ); + + return { subscription, currentPlan, newPlan }; + } + + private buildPlanChangePreview( + subscription: Subscription, + currentPlan: BillingPlan, + newPlan: BillingPlan, + ): PlanChangePreview { + const now = this.now(); + const quote = quoteProration({ + currentPrice: currentPlan.basePrice, + newPrice: newPlan.basePrice, + periodStart: new Date(subscription.currentPeriodStart).getTime(), + periodEnd: new Date(subscription.currentPeriodEnd).getTime(), + changeAt: now, + }); + + return { + subscriptionId: subscription.id, + currentPlanId: currentPlan.id, + newPlanId: newPlan.id, + currency: currentPlan.currency, + changeAt: new Date(now).toISOString(), + intervalChange: currentPlan.billingInterval !== newPlan.billingInterval, + ...quote, + }; } - private round(value: number, decimals = 2): number { - const factor = 10 ** decimals; - return Math.round((value + Number.EPSILON) * factor) / factor; + private redemptionContext(promo: PromoCode, merchantId: string, customerId: string, plan: BillingPlan) { + return { + now: this.now(), + merchantId, + planId: plan.id, + currency: plan.currency, + planPrice: plan.basePrice, + customerRedemptions: this.promoRedemptions.filter( + (r) => r.promoCodeId === promo.id && r.customerId === customerId, + ).length, + }; + } + + private requireRedeemable( + code: string, + ctx: { merchantId: string; customerId: string; plan: BillingPlan }, + ): PromoCode { + const promo = this.getPromoCode(code); + if (!promo) this.notFound('Promo code', normalizeCode(code)); + const reason = redemptionError(promo, this.redemptionContext(promo, ctx.merchantId, ctx.customerId, ctx.plan)); + this.validate(reason === null, reason ?? '', 'PROMO_CODE_INVALID'); + return promo; + } + + private redeem(subscription: Subscription, promo: PromoCode): void { + const nowIso = new Date(this.now()).toISOString(); + subscription.discount = toAppliedDiscount(promo, nowIso); + subscription.updatedAt = nowIso; + promo.timesRedeemed += 1; + this.promoRedemptions.push({ + promoCodeId: promo.id, + customerId: subscription.customerId, + subscriptionId: subscription.id, + redeemedAt: nowIso, + }); + } + + /** A billing period has ended: count it against a limited-duration discount. */ + private consumeDiscountPeriod(subscription: Subscription): void { + const discount = subscription.discount; + if (!discount || discount.duration === 'forever') return; + discount.remainingPeriods = (discount.remainingPeriods ?? 0) - 1; + if (discount.remainingPeriods <= 0) subscription.discount = undefined; + } + + /** Return a past-due subscription to active once no invoice is still in dunning. */ + private reactivateIfSettled(subscriptionId: string): void { + const subscription = this.subscriptions.get(subscriptionId); + if (!subscription || subscription.status !== 'past_due') return; + + const stillRetrying = Array.from(this.invoices.values()).some( + (invoice) => invoice.subscriptionId === subscriptionId && invoice.dunning?.status === 'retrying', + ); + if (!stillRetrying) { + subscription.status = 'active'; + subscription.updatedAt = new Date(this.now()).toISOString(); + } } } diff --git a/docs/SUBSCRIPTION_USAGE_BILLING.md b/docs/SUBSCRIPTION_USAGE_BILLING.md index 415c5ea3..7b7d4c42 100644 --- a/docs/SUBSCRIPTION_USAGE_BILLING.md +++ b/docs/SUBSCRIPTION_USAGE_BILLING.md @@ -1,30 +1,49 @@ # Subscription Billing with Usage Metering -Issue: [#914](https://github.com/Smartdevs17/agenticpay/issues/914) +Issues: [#914](https://github.com/Smartdevs17/agenticpay/issues/914), +[#812](https://github.com/Smartdevs17/agenticpay/issues/812) (proration), +[#813](https://github.com/Smartdevs17/agenticpay/issues/813) (dunning), +[#814](https://github.com/Smartdevs17/agenticpay/issues/814) (promo codes), +[#815](https://github.com/Smartdevs17/agenticpay/issues/815) (metered pricing) Recurring plans with an included usage allowance plus metered overage billed -per billing period. +per billing period, with mid-cycle plan changes, promotional discounts and +dunning for failed payments. ## Backend Service: `backend/src/services/subscription-billing.ts` +Pricing helpers: `backend/src/services/billing/` (`metered-pricing.ts`, +`proration.ts`, `discounts.ts`, `dunning.ts`) Routes: `backend/src/routes/subscription-billing.ts` (mounted at `/api/v1/billing`) | Method | Path | Purpose | | --- | --- | --- | -| `POST` | `/plans` | Create a plan (base price, included units, overage pricing) | +| `POST` | `/plans` | Create a plan (base price, included units, overage or metered pricing) | | `GET` | `/plans`, `/plans/:id` | List / read plans | -| `POST` | `/subscriptions` | Subscribe a customer to a plan (optional trial) | +| `POST` | `/subscriptions` | Subscribe a customer to a plan (optional trial and `promoCode`) | | `GET` | `/subscriptions` | List subscriptions (`merchantId`, `customerId`, `status`) | | `GET` | `/subscriptions/:id` | Read a subscription | | `POST` | `/subscriptions/:id/cancel` | Cancel now or at period end | +| `GET` | `/subscriptions/:id/change-plan/preview?planId=` | Quote the proration for a plan change | +| `POST` | `/subscriptions/:id/change-plan` | Change plan (`planId`, `prorationBehavior`) | +| `POST` | `/subscriptions/:id/discount` | Redeem a promo code on a subscription | +| `DELETE` | `/subscriptions/:id/discount` | Remove the subscription's discount | | `POST` | `/usage` | Record a metered usage event | -| `GET` | `/subscriptions/:id/usage` | Usage summary (units, overage, amount) | +| `GET` | `/subscriptions/:id/usage` | Usage summary (units, overage, amount, per-meter breakdown) | | `GET` | `/subscriptions/:id/usage-events` | Raw usage events | | `POST` | `/subscriptions/:id/invoices` | Generate an invoice for the period | | `POST` | `/subscriptions/:id/close-period` | Invoice, roll the period, reset usage | | `GET` | `/invoices`, `/invoices/:id` | List / read invoices | | `POST` | `/invoices/:id/pay`, `/invoices/:id/void` | Invoice lifecycle | +| `POST` | `/invoices/:id/payment-attempts` | Record a collection attempt (`success`, `failureReason`) | +| `POST` | `/promo-codes` | Create a promo code | +| `GET` | `/promo-codes`, `/promo-codes/:code` | List (`merchantId`, `active`) / read promo codes | +| `POST` | `/promo-codes/validate` | Check a code for a customer and plan without redeeming it | +| `POST` | `/promo-codes/:code/deactivate` | Stop a code from being redeemed | +| `GET`/`PUT` | `/dunning/config/:merchantId` | Read / set a merchant's retry schedule | +| `GET` | `/dunning/invoices` | Invoices in dunning (`merchantId`, `status`) | +| `POST` | `/dunning/process` | Retry every invoice whose next retry is due | ### Pricing @@ -34,11 +53,126 @@ Routes: `backend/src/routes/subscription-billing.ts` (mounted at `/api/v1/billin progressively; usage past the last tier falls back to `overageUnitPrice`. - Monthly periods are 30 days, annual periods 365. +### Metered pricing (#815) + +A plan can instead attach `meters`, one per metric. When a plan has meters, +each metric is priced on its own and usage for any other metric is rejected. + +```json +{ + "name": "Usage", + "basePrice": 0, + "meters": [ + { "metric": "api_calls", "model": "per_unit", "includedUnits": 1000, "unitPrice": 0.002 }, + { "metric": "seats", "aggregation": "max", "model": "per_unit", "unitPrice": 10 }, + { "metric": "exports", "model": "package", "packageSize": 100, "packagePrice": 5 }, + { + "metric": "storage_gb", + "aggregation": "last", + "model": "volume", + "tiers": [{ "upTo": 100, "unitPrice": 0.5 }, { "upTo": null, "unitPrice": 0.25 }] + } + ] +} +``` + +- **Aggregation** decides the period quantity: `sum` (default) adds every + event, `max` takes the peak (e.g. seats), `last` takes the latest reading + (e.g. storage). +- **Models**: `per_unit`; `package` (bundles of `packageSize`, rounded up); + `graduated` (each tier prices the units inside it); `volume` (the tier + reached prices every unit). Tiers can add a `flatFee`, and the last tier + must be open-ended (`upTo: null`). +- `includedUnits` is subtracted before pricing, and tier boundaries count + billable units after the allowance. +- Invoices get one `Metered usage — ` line per meter with a charge. + ### Idempotency Supplying an `idempotencyKey` when recording usage makes retries safe — the same key returns the original event and is not double-counted. +## Plan changes and proration (#812) + +Invoices are raised in arrears, when a period closes. A mid-cycle plan change +splits the period into plan segments, and each plan is charged for the share +of the period it was active. The preview endpoint (and the `proration` field +of a change) gives the same result as a credit for unused time on the old plan +(`unusedCredit`) and a charge for the rest of the period on the new plan +(`remainingCharge`); `net` is the difference. + +`prorationBehavior`: + +| Value | Effect | +| --- | --- | +| `create_prorations` (default) | The closing invoice carries prorated base lines for each plan. | +| `always_invoice` | The old plan's elapsed time and its metered usage are invoiced now (`kind: "plan_change"`); the rest of the period is billed on the new plan. | +| `none` | No proration: the whole period is billed on the new plan. | + +- Changing the billing interval (monthly ↔ annual) always invoices the elapsed + time straight away and starts a new period on the new plan. `none` is + rejected for interval changes. +- During a trial the plan is switched without proration. +- Plans must share a currency, and a cancelled subscription cannot change plan. +- With `create_prorations`, metered usage is priced by the plan active when the + invoice is raised. Use `always_invoice` to bill usage so far at the old + plan's rates. + +## Promotional codes (#814) + +```json +{ + "code": "LAUNCH20", + "discountType": "percent", + "percentOff": 20, + "duration": "repeating", + "durationInPeriods": 3, + "maxRedemptions": 100, + "appliesToPlanIds": ["plan_pro"], + "expiresAt": "2026-12-31T23:59:59.000Z" +} +``` + +- `discountType` is `percent` (`percentOff`, 0–100) or `fixed` (`amountOff` in + `currency`, which must match the plan's currency). +- `duration`: `once` (first billing period), `repeating` (`durationInPeriods` + periods) or `forever`. +- Optional limits: `merchantId`, `appliesToPlanIds`, `startsAt`/`expiresAt`, + `maxRedemptions` (total), `perCustomerLimit` (default 1) and `minimumAmount` + (minimum plan base price). +- Codes are case-insensitive and unique. Redeem one with `promoCode` when + subscribing or later via `POST /subscriptions/:id/discount`. A subscription + holds one discount at a time. +- The discount applies to the invoice subtotal (base + usage), appears as a + negative `Discount — ` line, is capped at the subtotal, and is recorded + on the invoice as `discountAmount` / `discountCode`. + +## Dunning (#813) + +When a payment attempt fails (`POST /invoices/:id/payment-attempts` with +`success: false`) the invoice enters dunning and the subscription becomes +`past_due`. Usage can still be metered while the subscription is past due. + +- Retries follow the merchant's `retryScheduleDays`, day offsets from the first + failure (default `[1, 3, 5, 7]`; at most 10 retries within 60 days). The + schedule is copied onto the invoice when dunning starts, so later config + changes don't affect it. +- `POST /dunning/process` retries every invoice whose `nextRetryAt` has passed, + using the payment attempter registered with + `subscriptionBillingService.setPaymentAttempter()`. An attempter that throws + counts as a failed retry. +- A successful retry, or a manual `pay`, marks the invoice paid and dunning + `recovered`. Voiding the invoice sets dunning to `stopped`. Once no invoice + is still in dunning, the subscription returns to `active`. +- When every retry fails, dunning is `exhausted`, the invoice becomes + `uncollectible`, and the merchant's `finalAction` runs: + `cancel_subscription` (default) or `mark_uncollectible` (the subscription + stays `past_due`). An uncollectible invoice can still be paid later. +- `invoice.dunning.history` records each attempt with its outcome and failure + reason. + ## Tests -`backend/src/services/__tests__/subscription-billing.test.ts` +- `backend/src/services/__tests__/subscription-billing.test.ts` +- `backend/src/services/__tests__/subscription-billing-lifecycle.test.ts` +- `backend/src/services/billing/__tests__/*.test.ts`