feat(payments): BNPL installment plan scheduling and lifecycle - #982
Merged
github-actions[bot] merged 1 commit intoSep 27, 2026
Merged
github-actions[bot] merged 1 commit into
github-actions[bot] merged 1 commit into
Conversation
…ing and lifecycle
|
@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. |
|
@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! 🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.tsand 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 sameBaseService/Resultconventions 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.tsInstallmentPlan,Installment,InstallmentStatus(scheduled/due/paid/failed/cancelled),InstallmentPlanStatus(active/completed/cancelled/defaulted),InstallmentFrequency(weekly/biweekly/monthly).CreateInstallmentPlanInput,RecordInstallmentPaymentInput,InstallmentPlanListFilter,InstallmentPlanSummary,InstallmentScheduleEntry,NormalizedPlanRequest,BNPLConfig.InstallmentPlanEvent/InstallmentPlanEventType/InstallmentEventPublisher— the domain events the service emits.Scheduling arithmetic —
backend/src/services/payments/installments/planner.tsroundCurrency— half-up rounding to cents, the single place money is rounded.buildInstallmentSchedule— equal shares per installment, with the rounding remainder absorbed by the final installment so the schedule always reconciles to the financed amount.addPeriod—weekly(+7d) andbiweekly(+14d) as exact day steps;monthlyas calendar months with end-of-month day clamping.validateInstallmentRequest— returns a normalized request or aResulterror: count 2–12, amount floor/ceiling, non-negative down payment smaller than the amount, supported currency, frequency, and a parseable start date.DEFAULT_BNPL_CONFIGplusnextActionableInstallment,summarizePlan(paid/outstanding/total/progress) andisPlanOverdue.Persistence —
backend/src/services/payments/installments/store.tsInstallmentPlanRepository— the persistence contract used by the service.InMemoryInstallmentPlanRepository— the default implementation, so the service and its tests need no database.Lifecycle —
backend/src/services/payments/installments/bnplService.tsBNPLInstallmentService extends BaseServicewithcreatePlan,getPlan,listPlans,payInstallment,markInstallmentFailed,cancelPlan,sweepOverdueandgetSummary.payInstallmentis the only path to apaidinstallment: it rejects unknown indexes, already-paid installments and cancelled plans, and marks the plancompletedonce the last installment clears.markInstallmentFailedrecords the reason; a failed installment can be retried throughpayInstallment.sweepOverdue(now)transitionsscheduled→duefor every installment past itsdueAt; it is idempotent, so running it repeatedly does not re-flag anything.cancelPlanvoids the plan and every installment that has not been paid, leaving settled installments intact.bnplInstallmentService— the shared singleton, plus atenantId-scoped guard on every read/write so one tenant cannot reach another's plan.HTTP surface —
backend/src/routes/installments.tsinstallmentsRouterusing the repository's existingasyncHandler+Resulterror mapping:Persistence schema
backend/prisma/schema.prisma—InstallmentPlanandInstallmentmodels with theInstallmentPlanStatus/InstallmentStatusenums, money asDecimal(20, 8),@@unique([planId, installmentNo]), indexes on(tenantId, status),customerIdand(status, dueAt), and a cascading plan → installments relation.backend/prisma/migrations/20260927000000_bnpl_installments/migration.sqlanddown.sql— DDL for the two tables, three indexes and two enums, plus the reverse migration.Wiring + docs
backend/src/index.ts— mountsinstallmentsRouterunder the versioned API.backend/src/services/payments/installments/index.ts— module barrel.backend/docs/BNPL_INSTALLMENTS.md— scheduling rules (rounding, period math), the installment/plan state machine, the event contract and the endpoint reference.backend/src/services/payments/installments/installments.test.ts— 31 tests.Verification Results
The suite ran against the real
BaseServiceandResulthelpers 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:
/api/v1/installments/*and persisted by the Prisma models + migrationbuildInstallmentScheduleabsorbs the rounding remainder in the final installment (roundCurrencyhalf-up)addPeriod:weekly7d,biweekly14d,monthlycalendar months with end-of-month clampinginstallments.test.ts— 31/31backend/docs/BNPL_INSTALLMENTS.md;prisma validatepasses on the migration DDLBaseService+Resulterror mapping,asyncHandlerroutes, domain events published through an injected publisher, repository behind an interfacesrc/index.ts, one new router, one new service folder. No existing file's behaviour is modified.Notes for the reviewer
InMemoryInstallmentPlanRepositoryas 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.InstallmentEventPublisherlogs 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.sweepOverduedeliberately only movesscheduled→due(the dunning transition the plan statusdefaultedis reserved for); it emits an event per transitioned installment and never touches settled ones.Closes #919