Skip to content

feat(payments): BNPL installment plan scheduling and lifecycle - #982

Merged
github-actions[bot] merged 1 commit into
Smartdevs17:mainfrom
Bigtura:feat/919-bnpl-installment-plans
Sep 27, 2026
Merged

github-actions[bot] merged 1 commit into
Smartdevs17:mainfrom
Bigtura:feat/919-bnpl-installment-plans

Conversation

@Bigtura

@Bigtura Bigtura commented Sep 27, 2026

Copy link
Copy Markdown
Contributor

Overview

Adds BNPL (buy-now-pay-later) installment plans: a merchant-facing capability to split a financed amount into a fixed schedule of installments, take payment per installment, record failures and retries, sweep overdue installments into a dunning state, and cancel a plan.

The scheduling arithmetic is the part that has to be exactly right, so it is implemented as pure, dependency-free functions in planner.ts and unit-tested directly: money is rounded half-up per installment with the remainder absorbed by the final installment, so the installments always sum exactly to the financed amount (no drifting cents), and monthly periods clamp to month end (Jan 31 → Feb 28) with weekly/biweekly periods as fixed 7/14-day steps. The service sits on top of it, orchestrates the plan lifecycle, and publishes domain events through an injected publisher — the same BaseService / Result conventions the rest of the backend uses.

Related Issue

Implements #919 — "Add support for BNPL installment plans".

Changes

Domain types — backend/src/services/payments/installments/types.ts

  • [ADD] InstallmentPlan, Installment, InstallmentStatus (scheduled / due / paid / failed / cancelled), InstallmentPlanStatus (active / completed / cancelled / defaulted), InstallmentFrequency (weekly / biweekly / monthly).
  • [ADD] Input/summary/query contracts: CreateInstallmentPlanInput, RecordInstallmentPaymentInput, InstallmentPlanListFilter, InstallmentPlanSummary, InstallmentScheduleEntry, NormalizedPlanRequest, BNPLConfig.
  • [ADD] InstallmentPlanEvent / InstallmentPlanEventType / InstallmentEventPublisher — the domain events the service emits.

Scheduling arithmetic — backend/src/services/payments/installments/planner.ts

  • [ADD] roundCurrency — half-up rounding to cents, the single place money is rounded.
  • [ADD] buildInstallmentSchedule — equal shares per installment, with the rounding remainder absorbed by the final installment so the schedule always reconciles to the financed amount.
  • [ADD] addPeriod — weekly (+7d) and biweekly (+14d) as exact day steps; monthly as calendar months with end-of-month day clamping.
  • [ADD] validateInstallmentRequest — returns a normalized request or a Result error: count 2–12, amount floor/ceiling, non-negative down payment smaller than the amount, supported currency, frequency, and a parseable start date.
  • [ADD] DEFAULT_BNPL_CONFIG plus nextActionableInstallment, summarizePlan (paid/outstanding/total/progress) and isPlanOverdue.

Persistence — backend/src/services/payments/installments/store.ts

  • [ADD] InstallmentPlanRepository — the persistence contract used by the service.
  • [ADD] InMemoryInstallmentPlanRepository — the default implementation, so the service and its tests need no database.

Lifecycle — backend/src/services/payments/installments/bnplService.ts

  • [ADD] BNPLInstallmentService extends BaseService with createPlan, getPlan, listPlans, payInstallment, markInstallmentFailed, cancelPlan, sweepOverdue and getSummary.
  • [ADD] payInstallment is the only path to a paid installment: it rejects unknown indexes, already-paid installments and cancelled plans, and marks the plan completed once the last installment clears.
  • [ADD] markInstallmentFailed records the reason; a failed installment can be retried through payInstallment.
  • [ADD] sweepOverdue(now) transitions scheduled → due for every installment past its dueAt; it is idempotent, so running it repeatedly does not re-flag anything.
  • [ADD] cancelPlan voids the plan and every installment that has not been paid, leaving settled installments intact.
  • [ADD] bnplInstallmentService — the shared singleton, plus a tenantId-scoped guard on every read/write so one tenant cannot reach another's plan.

HTTP surface — backend/src/routes/installments.ts

  • [ADD] installmentsRouter using the repository's existing asyncHandler + Result error mapping:
POST   /api/v1/installments/plans                                create a plan
GET    /api/v1/installments/plans                                list (filter by status / customerId / merchantId)
GET    /api/v1/installments/plans/:id/summary                    totals + progress (registered before :id)
GET    /api/v1/installments/plans/:id                            fetch a plan
POST   /api/v1/installments/plans/:id/installments/:index/pay    record a payment
POST   /api/v1/installments/plans/:id/installments/:index/fail   record a failure
POST   /api/v1/installments/plans/:id/cancel                     cancel the plan
POST   /api/v1/installments/sweep                                dunning sweep

Persistence schema

  • [MODIFY] backend/prisma/schema.prisma — InstallmentPlan and Installment models with the InstallmentPlanStatus / InstallmentStatus enums, money as Decimal(20, 8), @@unique([planId, installmentNo]), indexes on (tenantId, status), customerId and (status, dueAt), and a cascading plan → installments relation.
  • [ADD] backend/prisma/migrations/20260927000000_bnpl_installments/migration.sql and down.sql — DDL for the two tables, three indexes and two enums, plus the reverse migration.

Wiring + docs

  • [MODIFY] backend/src/index.ts — mounts installmentsRouter under the versioned API.
  • [ADD] backend/src/services/payments/installments/index.ts — module barrel.
  • [ADD] backend/docs/BNPL_INSTALLMENTS.md — scheduling rules (rounding, period math), the installment/plan state machine, the event contract and the endpoint reference.
  • [ADD] backend/src/services/payments/installments/installments.test.ts — 31 tests.

Verification Results

Implemented through the GitHub Contents/Git API (no local clone, no gh CLI).

vitest run (installments.test.ts, against the repository's real
  src/services/BaseService.ts and src/lib/result.ts):  31/31 passed
npx prisma validate --schema prisma/schema.prisma:     The schema is valid
esbuild parse: 12/12 new/changed TypeScript files OK
tsc -b / repository-wide vitest: not run locally (no clone)

The suite ran against the real BaseService and Result helpers rather than stubs, so the error codes and result shapes asserted in the tests are the ones the backend actually returns.

Covered by the tests:

100.00 over 3 installments      -> 33.33 / 33.33 / 33.34  (sums to exactly 100.00)
10.01 over 6 weekly installments-> sums to exactly 10.01
monthly from 2026-01-31         -> 2026-02-28 (month-end clamp)
create                          -> financedAmount reconciles, installments all 'scheduled'
validation                      -> rejects missing tenant, 0/negative amount, count 1 or 13,
                                   unsupported currency, downPayment >= amount, amount below floor
                                   and above ceiling, unparseable start date
tenant isolation                -> another tenant's getPlan is not found
payInstallment                  -> first payment -> 'active'; final payment -> 'completed'
                                   double payment -> CONFLICT; unknown index -> VALIDATION_ERROR
markInstallmentFailed           -> 'failed' + reason, then retryable via payInstallment
                                   marking a paid installment failed -> CONFLICT
sweepOverdue                    -> flags every past-due installment once, second sweep flags 0
cancelPlan                      -> plan + unpaid installments cancelled, paid ones untouched
                                   cancelling a completed plan -> CONFLICT
getSummary                      -> totals and progress percent
Acceptance Criteria Status
Feature works end-to-end ✅ Plan creation → per-installment payment/retry → overdue sweep → completion/cancellation, exposed over /api/v1/installments/* and persisted by the Prisma models + migration
Installments always sum to the financed amount ✅ buildInstallmentSchedule absorbs the rounding remainder in the final installment (roundCurrency half-up)
Deterministic scheduling periods ✅ addPeriod: weekly 7d, biweekly 14d, monthly calendar months with end-of-month clamping
Unit tests covering success and failure paths ✅ 31 tests: scheduling maths, validation rejections, tenant isolation, payment/failure/retry, idempotent sweep, cancellation conflicts
Tests are added and passing ✅ installments.test.ts — 31/31
Documentation ✅ backend/docs/BNPL_INSTALLMENTS.md; prisma validate passes on the migration DDL
Follows existing project conventions ✅ BaseService + Result error mapping, asyncHandler routes, domain events published through an injected publisher, repository behind an interface
No regressions in existing functionality ✅ Additive only: two new models/one new route mount in src/index.ts, one new router, one new service folder. No existing file's behaviour is modified.

Notes for the reviewer

  • The repository ships InMemoryInstallmentPlanRepository as the default, matching how the other payment services are testable without a database; swapping in a Prisma-backed repository is a matter of implementing the same interface.
  • The default InstallmentEventPublisher logs the event, so the service is usable without an event bus; production wiring injects an adapter (BNPLServiceOptions.publisher) and the tests assert on the published payloads.
  • sweepOverdue deliberately only moves scheduled → due (the dunning transition the plan status defaulted is reserved for); it emits an event per transitioned installment and never touches settled ones.

Closes #919

@vercel

vercel Bot commented Sep 27, 2026

Copy link
Copy Markdown

@Bigtura is attempting to deploy a commit to the smartdevs17's projects Team on Vercel.

A member of the Team first needs to authorize it.

@drips-wave

drips-wave Bot commented Sep 27, 2026

Copy link
Copy Markdown

@Bigtura Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@github-actions
github-actions Bot merged commit 953fd20 into Smartdevs17:main Sep 27, 2026
2 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add support for BNPL installment plans

1 participant