33 KiB
Backend Phase 12 — BNPL: provider-financed installments (mocked)
Mission: let a family pay for a booking with a provider-financed BNPL plan (SnappPay / Digipay / Tara / Torob Pay) — and record it correctly. The decisive, verified truth is that an Iranian BNPL order settles the full booking amount to Balinyaar in one inbound lump, net of the provider's merchant commission, and the provider owns the customer's installments and 100% of default risk. So in our books a BNPL order is a card payment that lands net-of-fee: one
bnpl_transactionsrow (1:1 with itspayment_transaction) that drives an idempotenteligible → token_issued → verified → settledstate machine, a settle that posts the card-capture ledger legs plus abnpl_fee_expenseleg so escrow reflects the net cash actually received, and a provider-mediated revert path. We do not model the customer's repayment schedule or default — that subsystem was deleted. The nurse's payout is invariant to payment method.Track: backend · Depends on: b10 (
payment_transactions,ledger_entries,payment_webhook_events, the card-capture posting,IWebhookVerifier,IDistributedLock), b11 (refunds1:N, fee/payout decomposition,refund_channel) · Unlocks: BNPL checkout; frontend f11-b12 Before you start, read../_shared/agent-operating-rules.md. It is not optional.
1. Context — where this sits
This is backend phase b12, the third leg of the payments arc (b10 ledger/txn/webhook/capture → b11
refunds/clawbacks/invoices → b12 BNPL → b13 payouts). The platform never custodies cash: "escrow" is
an internal double-entry ledger state (product/payments/escrow-ledger.md),
and BNPL is not a new money model — it collapses to the existing inbound-capture rail with one extra
fact: the cash that lands is net of the provider's merchant discount. This phase records that single
inbound settlement, the provider's commission (a platform expense, never the nurse's), and the
provider-mediated reversal — nothing about the customer's 4-installment repayment, which the provider owns
end to end.
What already exists (do not rebuild) — built by prior phases:
- The ledger, transactions & webhook idempotency — b10 built
ledger_entries(append-only, balanced,transaction_group_id, the sixaccount_types incl.escrow_held,platform_revenue,nurse_payable,refund_payable,bnpl_fee_expense,nurse_clawback_receivable),payment_transactions(filteredUNIQUE(gateway_reference_code) WHERE NOT NULLandUNIQUE(booking_id) WHERE status='succeeded'),payment_webhook_events(UNIQUE(provider_code, external_event_id)— the idempotency anchor), the card-capture ledger posting (DEBIT escrow_heldgross /CREDIT platform_revenuecommission +CREDIT nurse_payablepayout), theIWebhookVerifierseam, and theIDistributedLockRedis-lock pattern on the money path (lock(booking:{id}:payment),lock(booking:{id}:refund)). Reuse the ledger posting helper, the webhook-event dedup, the lock, andIWebhookVerifier— do not re-implement any of them. - The card-capture posting structure — b10's
ConfirmPaymentAndPostLedgerposts the card-capture group. The BNPL settle is that same group PLUS abnpl_fee_expenseleg — extend/reuse the helper, do not fork it. - Refunds — b11 built
refunds(1:N perpayment_transaction, fee-leg vs payout-leg decomposition,refund_channel∈psp_card|bnpl_revert|manual,external_revert_reference,expected_customer_refund_eta, ticket-linked, admin-only) and the refund ledger posting. The BNPL revert path creates arefundrow withrefund_channel='bnpl_revert'and posts the refund ledger legs via b11's helper — it does not redefine refunds. - Bookings & the three-amount split — b9's
bookingscarrygross_price_irr = balinyaar_commission_irr + nurse_payout_amountandplatform_fee_rate. The BNPLorder_amount_irris the booking'sgross_price_irr; the nurse's payout is computed from the booking split, never fromsettled_amount_irr. payment_gateways— b10's per-provider config (encryptedconfig_json,typeselects flow). BNPL providers are rows withtype='bnpl'; provider selection is config-driven.- The platform config accessor — b1's typed, cached
platform_configsreader. Read the mock commission %, settlement-timing class, and currency through it; never hardcode. - The b0 foundation: REST surface,
BaseController,OperationResult<T>, CQRS viamartinothamar/Mediator,IFieldEncryptor,ICurrentUser+ audit interceptor, rate limiting,IDateTimeProvider,ICacheService.
What this phase introduces: the bnpl_transactions table + its status state machine, the
eligibility/initiate/verify/settle/revert/callback/status capabilities, and two new seams —
IBnplProvider (the mocked provider, one impl per provider_code) and ICurrencyNormalizer
(Toman→IRR at the boundary). bnpl_settlement_entries (tranched settlement) is DEFERRED — do not
build it.
2. Required reading (do this first)
../_shared/agent-operating-rules.mdand../_shared/backend-conventions-checklist.md— especially the Performance, caching, money, idempotency block (IRRBIGINT, append-only balanced ledger, idempotent money writes, webhook dedup, Redis lock on the money path).product/business/09-installments-bnpl.md— the business rules: full-upfront provider-financed settlement; a BNPL order is a card payment that lands net-of-fee; do not track customer installments / per-installment webhooks / default propagation; refunds flow only customer ↔ provider ↔ Balinyaar; the nurse's payout is unchanged by BNPL; MVP vs DEFERRED (no in-house credit, single provider, no tranched settlement).product/payments/bnpl-landscape.md— the provider mechanics: the SnappPay verb set (eligibility → token → verify → settle → revert/cancel/update), commission-as-config (anecdotal 7–15%; Torob Pay's published 6.6%; read the actual deducted amount from the settlement, never hardcode), settlement timing is NOT instant (daily/T+1–3/weekly/15-day, per-transactionsettled_at), Toman↔Rial conversion at the boundary, and the async ~7–10-business-day customer refund window.product/data-model/08-bnpl.md— the canonical schema forbnpl_transactions(every column + the state machine) and thebnpl_settlement_entriesDEFERRED note. Mirror these field names exactly.product/payments/escrow-ledger.md— the BNPL-settle ledger posting (card-capture legs PLUSDEBIT bnpl_fee_expense/CREDIT escrow_heldfor the commission, so escrow reflects net cash) and the refund/revert legs.- Code to mirror: b10's
Features/Payments/**command structure, theConfirmPaymentAndPostLedgerledger helper, thepayment_webhook_eventsupsert-first-then-mutate idempotency pattern, theIWebhookVerifierusage, and theIDistributedLocklock helper; b11'sFeatures/Refunds/**,refund_channel, and the refund ledger posting; b9's booking three-amount split; b1's typed config accessor. - Contract conventions:
../../contracts/conventions/api-conventions.mdandmoney-and-types.md(IRRBIGINTas a string on the wire, the envelope,refund_channelenum, Toman is display-only). - Prior handoffs:
dev/shared-working-context/backend/handoff/after-backend-phase-10.mdand…-11.md, andreports/mocks-registry.md(theIWebhookVerifier/IPaymentProvider/IDistributedLockrows you reuse, the new rows you add).
3. Scope — build this
All money is IRR long / BIGINT. Features live under
Baya.Application/Features/Bnpl/{Commands|Queries}/<Name>/; the entity in
Baya.Domain/Entities/Bnpl/; one IEntityTypeConfiguration<T> in Persistence/Configuration/BnplConfig/;
one EF migration for the single table.
3.1 Entity + migration
bnpl_transactions [MVP] — one row per BNPL order, 1:1 with its payment_transaction; the single
inbound settlement to reconcile, plus the revert path. (Replaces the deleted installment_plans; there is
nothing to amortize on our side.)
- Fields (mirror
product/data-model/08-bnpl.mdexactly):id(BIGINT PK).payment_transaction_id(BIGINT FK →payment_transactions)UNIQUE— the strict 1:1 guard.provider_code(NVARCHAR(50)) —snapppay|digipay|tara|torobpay(selects the provider impl).merchant_of_record(NVARCHAR(40)) — Balinyaar entity or partner center.external_payment_token(NVARCHAR(200)) — for verify/settle/revert; issued at initiate.external_transaction_id(NVARCHAR(200), nullable) — the provider's order/txn id.eligibility_status(NVARCHAR(30), nullable) — recorded by the eligibility check.order_amount_irr(BIGINT) — gross order = the booking'sgross_price_irr.settled_amount_irr(BIGINT, nullable) — net of provider commission actually received (set at settle).bnpl_commission_irr(BIGINT, nullable) — the provider's merchant discount = platform expense, set at settle.currency(NVARCHAR(5)) —IRR/TOMANat the boundary; normalized to IRR on the way in.installment_count(TINYINT, default 4) — informational only (owned by the provider).status(NVARCHAR(30)) — the state machine (see §3.2.0).settled_at(DATETIME2, nullable) — per-transaction, contract-defined (daily/T+1–3/weekly); never assume instant.revert_transaction_id(NVARCHAR(200), nullable),reverted_amount_irr(BIGINT, nullable),reverted_at(DATETIME2, nullable) — the reversal path.provider_commission_reversed_amount(BIGINT, nullable) — the provider's own commission reversal, reconciled from the provider response; do not hardcode (may be null/partial).refund_channel(NVARCHAR(20), nullable) —bnpl_reverton a reversal.callback_payload_json(NVARCHAR(MAX), nullable) — raw verify/settle/revert payload.- audit + soft-delete fields per conventions.
- Constraints / invariants:
payment_transaction_idUNIQUE (strict 1:1) — the structural one-BNPL-row-per-order guard.- State-machine guard on
status(forward-only; see §3.2.0) — illegal transitions are rejected; a replayedsettle/revertis a no-op, not a double-post. - Money invariant (handler, on settle):
settled_amount_irr = order_amount_irr − bnpl_commission_irr; all amounts ≥ 0.
- Relations: 1:1 →
payment_transactions; sharespayment_webhook_eventsfor callback idempotency; the revert creates arefundsrow (b11).
3.2 Status state machine & commands/queries (CQRS, OperationResult, never throw for expected failures)
3.2.0 The status state machine (the idempotency spine)
Define BnplStatus as a proper enum (persist as its stable string code):
eligible | token_issued | verified | settled | reverted | cancelled | failed.
Allowed forward transitions — enforce centrally (a TransitionTo guard on the entity / a small transition
table), reject anything else, and treat an already-in-target-state transition as an idempotent no-op:
eligible → token_issued | failed | cancelled
token_issued → verified | failed | cancelled
verified → settled | failed | reverted
settled → reverted
(any active) → cancelled (before settle)
A replayed callback that would re-drive a completed transition must not re-post the ledger — the guard
plus the payment_webhook_events dedup are the two backstops.
3.2.1 Capabilities
| Capability | Type | Route | What it does |
|---|---|---|---|
CheckBnplEligibilityQuery |
Query | POST api/v1/checkout_bnpl/eligibility |
Calls IBnplProvider.CheckEligibilityAsync(customerMobile, order_amount_irr, ct) for the chosen provider_code and records eligibility_status (and status='eligible') on a created/updated bnpl_transactions row tied to the booking's payment_transaction. Returns eligible/not_eligible/ceiling_exceeded + the plan summary (default 4 installments, "0% interest, provider-financed") so the client can show the plan or fall back to card. Amount comes from the booking's gross_price_irr. |
InitiateBnplOrderCommand |
Command | POST api/v1/checkout_bnpl/initiate |
Creates the bnpl_transactions row 1:1 with a payment_transaction (under the UNIQUE(payment_transaction_id) guard), normalizes order_amount_irr to IRR via ICurrencyNormalizer, calls IBnplProvider.CreatePaymentTokenAsync(...) to issue external_payment_token, transitions eligible → token_issued, and returns the token + provider redirect URL. Under lock(booking:{id}:payment) (reuse b10's lock). Carries an idempotencyKey. |
VerifyBnplOrderCommand |
Command | (driven by HandleBnplCallback, also POST api/v1/admin_bnpl/{id}/verify) |
Calls IBnplProvider.VerifyAsync(token, expected order_amount_irr, ct), re-checks amount + reference (never trust the callback alone), persists callback_payload_json, transitions token_issued → verified. Idempotent via the state guard. |
SettleBnplOrderCommand |
Command | (driven by HandleBnplCallback, also POST api/v1/admin_bnpl/{id}/settle) |
Calls IBnplProvider.SettleAsync(token, idempotencyKey, ct); records settled_amount_irr, bnpl_commission_irr, settled_at (nullable — read from the provider response, never assume now) from the actual settlement; posts the BNPL-settle ledger group (§5) — the card-capture legs plus DEBIT bnpl_fee_expense = bnpl_commission_irr / CREDIT escrow_held = bnpl_commission_irr so escrow reflects net cash — via b10's helper; transitions verified → settled and confirms the parent payment_transaction (succeeded, under b10's filtered-unique guard) which triggers the booking conversion. Under lock(booking:{id}:payment); carries an idempotencyKey. A replayed settle is a no-op (state guard + webhook dedup). |
RevertBnplOrderCommand |
Command | POST api/v1/admin_bnpl/{id}/revert |
Full reversal via the stored token: calls IBnplProvider.RevertAsync(token, idempotencyKey, ct) (partial/shortened-visit maps to UpdateAsync(newAmount strictly-lower)), writes revert_transaction_id, reverted_amount_irr, reverted_at, provider_commission_reversed_amount (from the provider response, nullable), sets refund_channel='bnpl_revert', creates a refunds row (b11) with refund_channel='bnpl_revert', external_revert_reference, expected_customer_refund_eta (~7–10 business days), posts the refund ledger legs (b11's helper — fee-leg + payout-leg decomposition; if the nurse was already paid, a clawback), and transitions … → reverted. Under lock(booking:{id}:refund). Money always flows customer ↔ provider ↔ Balinyaar — never direct-to-customer or nurse→customer. |
HandleBnplCallbackCommand |
Command | POST api/v1/webhooks_bnpl/{provider} |
The inbound provider-callback entry point. IWebhookVerifier (reuse, b10) validates signature + extracts (externalEventId, eventType, payload); upsert payment_webhook_events keyed UNIQUE(provider_code, external_event_id) FIRST, no-op on duplicate, inside the same DB transaction that mutates state; stores callback_payload_json; dispatches to VerifyBnplOrderCommand/SettleBnplOrderCommand/RevertBnplOrderCommand per eventType, all gated by the status state machine so a re-delivered callback never double-settles or double-posts. Rate-limited. |
GetBnplOrderStatusQuery |
Query | GET api/v1/admin_bnpl/{id} (+ tenancy-scoped customer view of their own order) |
Surfaces status, order_amount_irr, settled_amount_irr, bnpl_commission_irr, settlement timing (settled_at / the contract-defined class, "not instant"), and revert audit (reverted_amount_irr, external_revert_reference, expected_customer_refund_eta). Projected (AsNoTracking + .Select). |
- Controllers:
CheckoutBnplController(customer policy, tenancy-scoped, checkout endpoints rate-limited),WebhooksBnplController(anonymous but signature-verified + rate-limited), andAdminBnplController(admin policy, payout/refund-sensitive endpoints rate-limited). Allsealed : BaseController, injectISender, returnbase.OperationResult(...), snake_case[controller]/[action]routes,CancellationTokenthreaded. - Validators: FluentValidation on
InitiateBnplOrderCommand(validprovider_code, positive amount, booking inpending_payment) and the id-bearing commands;RevertBnplOrderCommandvalidates a partial/update amount is strictly lower than the settled amount.
3.3 DEFERRED (build the seam/flag, not the feature)
bnpl_settlement_entries— tranched-settlement child rows, only needed if a future provider pays the platform over time. Modeled-but-inactive: do not build the table. Note in the report that adding it later is a purely additive migration. (Refproduct/data-model/08-bnpl.md.)- Customer installment tracking (
installment_entries/installment_plans) — cut entirely; the provider owns the schedule and 100% default risk. Never reintroduce.installment_countis informational only. - Multiple-provider BNPL routing / failover — DEFERRED; this phase ships the mock with one impl per
provider_codeand config-driven selection, but the active route is a single provider. Note in the report. - The BNPL
settled_at-gates-payout coupling lives in b13 (therequire_bnpl_settlement_for_payoutconfig flag) — do not couple payout to BNPL settlement here; just recordsettled_atfaithfully.
4. Mocks & seams in this phase
| Seam | Owner | Mock behaviour | Registry |
|---|---|---|---|
IBnplProvider |
introduced here | The SnappPay-superset verb set: CheckEligibilityAsync (always eligible), CreatePaymentTokenAsync (fixed deterministic external_payment_token + redirect URL), VerifyAsync (instant verified, echoes amount), SettleAsync (instant settled: returns settledAmountIrr = order − commission, bnplCommissionIrr from a configurable mock commission %, settledAt = now), RevertAsync/UpdateAsync/CancelAsync (echo amounts, drive the reversal), GetStatusAsync. Drives the full eligible → token_issued → verified → settled → reverted/cancelled state machine with no network. One impl per provider_code (snapppay/digipay/tara/torobpay), selected by config / a provider_code-keyed resolver. |
add a new row (🟡) |
ICurrencyNormalizer |
introduced here | Toman↔IRR at the boundary: mock multiplies Toman ×10 → IRR (and back for display). Config-driven. Conversion happens ONLY here, at the provider boundary — never internally. | add a new row (🟡) |
IWebhookVerifier |
reuse from b10 | signature valid=true, extracts a test externalEventId/eventType from the body; lets tests replay duplicate callbacks to prove idempotency. |
reuse row |
IDistributedLock |
reuse from b10 | in-memory mock lock; lock(booking:{id}:payment) on initiate/verify/settle, lock(booking:{id}:refund) on revert. |
reuse row |
IFieldEncryptor |
reuse from b0 | local symmetric key; for any PII echoed in the callback payload — never log plaintext. | reuse row |
ICacheService |
reuse from b0/b1 | in-memory; behind the typed config accessor (commission %, currency, timing class). | reuse row |
The mocks live behind DI-registered interfaces in Infrastructure (real impl is a drop-in later); a real
SnappPayBnplProvider / DigipayBnplProvider selection is config-driven, never an if (mock) branch
in a handler. Append the IBnplProvider and ICurrencyNormalizer rows to
../../shared-working-context/reports/mocks-registry.md
(seam, file, what's faked, config keys, step-by-step how to make it real — for IBnplProvider:
SnappPay OAuth api/online/v1/oauth/token + offer/v1/eligible + payment/v1/token|verify|settle|revert| cancel|update|status, or Digipay UPG tickets/business?type=13 + purchases/verify +
purchases/deliver?type=13 + refunds/reverse; credentials from the encrypted payment_gateways.config_json;
Toman↔Rial conversion; per-contract commission read from the settle response; warn: do not use the
unrelated Canadian SnapPayInc/open-api-java-sdk).
5. Critical rules you must not get wrong
Money correctness is sacred — the following must hold verbatim:
- Money is IRR
BIGINT, no floats, ever. Every amount (order_amount_irr,settled_amount_irr,bnpl_commission_irr,reverted_amount_irr,provider_commission_reversed_amount) islong/BIGINT. No float path. Currency is normalized to IRR at the provider boundary (ICurrencyNormalizer) — the provider speaks Toman; conversion happens only in the adapter, never internally. - A BNPL order is, in our books, a card payment landing net-of-fee. Do NOT model the customer's
repayment schedule or default risk — the provider owns the installments and 100% default risk; the
installment_entriessubsystem was deleted.installment_countis informational only. bnpl_commission_irris the provider's merchant discount = a PLATFORM EXPENSE (thebnpl_fee_expenseleg) and NEVER touches the nurse's payout. The settle ledger reflects NET cash — escrow showssettled_amount_irr, notorder_amount_irr.- The nurse's payout is invariant to payment method — computed from
gross_price_irr − balinyaar_commission_irr(the booking split), never fromsettled_amount_irr. (b13 pays the identical amount whether the family paid by card or BNPL.) - The settle ledger group (balanced, append-only, one
transaction_group_id, Σdebit = Σcredit) — the card-capture legs plus the provider-fee leg, posted once via b10's helper:Never UPDATE/DELETE a ledger row; corrections are new balancing postings.DEBIT escrow_held order_amount_irr (= gross_price_irr) CREDIT platform_revenue balinyaar_commission_irr CREDIT nurse_payable nurse_payout_amount DEBIT bnpl_fee_expense bnpl_commission_irr CREDIT escrow_held bnpl_commission_irr (escrow reflects NET cash received) settled_amount_irr = order_amount_irr − bnpl_commission_irr, and the commission + settlement timing are read from the actual settlement record, never hardcoded.settled_atis per-transaction and contract-defined (daily/T+1–3/weekly) — never assume instant. Model it nullable; "full amount" does not mean "instant cash." Do not let b13 assume BNPL cash funds a payout (payout is decoupled).- Idempotency: every callback upserts
payment_webhook_events(UNIQUE(provider_code, external_event_id)) first, inside the money-mutating DB transaction, and no-ops on duplicate; the status state machine is forward-only so a replayed settle must not double-count or double-post the ledger, and a replayed revert must not double-refund. Redislock(booking:{id}:payment)/lock(booking:{id}:refund)is the fast first line; the webhook UNIQUE + state machine are the authoritative backstop. - Strict 1:1:
bnpl_transactions.payment_transaction_idis UNIQUE — exactly one BNPL row per order. Do not drop it. - Refund routing: BNPL refunds flow only customer ↔ provider ↔ Balinyaar via
RevertAsync(full) /UpdateAsync(partial, strictly lower amount) using the stored token — never nurse→customer or Balinyaar→customer directly. The refund still decomposes across the platform-fee and nurse-payout legs in the ledger (b11),refund_channel='bnpl_revert', and the customer's cash-back is async ~7–10 business days (surfaceexpected_customer_refund_eta). - Escrow is a ledger, not a status flag — every BNPL inbound/reversal is double-entry
ledger_entries. - Never trust the callback alone —
SettleBnplOrderCommand/VerifyBnplOrderCommandre-check amount + reference server-side against the storedorder_amount_irrbefore posting money. - Tenancy: the customer view of
GetBnplOrderStatusQueryis scoped toICurrentUser; a customer can never read another's BNPL order. Admin/webhook endpoints sit behind their policies and are rate-limited.
6. Definition of Done
The shared definition-of-done.md, plus:
bnpl_transactionsexists via one migration, with itsIEntityTypeConfiguration<T>, theUNIQUE(payment_transaction_id)1:1 guard, theBnplStatusstate-machine enum + central transition guard, thesettled_amount_irr = order_amount_irr − bnpl_commission_irrinvariant, nullablesettled_at/provider_commission_reversed_amount, and soft-delete/audit wiring per conventions.- All §3.2 commands/queries implemented (CQRS,
OperationResult, projected + paginated reads, validators), withCheckoutBnplController+WebhooksBnplController+AdminBnplController. IBnplProvider(one impl perprovider_code) andICurrencyNormalizerintroduced (Application interfaces, Infrastructure mocks, DI registration via aServiceConfiguration/extension, config-selected). Noif (mock)in handlers.- The settle posts the net-of-fee ledger group including the
bnpl_fee_expenseleg via b10's helper; a replayed settle webhook is a no-op (webhook dedup + state guard); the revert posts the reversal via b11's helper withrefund_channel='bnpl_revert'. - The
nurse_payableaccrual equals the card-path amount (payout invariant to method) — covered by a test that settles a BNPL order and assertsnurse_payablematches the card-capture path. - Handler unit tests (NSubstitute) for eligibility, the initiate→verify→settle posting (incl. the
bnpl_fee_expenseleg and the payout-invariance assertion), the replayed-settle no-op, the revert/reversal posting, and the strict-1:1 + state-machine guards; ≥1WebApplicationFactoryintegration test per controller (happy path, 401/403, validation 400).dotnet build Baya.slnzero new warnings;dotnet test Baya.slngreen. - The
Baya.Application/Features/Bnpl/**area is reflected in the Project map inserver/CLAUDE.md; theIBnplProvider+ICurrencyNormalizerseams noted where seams are documented. - The contract
dev/contracts/domains/bnpl.mdwritten and theswagger.jsonsnapshot republished.
7. How to test (what a human can verify after this phase)
Seed (or reuse from prior phases) a pending_payment booking with a known three-amount split
(gross_price_irr, balinyaar_commission_irr, nurse_payout_amount) and a payment_gateways row with
type='bnpl', provider_code='snapppay'. Set the mock commission % (config) to a known value (e.g. 10%).
- Eligibility —
POST api/v1/checkout_bnpl/eligibilityfor the booking →eligiblewith the plan summary (4 installments, 0% interest, provider-financed); abnpl_transactionsrow exists witheligibility_statusset andstatus='eligible'. - Initiate —
POST api/v1/checkout_bnpl/initiate→status='token_issued', a deterministicexternal_payment_token+ redirect URL returned; the row is 1:1 with thepayment_transaction; a second initiate for the samepayment_transactionis rejected by theUNIQUEguard. - Verify → settle (the ledger) — drive the callback
POST api/v1/webhooks_bnpl/snapppay(or the admin settle) →statuswalksverified → settled;settled_amount_irr = order_amount_irr − bnpl_commission_irr(e.g. 10% commission),bnpl_commission_irrandsettled_atrecorded; the ledger shows the balanced group:DEBIT escrow_heldgross /CREDIT platform_revenuecommission +CREDIT nurse_payablepayout plusDEBIT bnpl_fee_expensecommission /CREDIT escrow_heldcommission — so the netescrow_heldequalssettled_amount_irr. - Payout invariance — assert the
nurse_payablecredited equalsgross_price_irr − balinyaar_commission_irr, i.e. identical to the card path and independent ofsettled_amount_irr/ the BNPL commission. - Replayed settle is a no-op — re-deliver the same settle callback (same
external_event_id) → thepayment_webhook_eventsdedup + the state guard reject it; no second ledger group, balances unchanged. - Revert —
POST api/v1/admin_bnpl/{id}/revert→status='reverted',reverted_amount_irr/revert_transaction_id/reverted_atset; arefundsrow appears withrefund_channel='bnpl_revert',external_revert_reference, andexpected_customer_refund_eta(~7–10 business days); the reversal ledger legs post (fee-leg + payout-leg; clawback if the nurse was already paid). - Status —
GET api/v1/admin_bnpl/{id}→ surfaces settlement amount/commission, the non-instantsettled_at, and the revert audit; the customer can read only their own order (another customer's is 403/not visible).
8. Hand off & document (close the phase)
- Docs to update: the Project map in
server/CLAUDE.md(add theFeatures/Bnpl/**area + theIBnplProvider/ICurrencyNormalizerseams); if you discover/confirm a rule the product docs don't capture (e.g. the mock commission % config key, theprovider_code-keyed resolver, the exact transition table), record it inproduct/business/09-installments-bnpl.mdorproduct/data-model/08-bnpl.md— don't invent rules. - Contract to write:
dev/contracts/domains/bnpl.md(per../../contracts/domains/_TEMPLATE.md) — the checkout endpoints (eligibility, initiate), the webhook endpoint, the admin verify/settle/revert/status endpoints; theBnplStatusandrefund_channelenums; thebnpl_transactionsDTO shape (IRRBIGINTas a string, nullablesettled_at, the revert fields); auth/rate-limit/idempotency notes; the net-of-fee settle and the customer ↔ provider ↔ Balinyaar refund routing as documented side effects; the async refund-ETA copy. Republish theswagger.jsonsnapshot per../../contracts/openapi/README.md. This is what f11-b12 consumes. - Handoff & report: write
dev/shared-working-context/backend/handoff/after-backend-phase-12.md(BNPL checkout is live, what f11 can now build — the "pay with installments" option, eligibility/plan states, provider handoff, declined→fall-back-to-card, the admin BNPL revert path with the ~7–10-day ETA — which endpoints/contracts are live, that the provider + currency are mocked behindIBnplProvider/ICurrencyNormalizer), append tobackend/STATUS.md, writedev/shared-working-context/reports/backend-phase-12-report.md(what was built, what is now testable and exactly how per §7, what is mocked + how to make it real, contracts produced/consumed, follow-ups: tranched settlementbnpl_settlement_entries, multi-provider routing, the b13settled_atpayout guard), and updatedev/shared-working-context/reports/mocks-registry.md(theIBnplProvider+ICurrencyNormalizerrows → 🟡). - Memory: save a
projectmemory note for the non-obvious decisions this phase fixes — a BNPL order is a net-of-fee card payment (no installment tracking), thebnpl_fee_expensesettle leg so escrow shows net cash, the payout-invariant-to-method rule, the forward-only state machine + webhook dedup idempotency, the strict 1:1payment_transaction_idUNIQUE, the customer↔provider↔Balinyaar revert routing, and theIBnplProvider(per-provider_code) +ICurrencyNormalizerseams — with a one-line pointer inMEMORY.md.