Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions backend/docs/BNPL_INSTALLMENTS.md
Original file line number Diff line number Diff line change
@@ -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/<planId>/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.
Original file line number Diff line number Diff line change
@@ -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";
Original file line number Diff line number Diff line change
@@ -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;
60 changes: 60 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
2 changes: 2 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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());
});
Expand Down
Loading
Loading