From e0d5d58f0103db112c837e0b2e4319631570da91 Mon Sep 17 00:00:00 2001 From: BigT Date: Sun, 27 Sep 2026 20:58:57 +0400 Subject: [PATCH] feat(payments): add BNPL installment plans with deterministic scheduling and lifecycle --- backend/docs/BNPL_INSTALLMENTS.md | 112 +++++ .../20260927000000_bnpl_installments/down.sql | 5 + .../migration.sql | 54 +++ backend/prisma/schema.prisma | 60 +++ backend/src/index.ts | 2 + backend/src/routes/installments.ts | 223 ++++++++++ .../payments/installments/bnplService.ts | 349 ++++++++++++++++ .../services/payments/installments/index.ts | 22 + .../installments/installments.test.ts | 387 ++++++++++++++++++ .../services/payments/installments/planner.ts | 245 +++++++++++ .../services/payments/installments/store.ts | 70 ++++ .../services/payments/installments/types.ts | 145 +++++++ 12 files changed, 1674 insertions(+) create mode 100644 backend/docs/BNPL_INSTALLMENTS.md create mode 100644 backend/prisma/migrations/20260927000000_bnpl_installments/down.sql create mode 100644 backend/prisma/migrations/20260927000000_bnpl_installments/migration.sql create mode 100644 backend/src/routes/installments.ts create mode 100644 backend/src/services/payments/installments/bnplService.ts create mode 100644 backend/src/services/payments/installments/index.ts create mode 100644 backend/src/services/payments/installments/installments.test.ts create mode 100644 backend/src/services/payments/installments/planner.ts create mode 100644 backend/src/services/payments/installments/store.ts create mode 100644 backend/src/services/payments/installments/types.ts diff --git a/backend/docs/BNPL_INSTALLMENTS.md b/backend/docs/BNPL_INSTALLMENTS.md new file mode 100644 index 00000000..4efd9650 --- /dev/null +++ b/backend/docs/BNPL_INSTALLMENTS.md @@ -0,0 +1,112 @@ +# BNPL Installment Plans (Issue #919) + +Buy-Now-Pay-Later financing for checkout orders. A plan splits an order +principal into a deterministic schedule of installments that are collected +one at a time. + +## Concepts + +| Concept | Description | +|---------|-------------| +| **Principal** | The full order value. | +| **Down payment** | Amount paid up front. Not financed, never part of the schedule. | +| **Financed amount** | `principal - downPayment`. The sum of every installment. | +| **Installment** | One scheduled collection (`amount`, `dueAt`, `status`). | +| **Frequency** | `weekly` (7 days), `biweekly` (14 days), or `monthly` (calendar month, day-of-month clamped). | + +Installment statuses: `scheduled → due → paid`, plus `failed` (retryable) and +`cancelled` (voided with the plan). Plan statuses: `active`, `completed`, +`cancelled`, `defaulted`. + +## Rounding guarantee + +Amounts are computed in `planner.ts`. Each installment receives the floored +2-decimal share and the **final** installment absorbs the remainder, so the +schedule always reconciles back to the financed amount. `createPlan` asserts +this invariant and refuses to persist a plan that does not balance. + +``` +$100 financed over 3 installments → 33.33 + 33.33 + 33.34 = 100.00 +``` + +## Limits (`DEFAULT_BNPL_CONFIG`) + +| Setting | Value | +|---------|-------| +| Installments per plan | 2–12 | +| Financed amount | 10–100,000 | +| Currencies | `USD`, `EUR`, `GBP`, `XLM`, `USDC` | +| Frequencies | `weekly`, `biweekly`, `monthly` (default `monthly`) | + +Override these by constructing `new BNPLInstallmentService({ config })`. + +## REST API + +All routes are mounted under `/api/v1/installments`. + +| Method | Path | Purpose | +|--------|------|---------| +| `POST` | `/plans` | Create a plan and materialise its schedule. | +| `GET` | `/plans?tenantId=&status=&customerId=&merchantId=` | List a tenant's plans. | +| `GET` | `/plans/:id?tenantId=` | Fetch a single plan. | +| `GET` | `/plans/:id/summary?tenantId=` | Repayment summary (paid/remaining/next due). | +| `POST` | `/plans/:id/installments/:index/pay` | Record a successful collection. | +| `POST` | `/plans/:id/installments/:index/fail` | Record a failed collection (retryable). | +| `POST` | `/plans/:id/cancel` | Cancel the plan and void outstanding installments. | +| `POST` | `/sweep` | Flag `scheduled` installments whose due date passed as `due`. | + +### Create a plan + +```bash +curl -X POST http://localhost:3000/api/v1/installments/plans \ + -H 'Content-Type: application/json' \ + -d '{ + "tenantId": "tenant-1", + "customerId": "cust-9", + "amount": 300, + "currency": "USD", + "installmentCount": 3, + "frequency": "monthly", + "downPayment": 0, + "startDate": "2026-10-01T00:00:00.000Z" + }' +``` + +### Collect an installment + +```bash +curl -X POST http://localhost:3000/api/v1/installments/plans//installments/1/pay \ + -H 'Content-Type: application/json' \ + -d '{ "paymentId": "pay_123" }' +``` + +Paying the final outstanding installment transitions the plan to `completed`. + +## Events + +`BNPLInstallmentService` publishes domain events through the injectable +`InstallmentEventPublisher` interface: + +- `installment_plan.created` +- `installment.paid` +- `installment.failed` +- `installment.overdue` +- `installment_plan.completed` +- `installment_plan.cancelled` + +The default publisher logs; wire an event-bus adapter in production. + +## Persistence + +- Prisma models: `InstallmentPlan` (`installment_plans`) and `Installment` + (`installments`), migration `20260927000000_bnpl_installments`. +- The service depends on `InstallmentPlanRepository`; `InMemoryInstallmentPlanRepository` + backs tests and local development. Implement the same interface with Prisma + for production. + +## Overdue handling + +`POST /installments/sweep` runs the dunning step: every outstanding +installment past its due date flips from `scheduled` to `due` and emits +`installment.overdue`. The sweep is idempotent — re-running it only rewrites +installments still in `scheduled` state. diff --git a/backend/prisma/migrations/20260927000000_bnpl_installments/down.sql b/backend/prisma/migrations/20260927000000_bnpl_installments/down.sql new file mode 100644 index 00000000..4b7cb725 --- /dev/null +++ b/backend/prisma/migrations/20260927000000_bnpl_installments/down.sql @@ -0,0 +1,5 @@ +-- Down migration for Issue #919 (BNPL installment plans). +DROP TABLE IF EXISTS "installments"; +DROP TABLE IF EXISTS "installment_plans"; +DROP TYPE IF EXISTS "InstallmentStatus"; +DROP TYPE IF EXISTS "InstallmentPlanStatus"; diff --git a/backend/prisma/migrations/20260927000000_bnpl_installments/migration.sql b/backend/prisma/migrations/20260927000000_bnpl_installments/migration.sql new file mode 100644 index 00000000..cae8a2a0 --- /dev/null +++ b/backend/prisma/migrations/20260927000000_bnpl_installments/migration.sql @@ -0,0 +1,54 @@ +-- Issue #919: BNPL installment plans +-- Adds financed installment plans plus their per-installment schedule. +-- The schedule is materialised (one row per installment) so dunning jobs can +-- index directly on (status, due_at) instead of recomputing dates. + +-- CreateEnum +CREATE TYPE "InstallmentPlanStatus" AS ENUM ('active', 'completed', 'cancelled', 'defaulted'); +CREATE TYPE "InstallmentStatus" AS ENUM ('scheduled', 'due', 'paid', 'failed', 'cancelled'); + +-- CreateTable +CREATE TABLE "installment_plans" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "customer_id" TEXT, + "merchant_id" TEXT, + "currency" TEXT NOT NULL DEFAULT 'USD', + "principal" DECIMAL(20,8) NOT NULL, + "down_payment" DECIMAL(20,8) NOT NULL DEFAULT 0, + "financed_amount" DECIMAL(20,8) NOT NULL, + "installment_count" INTEGER NOT NULL, + "frequency" TEXT NOT NULL DEFAULT 'monthly', + "status" "InstallmentPlanStatus" NOT NULL DEFAULT 'active', + "metadata" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "installment_plans_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "installments" ( + "id" TEXT NOT NULL, + "plan_id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "installment_no" INTEGER NOT NULL, + "amount" DECIMAL(20,8) NOT NULL, + "due_at" TIMESTAMP(3) NOT NULL, + "status" "InstallmentStatus" NOT NULL DEFAULT 'scheduled', + "paid_at" TIMESTAMP(3), + "payment_id" TEXT, + "failure_reason" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "installments_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "installment_plans_tenant_id_status_idx" ON "installment_plans"("tenant_id", "status"); +CREATE INDEX "installment_plans_customer_id_idx" ON "installment_plans"("customer_id"); +CREATE UNIQUE INDEX "installments_plan_id_installment_no_key" ON "installments"("plan_id", "installment_no"); +CREATE INDEX "installments_status_due_at_idx" ON "installments"("status", "due_at"); + +-- AddForeignKey +ALTER TABLE "installments" ADD CONSTRAINT "installments_plan_id_fkey" FOREIGN KEY ("plan_id") REFERENCES "installment_plans"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index b1b36d59..1d71a126 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -583,3 +583,63 @@ model NotificationLog { @@map("notification_logs") } +// ─── BNPL installment plans (Issue #919) ────────────────────────────────────── + +enum InstallmentPlanStatus { + active + completed + cancelled + defaulted +} + +enum InstallmentStatus { + scheduled + due + paid + failed + cancelled +} + +model InstallmentPlan { + id String @id @default(uuid()) + tenantId String @map("tenant_id") + customerId String? @map("customer_id") + merchantId String? @map("merchant_id") + currency String @default("USD") + principal Decimal @db.Decimal(20, 8) + downPayment Decimal @default(0) @map("down_payment") @db.Decimal(20, 8) + financedAmount Decimal @map("financed_amount") @db.Decimal(20, 8) + installmentCount Int @map("installment_count") + frequency String @default("monthly") + status InstallmentPlanStatus @default(active) + metadata Json? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + installments Installment[] + + @@index([tenantId, status]) + @@index([customerId]) + @@map("installment_plans") +} + +model Installment { + id String @id @default(uuid()) + planId String @map("plan_id") + tenantId String @map("tenant_id") + installmentNo Int @map("installment_no") + amount Decimal @db.Decimal(20, 8) + dueAt DateTime @map("due_at") + status InstallmentStatus @default(scheduled) + paidAt DateTime? @map("paid_at") + paymentId String? @map("payment_id") + failureReason String? @map("failure_reason") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + plan InstallmentPlan @relation(fields: [planId], references: [id], onDelete: Cascade) + + @@unique([planId, installmentNo]) + @@index([status, dueAt]) + @@map("installments") +} diff --git a/backend/src/index.ts b/backend/src/index.ts index fdca8608..87e32d2c 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -108,6 +108,7 @@ import { subscriptionsRouter } from './routes/subscriptions.js'; import { workspacesRouter } from './routes/workspaces.js'; import { teamsRouter } from './routes/teams.js'; import { merchantAuditRouter } from './routes/merchant-audit.js'; +import { installmentsRouter } from './routes/installments.js'; // Validate environment variables at startup validateEnv(); @@ -265,6 +266,7 @@ apiV1Router.use('/subscriptions', subscriptionsRouter); apiV1Router.use('/workspaces', workspacesRouter); apiV1Router.use('/teams', teamsRouter); apiV1Router.use('/merchant-audit', merchantAuditRouter); +apiV1Router.use('/installments', installmentsRouter); apiV1Router.get('/compression/metrics', (_req, res) => { res.json(getCompressionMetrics()); }); diff --git a/backend/src/routes/installments.ts b/backend/src/routes/installments.ts new file mode 100644 index 00000000..67d69901 --- /dev/null +++ b/backend/src/routes/installments.ts @@ -0,0 +1,223 @@ +/** + * installments.ts — Issue #919: BNPL installment plans + * + * REST surface for Buy-Now-Pay-Later financing. + * + * POST /installments/plans — create a plan + * GET /installments/plans?tenantId=&status=&customerId= — list plans + * GET /installments/plans/:id?tenantId= — fetch a plan + * GET /installments/plans/:id/summary?tenantId= — repayment summary + * POST /installments/plans/:id/installments/:index/pay — record a collection + * POST /installments/plans/:id/installments/:index/fail — record a failure + * POST /installments/plans/:id/cancel — cancel the plan + * POST /installments/sweep — flag overdue installments + */ +import { Router, type Request, type Response } from 'express'; +import { z } from 'zod'; + +import { asyncHandler } from '../middleware/errorHandler.js'; +import { bnplInstallmentService } from '../services/payments/installments/index.js'; +import type { ServiceError } from '../lib/result.js'; + +export const installmentsRouter = Router(); + +const frequencySchema = z.enum(['weekly', 'biweekly', 'monthly']); +const planStatusSchema = z.enum(['active', 'completed', 'cancelled', 'defaulted']); + +const createPlanSchema = z.object({ + tenantId: z.string().min(1), + amount: z.number().positive(), + currency: z.string().length(3).optional(), + installmentCount: z.number().int().positive(), + frequency: frequencySchema.optional(), + downPayment: z.number().nonnegative().optional(), + customerId: z.string().min(1).optional(), + merchantId: z.string().min(1).optional(), + startDate: z.string().datetime().optional(), + metadata: z.record(z.unknown()).optional(), +}); + +const scheduleInputSchema = z.object({ + paymentId: z.string().min(1).optional(), + paidAt: z.string().datetime().optional(), +}); + +const failSchema = z.object({ + reason: z.string().min(1).max(280), +}); + +const cancelSchema = z.object({ + reason: z.string().max(280).optional(), +}); + +const sweepSchema = z.object({ + tenantId: z.string().min(1), + at: z.string().datetime().optional(), +}); + +function requireTenant(req: Request, res: Response): string | null { + const tenantId = (req.query.tenantId ?? req.body?.tenantId) as string | undefined; + if (!tenantId) { + res.status(400).json({ success: false, error: 'tenantId is required' }); + return null; + } + return tenantId; +} + +function sendResult(res: Response, result: { ok: true; value: T } | { ok: false; error: ServiceError }) { + if (result.ok) { + return res.json({ success: true, data: result.value }); + } + return res.status(result.error.statusCode ?? 400).json({ + success: false, + error: result.error.message, + code: result.error.code, + }); +} + +// ── POST /installments/plans ───────────────────────────────────────────────── + +installmentsRouter.post( + '/plans', + asyncHandler(async (req: Request, res: Response) => { + const parsed = createPlanSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ success: false, error: 'Invalid payload', details: parsed.error.format() }); + } + const result = await bnplInstallmentService.createPlan(parsed.data); + if (!result.ok) { + return sendResult(res, result); + } + return res.status(201).json({ success: true, data: result.value }); + }), +); + +// ── GET /installments/plans ────────────────────────────────────────────────── + +installmentsRouter.get( + '/plans', + asyncHandler(async (req: Request, res: Response) => { + const tenantId = requireTenant(req, res); + if (!tenantId) return; + + const status = planStatusSchema.safeParse(req.query.status).success + ? (req.query.status as z.infer) + : undefined; + const result = await bnplInstallmentService.listPlans(tenantId, { + status, + customerId: (req.query.customerId as string) || undefined, + merchantId: (req.query.merchantId as string) || undefined, + }); + return sendResult(res, result); + }), +); + +// ── GET /installments/plans/:id/summary ────────────────────────────────────── + +installmentsRouter.get( + '/plans/:id/summary', + asyncHandler(async (req: Request, res: Response) => { + const tenantId = requireTenant(req, res); + if (!tenantId) return; + const result = await bnplInstallmentService.getSummary(tenantId, req.params.id); + return sendResult(res, result); + }), +); + +// ── GET /installments/plans/:id ────────────────────────────────────────────── + +installmentsRouter.get( + '/plans/:id', + asyncHandler(async (req: Request, res: Response) => { + const tenantId = requireTenant(req, res); + if (!tenantId) return; + const result = await bnplInstallmentService.getPlan(tenantId, req.params.id); + return sendResult(res, result); + }), +); + +// ── POST /installments/plans/:id/installments/:index/pay ───────────────────── + +installmentsRouter.post( + '/plans/:id/installments/:index/pay', + asyncHandler(async (req: Request, res: Response) => { + const tenantId = requireTenant(req, res); + if (!tenantId) return; + + const installmentIndex = Number(req.params.index); + if (!Number.isInteger(installmentIndex) || installmentIndex < 1) { + return res.status(400).json({ success: false, error: 'installment index must be a positive integer' }); + } + + const parsed = scheduleInputSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + return res.status(400).json({ success: false, error: 'Invalid payload', details: parsed.error.format() }); + } + + const result = await bnplInstallmentService.payInstallment(tenantId, req.params.id, { + installmentIndex, + ...parsed.data, + }); + return sendResult(res, result); + }), +); + +// ── POST /installments/plans/:id/installments/:index/fail ──────────────────── + +installmentsRouter.post( + '/plans/:id/installments/:index/fail', + asyncHandler(async (req: Request, res: Response) => { + const tenantId = requireTenant(req, res); + if (!tenantId) return; + + const installmentIndex = Number(req.params.index); + if (!Number.isInteger(installmentIndex) || installmentIndex < 1) { + return res.status(400).json({ success: false, error: 'installment index must be a positive integer' }); + } + + const parsed = failSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ success: false, error: 'Invalid payload', details: parsed.error.format() }); + } + + const result = await bnplInstallmentService.markInstallmentFailed( + tenantId, + req.params.id, + installmentIndex, + parsed.data.reason, + ); + return sendResult(res, result); + }), +); + +// ── POST /installments/plans/:id/cancel ────────────────────────────────────── + +installmentsRouter.post( + '/plans/:id/cancel', + asyncHandler(async (req: Request, res: Response) => { + const tenantId = requireTenant(req, res); + if (!tenantId) return; + + const parsed = cancelSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + return res.status(400).json({ success: false, error: 'Invalid payload', details: parsed.error.format() }); + } + + const result = await bnplInstallmentService.cancelPlan(tenantId, req.params.id, parsed.data.reason); + return sendResult(res, result); + }), +); + +// ── POST /installments/sweep ───────────────────────────────────────────────── + +installmentsRouter.post( + '/sweep', + asyncHandler(async (req: Request, res: Response) => { + const parsed = sweepSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ success: false, error: 'Invalid payload', details: parsed.error.format() }); + } + const result = await bnplInstallmentService.sweepOverdue(parsed.data.tenantId, parsed.data.at ?? new Date()); + return sendResult(res, result); + }), +); diff --git a/backend/src/services/payments/installments/bnplService.ts b/backend/src/services/payments/installments/bnplService.ts new file mode 100644 index 00000000..736b7f1b --- /dev/null +++ b/backend/src/services/payments/installments/bnplService.ts @@ -0,0 +1,349 @@ +/** + * bnplService.ts — Issue #919: BNPL installment plans + * + * Coordinates plan creation, installment collection, and plan lifecycle. + * All money math lives in `planner.ts`; this class only sequences state + * transitions and persistence so it stays easy to test with a stubbed repo. + */ +import { randomUUID } from 'node:crypto'; + +import { BaseService } from '../../BaseService.js'; +import type { Result } from '../../../lib/result.js'; +import { + DEFAULT_BNPL_CONFIG, + buildInstallmentSchedule, + nextActionableInstallment, + roundCurrency, + summarizePlan, + validateInstallmentRequest, +} from './planner.js'; +import { InMemoryInstallmentPlanRepository, type InstallmentPlanRepository } from './store.js'; +import type { + BNPLConfig, + CreateInstallmentPlanInput, + Installment, + InstallmentEventPublisher, + InstallmentPlan, + InstallmentPlanEvent, + InstallmentPlanListFilter, + InstallmentPlanSummary, + RecordInstallmentPaymentInput, +} from './types.js'; + +export interface BNPLServiceOptions { + repository?: InstallmentPlanRepository; + config?: BNPLConfig; + publisher?: InstallmentEventPublisher; + now?: () => Date; + idFactory?: () => string; +} + +/** Default publisher that simply logs; swap for an event-bus adapter in prod. */ +class LoggingInstallmentEventPublisher implements InstallmentEventPublisher { + publish(event: InstallmentPlanEvent): void { + console.info('[bnpl] event', event.type, event.planId, event.installmentIndex ?? ''); + } +} + +export class BNPLInstallmentService extends BaseService { + private readonly repository: InstallmentPlanRepository; + private readonly config: BNPLConfig; + private readonly publisher: InstallmentEventPublisher; + private readonly now: () => Date; + private readonly idFactory: () => string; + + constructor(options: BNPLServiceOptions = {}) { + super(); + this.repository = options.repository ?? new InMemoryInstallmentPlanRepository(); + this.config = options.config ?? DEFAULT_BNPL_CONFIG; + this.publisher = options.publisher ?? new LoggingInstallmentEventPublisher(); + this.now = options.now ?? (() => new Date()); + this.idFactory = options.idFactory ?? (() => randomUUID()); + } + + getConfig(): BNPLConfig { + return { ...this.config, supportedCurrencies: [...this.config.supportedCurrencies] }; + } + + /** + * Create a financed plan and persist its derived schedule. A down payment + * (when supplied) is recorded on the plan but is not part of the financed + * schedule — the customer settles it outside the plan. + */ + async createPlan(input: CreateInstallmentPlanInput): Promise> { + const validated = validateInstallmentRequest(input, this.config); + if (!validated.ok) { + return validated; + } + + const request = validated.value; + const schedule = buildInstallmentSchedule({ + financedAmount: request.financedAmount, + installmentCount: request.installmentCount, + frequency: request.frequency, + startDate: request.startDate, + }); + + const timestamp = this.now().toISOString(); + const installments: Installment[] = schedule.map((entry) => ({ + index: entry.index, + amount: entry.amount, + dueAt: entry.dueAt, + status: 'scheduled', + paidAt: null, + paymentId: null, + failureReason: null, + })); + + const plan: InstallmentPlan = { + id: this.idFactory(), + tenantId: request.tenantId, + customerId: request.customerId ?? null, + merchantId: request.merchantId ?? null, + currency: request.currency, + principal: request.amount, + downPayment: request.downPayment, + financedAmount: request.financedAmount, + installmentCount: request.installmentCount, + frequency: request.frequency, + status: 'active', + installments, + metadata: request.metadata, + createdAt: timestamp, + updatedAt: timestamp, + }; + + // Guard against a schedule that fails to reconcile with the financed amount. + const scheduledTotal = roundCurrency( + installments.reduce((sum, installment) => sum + installment.amount, 0), + ); + if (scheduledTotal !== request.financedAmount) { + return this.unexpectedFailure( + new Error( + `Installment schedule does not reconcile: expected ${request.financedAmount}, got ${scheduledTotal}`, + ), + ); + } + + const saved = await this.repository.create(plan); + await this.publisher.publish({ + type: 'installment_plan.created', + planId: saved.id, + tenantId: saved.tenantId, + occurredAt: timestamp, + data: { financedAmount: saved.financedAmount, installmentCount: saved.installmentCount }, + }); + return this.ok(saved); + } + + async getPlan(tenantId: string, planId: string): Promise> { + const plan = await this.repository.findById(planId); + if (!plan || plan.tenantId !== tenantId) { + return this.notFoundFailure('Installment plan', planId); + } + return this.ok(plan); + } + + async listPlans( + tenantId: string, + filter?: InstallmentPlanListFilter, + ): Promise> { + if (!tenantId) { + return this.validationFailure('tenantId is required'); + } + return this.ok(await this.repository.list(tenantId, filter)); + } + + async getSummary(tenantId: string, planId: string): Promise> { + const found = await this.getPlan(tenantId, planId); + if (!found.ok) { + return found; + } + return this.ok(summarizePlan(found.value)); + } + + /** Record a successful collection for one installment. */ + async payInstallment( + tenantId: string, + planId: string, + input: RecordInstallmentPaymentInput, + ): Promise> { + const found = await this.getPlan(tenantId, planId); + if (!found.ok) { + return found; + } + const plan = found.value; + + if (plan.status === 'cancelled') { + return this.conflictFailure('Cannot collect an installment on a cancelled plan'); + } + if (plan.status === 'completed') { + return this.conflictFailure('Plan is already fully paid'); + } + + const target = plan.installments.find((installment) => installment.index === input.installmentIndex); + if (!target) { + return this.validationFailure(`No installment at index ${input.installmentIndex}`); + } + if (target.status === 'paid') { + return this.conflictFailure(`Installment ${input.installmentIndex} has already been paid`); + } + if (target.status === 'cancelled') { + return this.conflictFailure(`Installment ${input.installmentIndex} has been cancelled`); + } + + const paidAt = input.paidAt ? new Date(input.paidAt) : this.now(); + if (Number.isNaN(paidAt.getTime())) { + return this.validationFailure('paidAt must be a valid date'); + } + + target.status = 'paid'; + target.paidAt = paidAt.toISOString(); + target.paymentId = input.paymentId ?? null; + target.failureReason = null; + + const allPaid = plan.installments.every((installment) => installment.status === 'paid'); + plan.status = allPaid ? 'completed' : plan.status; + plan.updatedAt = this.now().toISOString(); + + const saved = await this.repository.update(plan); + await this.publisher.publish({ + type: 'installment.paid', + planId: saved.id, + tenantId: saved.tenantId, + occurredAt: saved.updatedAt, + installmentIndex: target.index, + data: { amount: target.amount, paymentId: target.paymentId }, + }); + if (allPaid) { + await this.publisher.publish({ + type: 'installment_plan.completed', + planId: saved.id, + tenantId: saved.tenantId, + occurredAt: saved.updatedAt, + }); + } + return this.ok(saved); + } + + /** Mark an installment collection as failed so retries can target it. */ + async markInstallmentFailed( + tenantId: string, + planId: string, + installmentIndex: number, + reason: string, + ): Promise> { + const found = await this.getPlan(tenantId, planId); + if (!found.ok) { + return found; + } + const plan = found.value; + + if (plan.status === 'cancelled' || plan.status === 'completed') { + return this.conflictFailure(`Cannot fail an installment on a ${plan.status} plan`); + } + + const target = plan.installments.find((installment) => installment.index === installmentIndex); + if (!target) { + return this.validationFailure(`No installment at index ${installmentIndex}`); + } + if (target.status === 'paid') { + return this.conflictFailure(`Installment ${installmentIndex} is already paid`); + } + + target.status = 'failed'; + target.failureReason = reason; + plan.updatedAt = this.now().toISOString(); + + const saved = await this.repository.update(plan); + await this.publisher.publish({ + type: 'installment.failed', + planId: saved.id, + tenantId: saved.tenantId, + occurredAt: saved.updatedAt, + installmentIndex: target.index, + data: { reason }, + }); + return this.ok(saved); + } + + /** Cancel a plan, voiding every still-outstanding installment. */ + async cancelPlan(tenantId: string, planId: string, reason?: string): Promise> { + const found = await this.getPlan(tenantId, planId); + if (!found.ok) { + return found; + } + const plan = found.value; + + if (plan.status === 'completed') { + return this.conflictFailure('Cannot cancel a completed plan'); + } + if (plan.status === 'cancelled') { + return this.conflictFailure('Plan is already cancelled'); + } + + for (const installment of plan.installments) { + if (installment.status === 'scheduled' || installment.status === 'due' || installment.status === 'failed') { + installment.status = 'cancelled'; + } + } + plan.status = 'cancelled'; + plan.updatedAt = this.now().toISOString(); + + const saved = await this.repository.update(plan); + await this.publisher.publish({ + type: 'installment_plan.cancelled', + planId: saved.id, + tenantId: saved.tenantId, + occurredAt: saved.updatedAt, + data: { reason: reason ?? null }, + }); + return this.ok(saved); + } + + /** + * Flag outstanding installments whose due date has passed. Idempotent: + * re-running only rewrites installments that are still `scheduled`. + */ + async sweepOverdue( + tenantId: string, + at: Date | string = new Date(), + ): Promise> { + const reference = at instanceof Date ? at : new Date(at); + if (Number.isNaN(reference.getTime())) { + return this.validationFailure('sweep timestamp must be a valid date'); + } + + const plans = await this.repository.findWithOverdueInstallments(tenantId, reference); + let flagged = 0; + + for (const plan of plans) { + let mutated = false; + for (const installment of plan.installments) { + if (installment.status === 'scheduled' && new Date(installment.dueAt).getTime() < reference.getTime()) { + installment.status = 'due'; + mutated = true; + flagged += 1; + } + } + if (!mutated) { + continue; + } + plan.updatedAt = this.now().toISOString(); + await this.repository.update(plan); + const next = nextActionableInstallment(plan); + await this.publisher.publish({ + type: 'installment.overdue', + planId: plan.id, + tenantId: plan.tenantId, + occurredAt: plan.updatedAt, + installmentIndex: next?.index, + data: { dueAt: next?.dueAt ?? null }, + }); + } + + return this.ok({ scanned: plans.length, flagged }); + } +} + +export const bnplInstallmentService = new BNPLInstallmentService(); diff --git a/backend/src/services/payments/installments/index.ts b/backend/src/services/payments/installments/index.ts new file mode 100644 index 00000000..728d37a1 --- /dev/null +++ b/backend/src/services/payments/installments/index.ts @@ -0,0 +1,22 @@ +/** + * index.ts — Issue #919: BNPL installment plans + * + * Public surface of the BNPL installment domain. + */ +export * from './types.js'; +export { + DEFAULT_BNPL_CONFIG, + addPeriod, + buildInstallmentSchedule, + isPlanOverdue, + nextActionableInstallment, + roundCurrency, + summarizePlan, + validateInstallmentRequest, +} from './planner.js'; +export type { BuildScheduleParams } from './planner.js'; +export { + InMemoryInstallmentPlanRepository, + type InstallmentPlanRepository, +} from './store.js'; +export { BNPLInstallmentService, bnplInstallmentService, type BNPLServiceOptions } from './bnplService.js'; diff --git a/backend/src/services/payments/installments/installments.test.ts b/backend/src/services/payments/installments/installments.test.ts new file mode 100644 index 00000000..280d0f72 --- /dev/null +++ b/backend/src/services/payments/installments/installments.test.ts @@ -0,0 +1,387 @@ +/** + * installments.test.ts — Issue #919: BNPL installment plans + * + * Covers the pure schedule math, request validation, and the plan lifecycle + * (create → collect → complete / fail / cancel / sweep). + */ +import { describe, expect, it, beforeEach } from 'vitest'; + +import { + BNPLInstallmentService, + DEFAULT_BNPL_CONFIG, + InMemoryInstallmentPlanRepository, + addPeriod, + buildInstallmentSchedule, + isPlanOverdue, + nextActionableInstallment, + roundCurrency, + summarizePlan, + validateInstallmentRequest, +} from './index.js'; +import type { InstallmentPlanEvent, InstallmentEventPublisher } from './types.js'; + +class CapturingPublisher implements InstallmentEventPublisher { + events: InstallmentPlanEvent[] = []; + publish(event: InstallmentPlanEvent): void { + this.events.push(event); + } +} + +const FIXED_NOW = new Date('2026-09-27T00:00:00.000Z'); + +function makeService(options: { publisher?: InstallmentEventPublisher } = {}) { + return new BNPLInstallmentService({ + repository: new InMemoryInstallmentPlanRepository(), + publisher: options.publisher, + now: () => FIXED_NOW, + idFactory: (() => { + let counter = 0; + return () => `plan-${++counter}`; + })(), + }); +} + +describe('roundCurrency', () => { + it('rounds to two decimals without drifting', () => { + expect(roundCurrency(33.333333)).toBe(33.33); + expect(roundCurrency(33.335)).toBe(33.34); + expect(roundCurrency(0.1 + 0.2)).toBe(0.3); + }); +}); + +describe('addPeriod', () => { + it('adds fixed day windows for weekly and biweekly frequencies', () => { + const start = new Date('2026-01-01T00:00:00.000Z'); + expect(addPeriod(start, 'weekly', 1).toISOString()).toBe('2026-01-08T00:00:00.000Z'); + expect(addPeriod(start, 'biweekly', 2).toISOString()).toBe('2026-01-29T00:00:00.000Z'); + }); + + it('preserves day-of-month for monthly frequency', () => { + const jan15 = new Date('2026-01-15T00:00:00.000Z'); + expect(addPeriod(jan15, 'monthly', 1).toISOString()).toBe('2026-02-15T00:00:00.000Z'); + }); + + it('clamps monthly additions that overflow a shorter month', () => { + const jan31 = new Date('2026-01-31T00:00:00.000Z'); + expect(addPeriod(jan31, 'monthly', 1).toISOString()).toBe('2026-02-28T00:00:00.000Z'); + }); +}); + +describe('buildInstallmentSchedule', () => { + it('splits an amount that divides evenly', () => { + const schedule = buildInstallmentSchedule({ + financedAmount: 120, + installmentCount: 4, + frequency: 'monthly', + startDate: '2026-01-01T00:00:00.000Z', + }); + + expect(schedule.map((entry) => entry.amount)).toEqual([30, 30, 30, 30]); + expect(schedule[0].dueAt).toBe('2026-01-01T00:00:00.000Z'); + expect(schedule[3].dueAt).toBe('2026-04-01T00:00:00.000Z'); + }); + + it('reconciles rounding remainders into the final installment', () => { + const schedule = buildInstallmentSchedule({ + financedAmount: 100, + installmentCount: 3, + frequency: 'monthly', + startDate: '2026-01-01T00:00:00.000Z', + }); + + expect(schedule.map((entry) => entry.amount)).toEqual([33.33, 33.33, 33.34]); + const total = roundCurrency(schedule.reduce((sum, entry) => sum + entry.amount, 0)); + expect(total).toBe(100); + }); + + it('keeps a low-denomination split exact', () => { + const schedule = buildInstallmentSchedule({ + financedAmount: 10.01, + installmentCount: 6, + frequency: 'weekly', + startDate: '2026-01-01T00:00:00.000Z', + }); + const total = roundCurrency(schedule.reduce((sum, entry) => sum + entry.amount, 0)); + expect(total).toBe(10.01); + }); + + it('rejects a non-positive installment count', () => { + expect(() => + buildInstallmentSchedule({ + financedAmount: 100, + installmentCount: 0, + frequency: 'monthly', + startDate: FIXED_NOW, + }), + ).toThrow(/greater than zero/); + }); +}); + +describe('validateInstallmentRequest', () => { + const base = { + tenantId: 'tenant-1', + amount: 300, + installmentCount: 3, + startDate: '2026-01-01T00:00:00.000Z', + }; + + it('applies defaults for currency, frequency and down payment', () => { + const result = validateInstallmentRequest(base); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.currency).toBe('USD'); + expect(result.value.frequency).toBe('monthly'); + expect(result.value.downPayment).toBe(0); + expect(result.value.financedAmount).toBe(300); + }); + + it('computes the financed amount net of a down payment', () => { + const result = validateInstallmentRequest({ ...base, amount: 500, downPayment: 100 }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.financedAmount).toBe(400); + }); + + it('rejects a missing tenant', () => { + const result = validateInstallmentRequest({ ...base, tenantId: '' }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe('VALIDATION_ERROR'); + }); + + it('rejects non-positive amounts', () => { + expect(validateInstallmentRequest({ ...base, amount: 0 }).ok).toBe(false); + expect(validateInstallmentRequest({ ...base, amount: -5 }).ok).toBe(false); + }); + + it('rejects installment counts outside the configured bounds', () => { + expect(validateInstallmentRequest({ ...base, installmentCount: 1 }).ok).toBe(false); + expect(validateInstallmentRequest({ ...base, installmentCount: 13 }).ok).toBe(false); + }); + + it('rejects unsupported frequencies and currencies', () => { + expect( + validateInstallmentRequest({ ...base, frequency: 'daily' as never }).ok, + ).toBe(false); + expect(validateInstallmentRequest({ ...base, currency: 'JPY' }).ok).toBe(false); + }); + + it('rejects a down payment that is not smaller than the amount', () => { + expect(validateInstallmentRequest({ ...base, downPayment: 300 }).ok).toBe(false); + expect(validateInstallmentRequest({ ...base, downPayment: 350 }).ok).toBe(false); + }); + + it('enforces the financed amount floor and ceiling', () => { + expect(validateInstallmentRequest({ ...base, amount: 5 }).ok).toBe(false); + expect( + validateInstallmentRequest({ ...base, amount: DEFAULT_BNPL_CONFIG.maxFinancedAmount + 1 }).ok, + ).toBe(false); + }); + + it('rejects an invalid start date', () => { + expect(validateInstallmentRequest({ ...base, startDate: 'not-a-date' }).ok).toBe(false); + }); +}); + +describe('BNPLInstallmentService', () => { + let publisher: CapturingPublisher; + let service: BNPLInstallmentService; + + beforeEach(() => { + publisher = new CapturingPublisher(); + service = makeService({ publisher }); + }); + + async function createPlan(overrides: Record = {}) { + const result = await service.createPlan({ + tenantId: 'tenant-1', + amount: 300, + installmentCount: 3, + startDate: '2026-01-01T00:00:00.000Z', + ...overrides, + }); + if (!result.ok) throw new Error(`createPlan failed: ${result.error.message}`); + return result.value; + } + + it('creates a plan whose schedule sums to the financed amount', async () => { + const plan = await createPlan(); + + expect(plan.id).toBe('plan-1'); + expect(plan.status).toBe('active'); + expect(plan.installments).toHaveLength(3); + expect(plan.financedAmount).toBe(300); + expect(roundCurrency(plan.installments.reduce((sum, i) => sum + i.amount, 0))).toBe(300); + expect(publisher.events.map((event) => event.type)).toContain('installment_plan.created'); + }); + + it('does not persist a plan when validation fails', async () => { + const result = await service.createPlan({ + tenantId: 'tenant-1', + amount: 300, + installmentCount: 1, + startDate: '2026-01-01T00:00:00.000Z', + }); + expect(result.ok).toBe(false); + const listed = await service.listPlans('tenant-1'); + expect(listed.ok && listed.value).toHaveLength(0); + }); + + it('scopes plan reads to the owning tenant', async () => { + const plan = await createPlan(); + const other = await service.getPlan('tenant-2', plan.id); + expect(other.ok).toBe(false); + if (other.ok) return; + expect(other.error.code).toBe('NOT_FOUND'); + }); + + it('lists plans filtered by status and customer', async () => { + await createPlan({ customerId: 'cust-1' }); + await createPlan({ customerId: 'cust-2' }); + + const byCustomer = await service.listPlans('tenant-1', { customerId: 'cust-1' }); + expect(byCustomer.ok && byCustomer.value).toHaveLength(1); + + await service.cancelPlan('tenant-1', 'plan-1', 'requested'); + const activeOnly = await service.listPlans('tenant-1', { status: 'active' }); + expect(activeOnly.ok && activeOnly.value).toHaveLength(1); + expect(activeOnly.ok && activeOnly.value[0].id).toBe('plan-2'); + }); + + it('collects installments in order and completes the plan on the final one', async () => { + const plan = await createPlan({ installmentCount: 2, amount: 200 }); + + const first = await service.payInstallment('tenant-1', plan.id, { installmentIndex: 1, paymentId: 'pay-1' }); + expect(first.ok).toBe(true); + expect(first.ok && first.value.status).toBe('active'); + expect(first.ok && first.value.installments[0].status).toBe('paid'); + + const second = await service.payInstallment('tenant-1', plan.id, { installmentIndex: 2, paymentId: 'pay-2' }); + expect(second.ok && second.value.status).toBe('completed'); + + const types = publisher.events.map((event) => event.type); + expect(types).toContain('installment.paid'); + expect(types).toContain('installment_plan.completed'); + }); + + it('rejects collecting the same installment twice', async () => { + const plan = await createPlan(); + await service.payInstallment('tenant-1', plan.id, { installmentIndex: 1 }); + const again = await service.payInstallment('tenant-1', plan.id, { installmentIndex: 1 }); + expect(again.ok).toBe(false); + if (again.ok) return; + expect(again.error.code).toBe('CONFLICT'); + }); + + it('rejects collecting an unknown installment index', async () => { + const plan = await createPlan(); + const result = await service.payInstallment('tenant-1', plan.id, { installmentIndex: 99 }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe('VALIDATION_ERROR'); + }); + + it('rejects collecting on a cancelled plan', async () => { + const plan = await createPlan(); + await service.cancelPlan('tenant-1', plan.id, 'customer changed mind'); + const result = await service.payInstallment('tenant-1', plan.id, { installmentIndex: 1 }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe('CONFLICT'); + }); + + it('records a failed collection and lets it be retried later', async () => { + const plan = await createPlan(); + const failed = await service.markInstallmentFailed('tenant-1', plan.id, 1, 'card declined'); + expect(failed.ok).toBe(true); + expect(failed.ok && failed.value.installments[0].status).toBe('failed'); + expect(failed.ok && failed.value.installments[0].failureReason).toBe('card declined'); + + const retried = await service.payInstallment('tenant-1', plan.id, { installmentIndex: 1, paymentId: 'pay-retry' }); + expect(retried.ok).toBe(true); + expect(retried.ok && retried.value.installments[0].status).toBe('paid'); + expect(retried.ok && retried.value.installments[0].failureReason).toBeNull(); + }); + + it('voids outstanding installments when a plan is cancelled', async () => { + const plan = await createPlan(); + await service.payInstallment('tenant-1', plan.id, { installmentIndex: 1 }); + const cancelled = await service.cancelPlan('tenant-1', plan.id, 'fraud review'); + + expect(cancelled.ok).toBe(true); + if (!cancelled.ok) return; + expect(cancelled.value.status).toBe('cancelled'); + expect(cancelled.value.installments[0].status).toBe('paid'); + expect(cancelled.value.installments.slice(1).every((i) => i.status === 'cancelled')).toBe(true); + expect(publisher.events.map((event) => event.type)).toContain('installment_plan.cancelled'); + }); + + it('refuses to cancel an already completed plan', async () => { + const plan = await createPlan({ installmentCount: 2, amount: 200 }); + await service.payInstallment('tenant-1', plan.id, { installmentIndex: 1 }); + await service.payInstallment('tenant-1', plan.id, { installmentIndex: 2 }); + const result = await service.cancelPlan('tenant-1', plan.id); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe('CONFLICT'); + }); + + it('flags overdue installments exactly once', async () => { + const plan = await createPlan({ + installmentCount: 3, + amount: 300, + frequency: 'monthly', + startDate: '2026-01-01T00:00:00.000Z', + }); + + const sweep = await service.sweepOverdue('tenant-1', new Date('2026-02-15T00:00:00.000Z')); + expect(sweep.ok && sweep.value.flagged).toBe(2); + + const stored = await service.getPlan('tenant-1', plan.id); + expect(stored.ok && stored.value.installments[0].status).toBe('due'); + expect(stored.ok && stored.value.installments[1].status).toBe('due'); + expect(stored.ok && stored.value.installments[2].status).toBe('scheduled'); + + const secondSweep = await service.sweepOverdue('tenant-1', new Date('2026-02-15T00:00:00.000Z')); + expect(secondSweep.ok && secondSweep.value.flagged).toBe(0); + }); + + it('summarises repayment progress', async () => { + const plan = await createPlan({ installmentCount: 4, amount: 400 }); + await service.payInstallment('tenant-1', plan.id, { installmentIndex: 1 }); + + const summary = await service.getSummary('tenant-1', plan.id); + expect(summary.ok).toBe(true); + if (!summary.ok) return; + expect(summary.value.paidAmount).toBe(100); + expect(summary.value.remainingAmount).toBe(300); + expect(summary.value.paidCount).toBe(1); + expect(summary.value.outstandingCount).toBe(3); + expect(summary.value.progressPercent).toBe(25); + expect(summary.value.nextDueAt).toBe(plan.installments[1].dueAt); + }); +}); + +describe('plan helpers', () => { + it('identifies the next outstanding installment and overdue plans', async () => { + const service = makeService(); + const created = await service.createPlan({ + tenantId: 'tenant-1', + amount: 200, + installmentCount: 2, + frequency: 'weekly', + startDate: '2026-01-01T00:00:00.000Z', + }); + if (!created.ok) throw new Error('plan creation failed'); + + const plan = created.value; + expect(nextActionableInstallment(plan)?.index).toBe(1); + expect(isPlanOverdue(plan, new Date('2025-12-31T00:00:00.000Z'))).toBe(false); + expect(isPlanOverdue(plan, new Date('2026-01-02T00:00:00.000Z'))).toBe(true); + + const summary = summarizePlan(plan); + expect(summary.totalAmount).toBe(200); + expect(summary.remainingAmount).toBe(200); + expect(summary.progressPercent).toBe(0); + }); +}); diff --git a/backend/src/services/payments/installments/planner.ts b/backend/src/services/payments/installments/planner.ts new file mode 100644 index 00000000..f9cc23d5 --- /dev/null +++ b/backend/src/services/payments/installments/planner.ts @@ -0,0 +1,245 @@ +/** + * planner.ts — Issue #919: BNPL installment plans + * + * Pure, side-effect-free helpers that validate an installment request and + * derive a deterministic repayment schedule. Keeping the money math here + * (rather than inside the service) makes it directly unit-testable and + * avoids floating-point drift leaking into persisted plans. + */ +import { err, ok, type Result } from '../../../lib/result.js'; +import type { + BNPLConfig, + CreateInstallmentPlanInput, + Installment, + InstallmentFrequency, + InstallmentPlan, + InstallmentPlanSummary, + InstallmentScheduleEntry, + NormalizedPlanRequest, +} from './types.js'; + +export const DEFAULT_BNPL_CONFIG: BNPLConfig = { + minInstallments: 2, + maxInstallments: 12, + minFinancedAmount: 10, + maxFinancedAmount: 100_000, + supportedCurrencies: ['USD', 'EUR', 'GBP', 'XLM', 'USDC'], + supportedFrequencies: ['weekly', 'biweekly', 'monthly'], + defaultFrequency: 'monthly', +}; + +const FREQUENCY_DAYS: Record, number> = { + weekly: 7, + biweekly: 14, +}; + +/** Round to 2 decimals using a half-up rule that tolerates float error. */ +export function roundCurrency(value: number): number { + return Math.round((value + Number.EPSILON) * 100) / 100; +} + +/** + * Advance a date by `periods` billing periods. + * + * Weekly/biweekly use fixed day counts; monthly uses calendar months so the + * day-of-month is preserved (clamped to the target month's last day, e.g. + * Jan 31 + 1 month => Feb 28/29). + */ +export function addPeriod( + base: Date | string, + frequency: InstallmentFrequency, + periods: number, +): Date { + const date = base instanceof Date ? new Date(base.getTime()) : new Date(base); + if (frequency === 'monthly') { + const dayOfMonth = date.getUTCDate(); + date.setUTCDate(1); + date.setUTCMonth(date.getUTCMonth() + periods); + const lastDayOfMonth = new Date( + Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0), + ).getUTCDate(); + date.setUTCDate(Math.min(dayOfMonth, lastDayOfMonth)); + return date; + } + + date.setUTCDate(date.getUTCDate() + FREQUENCY_DAYS[frequency] * periods); + return date; +} + +export interface BuildScheduleParams { + financedAmount: number; + installmentCount: number; + frequency: InstallmentFrequency; + startDate: Date | string; +} + +/** + * Split `financedAmount` into `installmentCount` installments that sum back + * to the financed amount exactly. Each installment gets the floored + * 2-decimal share and the final installment absorbs the remaining cents, so + * no money is created or lost to rounding. + */ +export function buildInstallmentSchedule(params: BuildScheduleParams): InstallmentScheduleEntry[] { + const { financedAmount, installmentCount, frequency, startDate } = params; + if (installmentCount <= 0) { + throw new Error('installmentCount must be greater than zero'); + } + + const financed = roundCurrency(financedAmount); + const baseShare = Math.floor((financed / installmentCount) * 100) / 100; + const remainder = roundCurrency(financed - roundCurrency(baseShare * installmentCount)); + const firstDue = startDate instanceof Date ? startDate : new Date(startDate); + + const schedule: InstallmentScheduleEntry[] = []; + for (let index = 1; index <= installmentCount; index += 1) { + const isFinal = index === installmentCount; + schedule.push({ + index, + amount: isFinal ? roundCurrency(baseShare + remainder) : baseShare, + dueAt: addPeriod(firstDue, frequency, index - 1).toISOString(), + }); + } + return schedule; +} + +/** + * Validate and normalise a raw create request against the BNPL config. + * Returns a `Result` so callers can surface a 400 with a precise reason + * instead of throwing deep inside the service. + */ +export function validateInstallmentRequest( + input: CreateInstallmentPlanInput, + config: BNPLConfig = DEFAULT_BNPL_CONFIG, +): Result { + if (!input || typeof input !== 'object') { + return err({ code: 'VALIDATION_ERROR', message: 'Plan request body is required', statusCode: 400 }); + } + + if (!input.tenantId || typeof input.tenantId !== 'string') { + return err({ code: 'VALIDATION_ERROR', message: 'tenantId is required', statusCode: 400 }); + } + + const amount = Number(input.amount); + if (!Number.isFinite(amount) || amount <= 0) { + return err({ code: 'VALIDATION_ERROR', message: 'amount must be a positive number', statusCode: 400 }); + } + + const installmentCount = Number(input.installmentCount); + if (!Number.isInteger(installmentCount)) { + return err({ code: 'VALIDATION_ERROR', message: 'installmentCount must be an integer', statusCode: 400 }); + } + if (installmentCount < config.minInstallments || installmentCount > config.maxInstallments) { + return err({ + code: 'VALIDATION_ERROR', + message: `installmentCount must be between ${config.minInstallments} and ${config.maxInstallments}`, + statusCode: 400, + }); + } + + const frequency = input.frequency ?? config.defaultFrequency; + if (!config.supportedFrequencies.includes(frequency)) { + return err({ + code: 'VALIDATION_ERROR', + message: `frequency must be one of: ${config.supportedFrequencies.join(', ')}`, + statusCode: 400, + }); + } + + const currency = (input.currency ?? 'USD').toUpperCase(); + if (!config.supportedCurrencies.includes(currency)) { + return err({ + code: 'VALIDATION_ERROR', + message: `currency must be one of: ${config.supportedCurrencies.join(', ')}`, + statusCode: 400, + }); + } + + const downPayment = input.downPayment == null ? 0 : roundCurrency(Number(input.downPayment)); + if (!Number.isFinite(downPayment) || downPayment < 0) { + return err({ code: 'VALIDATION_ERROR', message: 'downPayment must be a non-negative number', statusCode: 400 }); + } + if (downPayment >= amount) { + return err({ + code: 'VALIDATION_ERROR', + message: 'downPayment must be less than amount', + statusCode: 400, + }); + } + + const financedAmount = roundCurrency(amount - downPayment); + if (financedAmount < config.minFinancedAmount) { + return err({ + code: 'VALIDATION_ERROR', + message: `financed amount must be at least ${config.minFinancedAmount} ${currency}`, + statusCode: 400, + }); + } + if (financedAmount > config.maxFinancedAmount) { + return err({ + code: 'VALIDATION_ERROR', + message: `financed amount must not exceed ${config.maxFinancedAmount} ${currency}`, + statusCode: 400, + }); + } + + const startDate = input.startDate ? new Date(input.startDate) : new Date(); + if (Number.isNaN(startDate.getTime())) { + return err({ code: 'VALIDATION_ERROR', message: 'startDate must be a valid date', statusCode: 400 }); + } + + return ok({ + tenantId: input.tenantId, + amount: roundCurrency(amount), + currency, + installmentCount, + frequency, + downPayment, + financedAmount, + startDate, + customerId: input.customerId, + merchantId: input.merchantId, + metadata: input.metadata, + }); +} + +const OUTSTANDING_STATUSES = new Set(['scheduled', 'due']); + +/** Next installment that still needs collecting, in schedule order. */ +export function nextActionableInstallment(plan: InstallmentPlan): Installment | null { + return plan.installments.find((installment) => OUTSTANDING_STATUSES.has(installment.status)) ?? null; +} + +export function summarizePlan(plan: InstallmentPlan): InstallmentPlanSummary { + const paid = plan.installments.filter((installment) => installment.status === 'paid'); + const outstanding = plan.installments.filter((installment) => OUTSTANDING_STATUSES.has(installment.status)); + const paidAmount = roundCurrency(paid.reduce((sum, installment) => sum + installment.amount, 0)); + const totalAmount = roundCurrency( + plan.installments.reduce((sum, installment) => sum + installment.amount, 0), + ); + const next = nextActionableInstallment(plan); + + return { + planId: plan.id, + status: plan.status, + currency: plan.currency, + totalAmount, + paidAmount, + remainingAmount: roundCurrency(totalAmount - paidAmount), + paidCount: paid.length, + outstandingCount: outstanding.length, + nextDueAt: next ? next.dueAt : null, + progressPercent: + plan.installments.length === 0 + ? 0 + : roundCurrency((paid.length / plan.installments.length) * 100), + }; +} + +/** True when any outstanding installment is past its due date. */ +export function isPlanOverdue(plan: InstallmentPlan, now: Date | string = new Date()): boolean { + const reference = now instanceof Date ? now : new Date(now); + return plan.installments.some( + (installment) => + OUTSTANDING_STATUSES.has(installment.status) && new Date(installment.dueAt).getTime() < reference.getTime(), + ); +} diff --git a/backend/src/services/payments/installments/store.ts b/backend/src/services/payments/installments/store.ts new file mode 100644 index 00000000..ca6bb71d --- /dev/null +++ b/backend/src/services/payments/installments/store.ts @@ -0,0 +1,70 @@ +/** + * store.ts — Issue #919: BNPL installment plans + * + * Persistence boundary for installment plans. The service depends on the + * `InstallmentPlanRepository` interface, so production can back it with + * Prisma while tests use the deterministic in-memory implementation below. + */ +import type { InstallmentPlan, InstallmentPlanListFilter } from './types.js'; + +export interface InstallmentPlanRepository { + create(plan: InstallmentPlan): Promise; + findById(id: string): Promise; + update(plan: InstallmentPlan): Promise; + list(tenantId: string, filter?: InstallmentPlanListFilter): Promise; + /** All plans with at least one outstanding installment due before `before`. */ + findWithOverdueInstallments(tenantId: string, before: Date): Promise; +} + +function clone(plan: InstallmentPlan): InstallmentPlan { + return { + ...plan, + installments: plan.installments.map((installment) => ({ ...installment })), + metadata: plan.metadata ? { ...plan.metadata } : plan.metadata, + }; +} + +export class InMemoryInstallmentPlanRepository implements InstallmentPlanRepository { + private readonly plans = new Map(); + + async create(plan: InstallmentPlan): Promise { + const stored = clone(plan); + this.plans.set(stored.id, stored); + return clone(stored); + } + + async findById(id: string): Promise { + const found = this.plans.get(id); + return found ? clone(found) : null; + } + + async update(plan: InstallmentPlan): Promise { + const stored = clone(plan); + this.plans.set(stored.id, stored); + return clone(stored); + } + + async list(tenantId: string, filter?: InstallmentPlanListFilter): Promise { + return Array.from(this.plans.values()) + .filter((plan) => plan.tenantId === tenantId) + .filter((plan) => (filter?.status ? plan.status === filter.status : true)) + .filter((plan) => (filter?.customerId ? plan.customerId === filter.customerId : true)) + .filter((plan) => (filter?.merchantId ? plan.merchantId === filter.merchantId : true)) + .sort((a, b) => a.createdAt.localeCompare(b.createdAt)) + .map(clone); + } + + async findWithOverdueInstallments(tenantId: string, before: Date): Promise { + const cutoff = before.getTime(); + return Array.from(this.plans.values()) + .filter((plan) => plan.tenantId === tenantId && plan.status === 'active') + .filter((plan) => + plan.installments.some( + (installment) => + (installment.status === 'scheduled' || installment.status === 'due') && + new Date(installment.dueAt).getTime() < cutoff, + ), + ) + .map(clone); + } +} diff --git a/backend/src/services/payments/installments/types.ts b/backend/src/services/payments/installments/types.ts new file mode 100644 index 00000000..e34fda7e --- /dev/null +++ b/backend/src/services/payments/installments/types.ts @@ -0,0 +1,145 @@ +/** + * types.ts — Issue #919: BNPL installment plans + * + * Domain types for Buy-Now-Pay-Later installment financing. An + * `InstallmentPlan` is created against an order principal, optionally + * reduced by an up-front down payment, and split into a deterministic + * schedule of `Installment` rows. + */ + +/** How often consecutive installments fall due. */ +export type InstallmentFrequency = 'weekly' | 'biweekly' | 'monthly'; + +/** Lifecycle of a single scheduled installment. */ +export type InstallmentStatus = 'scheduled' | 'due' | 'paid' | 'failed' | 'cancelled'; + +/** Lifecycle of the plan that owns the installments. */ +export type InstallmentPlanStatus = 'active' | 'completed' | 'cancelled' | 'defaulted'; + +export interface Installment { + /** 1-based position in the schedule. */ + index: number; + /** Amount due, expressed in major currency units rounded to 2 decimals. */ + amount: number; + /** ISO-8601 UTC timestamp the installment falls due. */ + dueAt: string; + status: InstallmentStatus; + paidAt?: string | null; + /** Identifier of the settled payment attempt, when paid. */ + paymentId?: string | null; + failureReason?: string | null; +} + +export interface InstallmentPlan { + id: string; + tenantId: string; + customerId?: string | null; + merchantId?: string | null; + currency: string; + /** Full order value before the down payment. */ + principal: number; + /** Up-front amount paid immediately, never financed. */ + downPayment: number; + /** principal - downPayment. */ + financedAmount: number; + installmentCount: number; + frequency: InstallmentFrequency; + status: InstallmentPlanStatus; + installments: Installment[]; + metadata?: Record; + createdAt: string; + updatedAt: string; +} + +export interface CreateInstallmentPlanInput { + tenantId: string; + /** Order principal in major currency units. */ + amount: number; + currency?: string; + installmentCount: number; + frequency?: InstallmentFrequency; + downPayment?: number; + customerId?: string; + merchantId?: string; + /** First installment due date; defaults to "now" at call time. */ + startDate?: string | Date; + metadata?: Record; +} + +export interface InstallmentScheduleEntry { + index: number; + amount: number; + dueAt: string; +} + +export interface BNPLConfig { + minInstallments: number; + maxInstallments: number; + minFinancedAmount: number; + maxFinancedAmount: number; + supportedCurrencies: string[]; + supportedFrequencies: InstallmentFrequency[]; + defaultFrequency: InstallmentFrequency; +} + +export interface NormalizedPlanRequest { + tenantId: string; + amount: number; + currency: string; + installmentCount: number; + frequency: InstallmentFrequency; + downPayment: number; + financedAmount: number; + startDate: Date; + customerId?: string; + merchantId?: string; + metadata?: Record; +} + +export interface InstallmentPlanSummary { + planId: string; + status: InstallmentPlanStatus; + currency: string; + totalAmount: number; + paidAmount: number; + remainingAmount: number; + paidCount: number; + outstandingCount: number; + nextDueAt: string | null; + progressPercent: number; +} + +export interface RecordInstallmentPaymentInput { + installmentIndex: number; + paymentId?: string; + paidAt?: string | Date; +} + +export interface InstallmentPlanListFilter { + status?: InstallmentPlanStatus; + customerId?: string; + merchantId?: string; +} + +/** Emitted whenever a plan or one of its installments changes state. */ +export type InstallmentPlanEventType = + | 'installment_plan.created' + | 'installment_plan.cancelled' + | 'installment_plan.completed' + | 'installment.paid' + | 'installment.failed' + | 'installment.overdue'; + +export interface InstallmentPlanEvent { + type: InstallmentPlanEventType; + planId: string; + tenantId: string; + occurredAt: string; + installmentIndex?: number; + data?: Record; +} + +/** Pluggable publisher so the service can stay transport-agnostic. */ +export interface InstallmentEventPublisher { + publish(event: InstallmentPlanEvent): Promise | void; +}