diff --git a/backend/src/index.ts b/backend/src/index.ts index e89b8cdf..9a905f21 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -110,6 +110,7 @@ import { marketplaceEscrowRouter } from './routes/marketplace-escrow.js'; import { teamsRouter } from './routes/teams.js'; import { walletPaymentsRouter } from './routes/wallet-payments.js'; import { merchantAuditRouter } from './routes/merchant-audit.js'; +import { subscriptionBillingRouter } from './routes/subscription-billing.js'; import { installmentsRouter } from './routes/installments.js'; import { jobQueueRouter } from './routes/job-queue.js'; import { recurringBillingRouter } from './routes/recurring-billing.js'; @@ -273,6 +274,7 @@ apiV1Router.use('/marketplace-escrow', marketplaceEscrowRouter); apiV1Router.use('/teams', teamsRouter); apiV1Router.use('/wallet-payments', walletPaymentsRouter); apiV1Router.use('/merchant-audit', merchantAuditRouter); +apiV1Router.use('/billing', subscriptionBillingRouter); apiV1Router.use('/installments', installmentsRouter); apiV1Router.use('/job-queue', jobQueueRouter); apiV1Router.use('/recurring-payments', recurringBillingRouter); diff --git a/backend/src/routes/subscription-billing.ts b/backend/src/routes/subscription-billing.ts new file mode 100644 index 00000000..e320084a --- /dev/null +++ b/backend/src/routes/subscription-billing.ts @@ -0,0 +1,204 @@ +import { Router, Request, Response } from 'express'; +import { z } from 'zod'; +import { AppError, asyncHandler } from '../middleware/errorHandler.js'; +import { validate } from '../middleware/validate.js'; +import { subscriptionBillingService } from '../services/subscription-billing.js'; + +export const subscriptionBillingRouter = Router(); + +const pricingTierSchema = z.object({ + upTo: z.number().nonnegative(), + unitPrice: z.number().nonnegative(), +}); + +const createPlanSchema = z.object({ + id: z.string().min(1).optional(), + name: z.string().min(1).max(120), + currency: z.string().length(3).optional(), + basePrice: z.number().nonnegative(), + includedUnits: z.number().nonnegative().optional(), + overageUnitPrice: z.number().nonnegative().optional(), + tiers: z.array(pricingTierSchema).min(1).optional(), + billingInterval: z.enum(['monthly', 'annual']).optional(), + features: z.array(z.string()).optional(), +}); + +const createSubscriptionSchema = z.object({ + merchantId: z.string().min(1), + customerId: z.string().min(1), + planId: z.string().min(1), + trialDays: z.number().int().nonnegative().optional(), +}); + +const recordUsageSchema = z.object({ + subscriptionId: z.string().min(1), + metric: z.string().min(1).max(80), + quantity: z.number().positive(), + idempotencyKey: z.string().min(1).max(200).optional(), +}); + +const cancelSubscriptionSchema = z.object({ + atPeriodEnd: z.boolean().optional(), +}); + +/** + * Translate errors thrown by the in-memory billing service into AppError so the + * shared error handler preserves status codes and error codes. + */ +const serviceHandler = + (handler: (req: Request, res: Response) => unknown | Promise) => + asyncHandler(async (req, res) => { + try { + await handler(req, res); + } catch (err) { + const serviceError = err as { statusCode?: number; code?: string; message?: string }; + if (typeof serviceError?.statusCode === 'number') { + throw new AppError(serviceError.statusCode, serviceError.message ?? 'Billing error', serviceError.code); + } + throw err; + } + }); + +// ------------------------------------------------------------------- plans + +subscriptionBillingRouter.post( + '/plans', + validate(createPlanSchema), + serviceHandler((req: Request, res: Response) => { + res.status(201).json({ data: subscriptionBillingService.createPlan(req.body) }); + }), +); + +subscriptionBillingRouter.get( + '/plans', + serviceHandler((_req: Request, res: Response) => { + res.json({ data: subscriptionBillingService.listPlans() }); + }), +); + +subscriptionBillingRouter.get( + '/plans/:id', + serviceHandler((req: Request, res: Response) => { + const plan = subscriptionBillingService.getPlan(String(req.params.id)); + if (!plan) throw new AppError(404, 'Billing plan not found', 'NOT_FOUND'); + res.json({ data: plan }); + }), +); + +// ----------------------------------------------------------- subscriptions + +subscriptionBillingRouter.post( + '/subscriptions', + validate(createSubscriptionSchema), + serviceHandler((req: Request, res: Response) => { + res.status(201).json({ data: subscriptionBillingService.subscribe(req.body) }); + }), +); + +subscriptionBillingRouter.get( + '/subscriptions', + serviceHandler((req: Request, res: Response) => { + const { merchantId, customerId, status } = req.query; + res.json({ + data: subscriptionBillingService.listSubscriptions({ + merchantId: merchantId as string | undefined, + customerId: customerId as string | undefined, + status: status as never, + }), + }); + }), +); + +subscriptionBillingRouter.get( + '/subscriptions/:id', + serviceHandler((req: Request, res: Response) => { + const subscription = subscriptionBillingService.getSubscription(String(req.params.id)); + if (!subscription) throw new AppError(404, 'Subscription not found', 'NOT_FOUND'); + res.json({ data: subscription }); + }), +); + +subscriptionBillingRouter.post( + '/subscriptions/:id/cancel', + validate(cancelSubscriptionSchema), + serviceHandler((req: Request, res: Response) => { + res.json({ data: subscriptionBillingService.cancelSubscription(String(req.params.id), req.body) }); + }), +); + +subscriptionBillingRouter.post( + '/subscriptions/:id/close-period', + serviceHandler((req: Request, res: Response) => { + res.json({ data: subscriptionBillingService.closeBillingPeriod(String(req.params.id)) }); + }), +); + +// ---------------------------------------------------------------- metering + +subscriptionBillingRouter.post( + '/usage', + validate(recordUsageSchema), + serviceHandler((req: Request, res: Response) => { + res.status(201).json({ data: subscriptionBillingService.recordUsage(req.body) }); + }), +); + +subscriptionBillingRouter.get( + '/subscriptions/:id/usage', + serviceHandler((req: Request, res: Response) => { + res.json({ data: subscriptionBillingService.getUsage(String(req.params.id)) }); + }), +); + +subscriptionBillingRouter.get( + '/subscriptions/:id/usage-events', + serviceHandler((req: Request, res: Response) => { + res.json({ data: subscriptionBillingService.listUsageEvents(String(req.params.id)) }); + }), +); + +// --------------------------------------------------------------- invoices + +subscriptionBillingRouter.post( + '/subscriptions/:id/invoices', + serviceHandler((req: Request, res: Response) => { + res.status(201).json({ data: subscriptionBillingService.generateInvoice(String(req.params.id)) }); + }), +); + +subscriptionBillingRouter.get( + '/invoices', + serviceHandler((req: Request, res: Response) => { + const { subscriptionId, merchantId, status } = req.query; + res.json({ + data: subscriptionBillingService.listInvoices({ + subscriptionId: subscriptionId as string | undefined, + merchantId: merchantId as string | undefined, + status: status as never, + }), + }); + }), +); + +subscriptionBillingRouter.get( + '/invoices/:id', + serviceHandler((req: Request, res: Response) => { + const invoice = subscriptionBillingService.getInvoice(String(req.params.id)); + if (!invoice) throw new AppError(404, 'Invoice not found', 'NOT_FOUND'); + res.json({ data: invoice }); + }), +); + +subscriptionBillingRouter.post( + '/invoices/:id/pay', + serviceHandler((req: Request, res: Response) => { + res.json({ data: subscriptionBillingService.markInvoicePaid(String(req.params.id)) }); + }), +); + +subscriptionBillingRouter.post( + '/invoices/:id/void', + serviceHandler((req: Request, res: Response) => { + res.json({ data: subscriptionBillingService.voidInvoice(String(req.params.id)) }); + }), +); diff --git a/backend/src/services/__tests__/subscription-billing.test.ts b/backend/src/services/__tests__/subscription-billing.test.ts new file mode 100644 index 00000000..9303c5a1 --- /dev/null +++ b/backend/src/services/__tests__/subscription-billing.test.ts @@ -0,0 +1,291 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { SubscriptionBillingService } from '../subscription-billing.js'; + +describe('SubscriptionBillingService — usage metering (#914)', () => { + 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 seedPlan = (overrides: Partial[0]> = {}) => + service.createPlan({ + name: 'Pro', + basePrice: 49, + includedUnits: 1_000, + overageUnitPrice: 0.01, + ...overrides, + }); + + describe('plans', () => { + it('creates a normalized plan with sensible defaults', () => { + const plan = seedPlan({ currency: 'eur' }); + expect(plan.currency).toBe('EUR'); + expect(plan.billingInterval).toBe('monthly'); + expect(plan.basePrice).toBe(49); + expect(plan.id).toMatch(/^plan_/); + }); + + it('rejects a blank plan name', () => { + expectError(() => seedPlan({ name: ' ' }), 400, /name is required/); + }); + + it('rejects negative pricing', () => { + expectError(() => seedPlan({ basePrice: -1 }), 400, /basePrice cannot be negative/); + expectError(() => seedPlan({ overageUnitPrice: -0.5 }), 400, /overageUnitPrice/); + }); + + it('rejects unsorted or duplicate tiers', () => { + expectError( + () => seedPlan({ tiers: [{ upTo: 2000, unitPrice: 0.02 }, { upTo: 1000, unitPrice: 0.03 }] }), + 400, + /ascending/, + ); + }); + + it('stores and lists plans', () => { + const plan = seedPlan(); + expect(service.getPlan(plan.id)?.name).toBe('Pro'); + expect(service.listPlans()).toHaveLength(1); + expect(service.getPlan('missing')).toBeUndefined(); + }); + }); + + describe('subscriptions', () => { + it('subscribes a customer to a monthly plan', () => { + const plan = seedPlan(); + const subscription = service.subscribe({ merchantId: 'm1', customerId: 'c1', planId: plan.id }); + + expect(subscription.status).toBe('active'); + const days = + (new Date(subscription.currentPeriodEnd).getTime() - + new Date(subscription.currentPeriodStart).getTime()) / + (24 * 60 * 60 * 1000); + expect(days).toBe(30); + }); + + it('starts a trial when trialDays is supplied', () => { + const plan = seedPlan(); + const subscription = service.subscribe({ + merchantId: 'm1', + customerId: 'c1', + planId: plan.id, + trialDays: 14, + }); + expect(subscription.status).toBe('trialing'); + expect(subscription.trialEndsAt).toBeTruthy(); + }); + + it('reports an unknown plan', () => { + expectError( + () => service.subscribe({ merchantId: 'm1', customerId: 'c1', planId: 'nope' }), + 404, + /Billing plan not found/, + ); + }); + + it('requires a merchant and customer', () => { + const plan = seedPlan(); + expectError( + () => service.subscribe({ merchantId: '', customerId: 'c1', planId: plan.id }), + 400, + /merchantId is required/, + ); + }); + + it('filters subscriptions', () => { + const plan = seedPlan(); + service.subscribe({ merchantId: 'm1', customerId: 'c1', planId: plan.id }); + service.subscribe({ merchantId: 'm2', customerId: 'c2', planId: plan.id, trialDays: 7 }); + + expect(service.listSubscriptions({ merchantId: 'm1' })).toHaveLength(1); + expect(service.listSubscriptions({ status: 'trialing' })).toHaveLength(1); + }); + }); + + describe('usage metering', () => { + let subscriptionId: string; + + beforeEach(() => { + const plan = seedPlan(); + subscriptionId = service.subscribe({ merchantId: 'm1', customerId: 'c1', planId: plan.id }).id; + }); + + it('aggregates usage per metric', () => { + service.recordUsage({ subscriptionId, metric: 'api_calls', quantity: 100 }); + service.recordUsage({ subscriptionId, metric: 'api_calls', quantity: 250 }); + service.recordUsage({ subscriptionId, metric: 'storage_gb', quantity: 5 }); + + const summary = service.getUsage(subscriptionId); + expect(summary.metrics).toEqual({ api_calls: 350, storage_gb: 5 }); + expect(summary.totalUnits).toBe(355); + expect(summary.overageUnits).toBe(0); + expect(summary.usageAmount).toBe(0); + }); + + it('deduplicates events sharing an idempotency key', () => { + const first = service.recordUsage({ + subscriptionId, + metric: 'api_calls', + quantity: 100, + idempotencyKey: 'evt-1', + }); + const second = service.recordUsage({ + subscriptionId, + metric: 'api_calls', + quantity: 100, + idempotencyKey: 'evt-1', + }); + + expect(second.id).toBe(first.id); + expect(service.listUsageEvents(subscriptionId)).toHaveLength(1); + expect(service.getUsage(subscriptionId).totalUnits).toBe(100); + }); + + it('rejects non-positive quantities', () => { + expectError( + () => service.recordUsage({ subscriptionId, metric: 'api_calls', quantity: 0 }), + 400, + /greater than 0/, + ); + }); + + it('rejects unknown subscriptions', () => { + expectError( + () => service.recordUsage({ subscriptionId: 'ghost', metric: 'api_calls', quantity: 1 }), + 404, + /Subscription not found/, + ); + }); + + it('refuses to meter a cancelled subscription', () => { + service.cancelSubscription(subscriptionId); + expectError( + () => service.recordUsage({ subscriptionId, metric: 'api_calls', quantity: 1 }), + 400, + /cancelled subscription/, + ); + }); + }); + + describe('overage pricing', () => { + it('charges the flat overage rate past the included units', () => { + const plan = seedPlan({ includedUnits: 1_000, overageUnitPrice: 0.05 }); + const subscriptionId = service.subscribe({ merchantId: 'm', customerId: 'c', planId: plan.id }).id; + + service.recordUsage({ subscriptionId, metric: 'api_calls', quantity: 1_200 }); + + const summary = service.getUsage(subscriptionId); + expect(summary.overageUnits).toBe(200); + expect(summary.usageAmount).toBe(10); + }); + + it('applies graduated tiers', () => { + const plan = seedPlan({ + includedUnits: 0, + overageUnitPrice: 0.2, + tiers: [ + { upTo: 1_000, unitPrice: 0.01 }, + { upTo: 5_000, unitPrice: 0.005 }, + ], + }); + const subscriptionId = service.subscribe({ merchantId: 'm', customerId: 'c', planId: plan.id }).id; + + // 1_000 * 0.01 + 4_000 * 0.005 + 1_000 * 0.2 = 10 + 20 + 200 = 230 + service.recordUsage({ subscriptionId, metric: 'api_calls', quantity: 6_000 }); + + const summary = service.getUsage(subscriptionId); + expect(summary.overageUnits).toBe(6_000); + expect(summary.usageAmount).toBe(230); + }); + }); + + describe('invoicing', () => { + it('invoices the base fee when usage is within the allowance', () => { + const plan = seedPlan({ includedUnits: 1_000 }); + const subscriptionId = service.subscribe({ merchantId: 'm1', customerId: 'c1', planId: plan.id }).id; + service.recordUsage({ subscriptionId, metric: 'api_calls', quantity: 500 }); + + const invoice = service.generateInvoice(subscriptionId); + expect(invoice.baseAmount).toBe(49); + expect(invoice.usageAmount).toBe(0); + expect(invoice.total).toBe(49); + expect(invoice.status).toBe('open'); + expect(invoice.lineItems).toHaveLength(1); + expect(invoice.currency).toBe('USD'); + }); + + it('adds a metered overage line item when usage exceeds the allowance', () => { + const plan = seedPlan({ includedUnits: 1_000, overageUnitPrice: 0.1 }); + const subscriptionId = service.subscribe({ merchantId: 'm1', customerId: 'c1', planId: plan.id }).id; + service.recordUsage({ subscriptionId, metric: 'api_calls', quantity: 1_500 }); + + const invoice = service.generateInvoice(subscriptionId); + expect(invoice.usageAmount).toBe(50); + expect(invoice.total).toBe(99); + expect(invoice.lineItems).toHaveLength(2); + expect(invoice.lineItems[1]).toMatchObject({ description: 'Metered overage', quantity: 500, amount: 50 }); + }); + + it('rolls the period and resets usage when closing a billing period', () => { + const plan = seedPlan(); + const subscriptionId = service.subscribe({ merchantId: 'm1', customerId: 'c1', planId: plan.id }).id; + service.recordUsage({ subscriptionId, metric: 'api_calls', quantity: 1_500 }); + + const firstPeriodEnd = service.getSubscription(subscriptionId)!.currentPeriodEnd; + const { invoice, subscription } = service.closeBillingPeriod(subscriptionId); + + expect(invoice.usageAmount).toBe(5); // 500 overage * 0.01 + expect(subscription.currentPeriodStart).toBe(firstPeriodEnd); + expect(service.getUsage(subscriptionId).totalUnits).toBe(0); + expect(service.listInvoices({ subscriptionId })).toHaveLength(1); + }); + + it('cancels at period end when closing the period', () => { + const plan = seedPlan(); + const subscriptionId = service.subscribe({ merchantId: 'm1', customerId: 'c1', planId: plan.id }).id; + service.cancelSubscription(subscriptionId, { atPeriodEnd: true }); + expect(service.getSubscription(subscriptionId)!.status).toBe('active'); + + const { subscription } = service.closeBillingPeriod(subscriptionId); + expect(subscription.status).toBe('cancelled'); + }); + + it('pays and voids invoices with valid transitions only', () => { + const plan = seedPlan(); + const subscriptionId = service.subscribe({ merchantId: 'm1', customerId: 'c1', planId: plan.id }).id; + + const paid = service.generateInvoice(subscriptionId); + expect(service.markInvoicePaid(paid.id).status).toBe('paid'); + expectError(() => service.markInvoicePaid(paid.id), 400, /cannot be paid/); + expectError(() => service.voidInvoice(paid.id), 409, /cannot be voided/); + + const voided = service.generateInvoice(subscriptionId); + expect(service.voidInvoice(voided.id).status).toBe('void'); + }); + + it('reports missing invoices and subscriptions', () => { + expectError(() => service.markInvoicePaid('nope'), 404, /Invoice not found/); + expectError(() => service.generateInvoice('nope'), 404, /Subscription not found/); + }); + }); + + it('clears state between tests', () => { + seedPlan(); + service.resetForTests(); + expect(service.listPlans()).toHaveLength(0); + }); +}); diff --git a/backend/src/services/subscription-billing.ts b/backend/src/services/subscription-billing.ts new file mode 100644 index 00000000..2ee77a6b --- /dev/null +++ b/backend/src/services/subscription-billing.ts @@ -0,0 +1,469 @@ +/** + * subscription-billing.ts — Issue #914 + * + * Subscription billing with usage metering. + * + * Merchants define plans with a recurring base price and an included usage + * allowance. Customers subscribe to a plan, the platform records metered + * 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. + */ + +import { randomUUID } from 'node:crypto'; +import { BaseService } from './BaseService.js'; + +export type BillingInterval = 'monthly' | 'annual'; +export type SubscriptionStatus = 'trialing' | 'active' | 'past_due' | 'cancelled'; +export type InvoiceStatus = 'draft' | 'open' | 'paid' | 'void'; + +export interface PricingTier { + /** Cumulative unit boundary for this tier (e.g. first 10_000 units). */ + upTo: number; + /** Price per billable unit within this tier. */ + unitPrice: number; +} + +export interface BillingPlan { + id: string; + name: string; + currency: string; + basePrice: number; + includedUnits: number; + overageUnitPrice: number; + tiers?: PricingTier[]; + billingInterval: BillingInterval; + features?: string[]; + createdAt: string; +} + +export interface Subscription { + id: string; + merchantId: string; + customerId: string; + planId: string; + status: SubscriptionStatus; + currentPeriodStart: string; + currentPeriodEnd: string; + trialEndsAt?: string; + cancelAtPeriodEnd: boolean; + canceledAt?: string; + usage: Record; + createdAt: string; + updatedAt: string; +} + +export interface UsageEvent { + id: string; + subscriptionId: string; + metric: string; + quantity: number; + idempotencyKey?: string; + recordedAt: string; +} + +export interface InvoiceLineItem { + description: string; + quantity: number; + unitPrice: number; + amount: number; +} + +export interface Invoice { + id: string; + subscriptionId: string; + merchantId: string; + customerId: string; + planId: string; + periodStart: string; + periodEnd: string; + currency: string; + status: InvoiceStatus; + lineItems: InvoiceLineItem[]; + baseAmount: number; + usageAmount: number; + total: number; + paidAt?: string; + createdAt: string; +} + +export interface UsageSummary { + subscriptionId: string; + metrics: Record; + totalUnits: number; + includedUnits: number; + overageUnits: number; + usageAmount: number; + currency: string; + periodStart: string; + periodEnd: string; +} + +const DAY_MS = 24 * 60 * 60 * 1000; +const INTERVAL_DAYS: Record = { monthly: 30, annual: 365 }; + +export class SubscriptionBillingService extends BaseService { + private plans = new Map(); + private subscriptions = new Map(); + private usageEvents: UsageEvent[] = []; + private usageIdempotency = new Map(); + private invoices = new Map(); + + constructor(private readonly now: () => number = Date.now) { + super(); + } + + // ------------------------------------------------------------------- plans + + createPlan(input: { + id?: string; + name: string; + currency?: string; + basePrice: number; + includedUnits?: number; + overageUnitPrice?: number; + tiers?: PricingTier[]; + billingInterval?: BillingInterval; + features?: string[]; + }): BillingPlan { + this.validate(!!input.name?.trim(), 'Plan name is required'); + this.validate(input.basePrice >= 0, 'basePrice cannot be negative'); + this.validate((input.includedUnits ?? 0) >= 0, 'includedUnits cannot be negative'); + this.validate((input.overageUnitPrice ?? 0) >= 0, 'overageUnitPrice cannot be negative'); + + if (input.tiers) { + this.validate(input.tiers.length > 0, 'tiers cannot be empty'); + let previous = -1; + for (const tier of input.tiers) { + this.validate(tier.upTo >= 0, 'Tier upTo must be non-negative'); + this.validate(tier.unitPrice >= 0, 'Tier unitPrice cannot be negative'); + this.validate(tier.upTo > previous, 'Tiers must be sorted by ascending upTo'); + previous = tier.upTo; + } + } + + const plan: BillingPlan = { + id: input.id ?? `plan_${randomUUID()}`, + name: input.name.trim(), + currency: (input.currency ?? 'USD').toUpperCase(), + basePrice: this.round(input.basePrice), + includedUnits: input.includedUnits ?? 0, + overageUnitPrice: input.overageUnitPrice ?? 0, + tiers: input.tiers ? [...input.tiers].sort((a, b) => a.upTo - b.upTo) : undefined, + billingInterval: input.billingInterval ?? 'monthly', + features: input.features, + createdAt: new Date(this.now()).toISOString(), + }; + + this.plans.set(plan.id, plan); + return plan; + } + + getPlan(id: string): BillingPlan | undefined { + return this.plans.get(id); + } + + listPlans(): BillingPlan[] { + return Array.from(this.plans.values()); + } + + // ----------------------------------------------------------- subscriptions + + subscribe(input: { + merchantId: string; + customerId: string; + planId: string; + trialDays?: number; + }): Subscription { + this.validate(!!input.merchantId, 'merchantId is required'); + this.validate(!!input.customerId, 'customerId is required'); + + const plan = this.plans.get(input.planId); + if (!plan) this.notFound('Billing plan', input.planId); + + const trialDays = input.trialDays ?? 0; + this.validate(trialDays >= 0, 'trialDays cannot be negative'); + + const start = this.now(); + const periodEnd = start + INTERVAL_DAYS[plan.billingInterval] * DAY_MS; + + const subscription: Subscription = { + id: `sub_${randomUUID()}`, + merchantId: input.merchantId, + customerId: input.customerId, + planId: plan.id, + status: trialDays > 0 ? 'trialing' : 'active', + currentPeriodStart: new Date(start).toISOString(), + 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(), + }; + + this.subscriptions.set(subscription.id, subscription); + return subscription; + } + + getSubscription(id: string): Subscription | undefined { + return this.subscriptions.get(id); + } + + listSubscriptions(filter: { + merchantId?: string; + customerId?: string; + status?: SubscriptionStatus; + } = {}): Subscription[] { + return Array.from(this.subscriptions.values()).filter( + (s) => + (!filter.merchantId || s.merchantId === filter.merchantId) && + (!filter.customerId || s.customerId === filter.customerId) && + (!filter.status || s.status === filter.status), + ); + } + + cancelSubscription(id: string, options: { atPeriodEnd?: boolean } = {}): Subscription { + const subscription = this.subscriptions.get(id); + if (!subscription) this.notFound('Subscription', id); + if (subscription.status === 'cancelled') { + this.conflict('Subscription is already cancelled'); + } + + subscription.cancelAtPeriodEnd = options.atPeriodEnd === true; + if (!subscription.cancelAtPeriodEnd) { + subscription.status = 'cancelled'; + subscription.canceledAt = new Date(this.now()).toISOString(); + } + subscription.updatedAt = new Date(this.now()).toISOString(); + this.subscriptions.set(id, subscription); + return subscription; + } + + // ---------------------------------------------------------------- metering + + recordUsage(input: { + subscriptionId: string; + metric: string; + quantity: number; + idempotencyKey?: string; + }): UsageEvent { + this.validate(!!input.metric?.trim(), 'metric is required'); + this.validate(input.quantity > 0, 'quantity must be greater than 0'); + + const subscription = this.subscriptions.get(input.subscriptionId); + if (!subscription) this.notFound('Subscription', input.subscriptionId); + this.validate( + subscription.status === 'active' || subscription.status === 'trialing', + `Cannot meter usage for a ${subscription.status} subscription`, + ); + + if (input.idempotencyKey) { + const existingId = this.usageIdempotency.get(input.idempotencyKey); + if (existingId) { + const existing = this.usageEvents.find((event) => event.id === existingId); + if (existing) return existing; + } + } + + const event: UsageEvent = { + id: `usg_${randomUUID()}`, + subscriptionId: subscription.id, + metric: input.metric, + quantity: input.quantity, + idempotencyKey: input.idempotencyKey, + recordedAt: new Date(this.now()).toISOString(), + }; + + subscription.usage[input.metric] = (subscription.usage[input.metric] ?? 0) + input.quantity; + subscription.updatedAt = event.recordedAt; + + this.usageEvents.push(event); + if (input.idempotencyKey) this.usageIdempotency.set(input.idempotencyKey, event.id); + this.subscriptions.set(subscription.id, subscription); + + return event; + } + + listUsageEvents(subscriptionId: string): UsageEvent[] { + return this.usageEvents.filter((event) => event.subscriptionId === subscriptionId); + } + + getUsage(subscriptionId: string): UsageSummary { + const subscription = this.subscriptions.get(subscriptionId); + if (!subscription) this.notFound('Subscription', subscriptionId); + 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); + + return { + subscriptionId: subscription.id, + metrics: { ...subscription.usage }, + totalUnits, + includedUnits: plan.includedUnits, + overageUnits, + usageAmount: this.priceOverage(plan, overageUnits), + currency: plan.currency, + periodStart: subscription.currentPeriodStart, + periodEnd: subscription.currentPeriodEnd, + }; + } + + // ---------------------------------------------------------------- invoicing + + generateInvoice(subscriptionId: string): Invoice { + const subscription = this.subscriptions.get(subscriptionId); + if (!subscription) this.notFound('Subscription', subscriptionId); + 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; + } + + getInvoice(id: string): Invoice | undefined { + return this.invoices.get(id); + } + + listInvoices(filter: { subscriptionId?: string; merchantId?: string; status?: InvoiceStatus } = {}): Invoice[] { + return Array.from(this.invoices.values()).filter( + (invoice) => + (!filter.subscriptionId || invoice.subscriptionId === filter.subscriptionId) && + (!filter.merchantId || invoice.merchantId === filter.merchantId) && + (!filter.status || invoice.status === filter.status), + ); + } + + 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'); + + invoice.status = 'paid'; + invoice.paidAt = new Date(this.now()).toISOString(); + this.invoices.set(id, invoice); + return invoice; + } + + voidInvoice(id: string): Invoice { + const invoice = this.invoices.get(id); + if (!invoice) this.notFound('Invoice', id); + if (invoice.status === 'paid') this.conflict('A paid invoice cannot be voided'); + + invoice.status = 'void'; + this.invoices.set(id, invoice); + return invoice; + } + + /** + * Invoice the current period, roll the subscription into the next period and + * reset metered usage counters. + */ + closeBillingPeriod(subscriptionId: string): { invoice: Invoice; subscription: Subscription } { + const subscription = this.subscriptions.get(subscriptionId); + if (!subscription) this.notFound('Subscription', subscriptionId); + const plan = this.plans.get(subscription.planId); + if (!plan) this.notFound('Billing plan', subscription.planId); + + const invoice = this.generateInvoice(subscriptionId); + + const periodStart = new Date(subscription.currentPeriodEnd).getTime(); + subscription.currentPeriodStart = new Date(periodStart).toISOString(); + subscription.currentPeriodEnd = new Date( + periodStart + INTERVAL_DAYS[plan.billingInterval] * DAY_MS, + ).toISOString(); + subscription.usage = {}; + subscription.updatedAt = new Date(this.now()).toISOString(); + + if (subscription.status === 'trialing' && subscription.trialEndsAt && subscription.trialEndsAt <= invoice.periodEnd) { + subscription.status = 'active'; + } + if (subscription.cancelAtPeriodEnd) { + subscription.status = 'cancelled'; + subscription.canceledAt = new Date(this.now()).toISOString(); + } + + this.subscriptions.set(subscriptionId, subscription); + return { invoice, subscription }; + } + + resetForTests(): void { + this.plans.clear(); + this.subscriptions.clear(); + this.usageEvents = []; + this.usageIdempotency.clear(); + this.invoices.clear(); + } + + // --------------------------------------------------------------- internals + + /** 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); + } + + let remaining = overageUnits; + let cursor = plan.includedUnits; + let amount = 0; + + for (const tier of plan.tiers) { + if (remaining <= 0) break; + if (cursor >= tier.upTo) continue; + const span = Math.min(remaining, tier.upTo - cursor); + amount += span * tier.unitPrice; + remaining -= span; + cursor += span; + } + + // 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); + } + + private round(value: number, decimals = 2): number { + const factor = 10 ** decimals; + return Math.round((value + Number.EPSILON) * factor) / factor; + } +} + +export const subscriptionBillingService = new SubscriptionBillingService(); diff --git a/docs/SUBSCRIPTION_USAGE_BILLING.md b/docs/SUBSCRIPTION_USAGE_BILLING.md new file mode 100644 index 00000000..415c5ea3 --- /dev/null +++ b/docs/SUBSCRIPTION_USAGE_BILLING.md @@ -0,0 +1,44 @@ +# Subscription Billing with Usage Metering + +Issue: [#914](https://github.com/Smartdevs17/agenticpay/issues/914) + +Recurring plans with an included usage allowance plus metered overage billed +per billing period. + +## Backend + +Service: `backend/src/services/subscription-billing.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) | +| `GET` | `/plans`, `/plans/:id` | List / read plans | +| `POST` | `/subscriptions` | Subscribe a customer to a plan (optional trial) | +| `GET` | `/subscriptions` | List subscriptions (`merchantId`, `customerId`, `status`) | +| `GET` | `/subscriptions/:id` | Read a subscription | +| `POST` | `/subscriptions/:id/cancel` | Cancel now or at period end | +| `POST` | `/usage` | Record a metered usage event | +| `GET` | `/subscriptions/:id/usage` | Usage summary (units, overage, amount) | +| `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 | + +### Pricing + +- The plan's `includedUnits` are free; units beyond that are billed as overage. +- **Flat** overage charges `overageUnitPrice` per unit. +- **Graduated** plans define `tiers` (`{ upTo, unitPrice }`) that are applied + progressively; usage past the last tier falls back to `overageUnitPrice`. +- Monthly periods are 30 days, annual periods 365. + +### Idempotency + +Supplying an `idempotencyKey` when recording usage makes retries safe — the +same key returns the original event and is not double-counted. + +## Tests + +`backend/src/services/__tests__/subscription-billing.test.ts`