Files
baya-monorepo/dev/phases/backend/backend-phase-12.md
T
2026-06-28 21:59:59 +03:30

33 KiB
Raw Blame History

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_transactions row (1:1 with its payment_transaction) that drives an idempotent eligible → token_issued → verified → settled state machine, a settle that posts the card-capture ledger legs plus a bnpl_fee_expense leg 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 (refunds 1: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 idempotencyb10 built ledger_entries (append-only, balanced, transaction_group_id, the six account_types incl. escrow_held, platform_revenue, nurse_payable, refund_payable, bnpl_fee_expense, nurse_clawback_receivable), payment_transactions (filtered UNIQUE(gateway_reference_code) WHERE NOT NULL and UNIQUE(booking_id) WHERE status='succeeded'), payment_webhook_events (UNIQUE(provider_code, external_event_id) — the idempotency anchor), the card-capture ledger posting (DEBIT escrow_held gross / CREDIT platform_revenue commission + CREDIT nurse_payable payout), the IWebhookVerifier seam, and the IDistributedLock Redis-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, and IWebhookVerifier — do not re-implement any of them.
  • The card-capture posting structure — b10's ConfirmPaymentAndPostLedger posts the card-capture group. The BNPL settle is that same group PLUS a bnpl_fee_expense leg — extend/reuse the helper, do not fork it.
  • Refundsb11 built refunds (1:N per payment_transaction, fee-leg vs payout-leg decomposition, refund_channelpsp_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 a refund row with refund_channel='bnpl_revert' and posts the refund ledger legs via b11's helper — it does not redefine refunds.
  • Bookings & the three-amount splitb9's bookings carry gross_price_irr = balinyaar_commission_irr + nurse_payout_amount and platform_fee_rate. The BNPL order_amount_irr is the booking's gross_price_irr; the nurse's payout is computed from the booking split, never from settled_amount_irr.
  • payment_gatewaysb10's per-provider config (encrypted config_json, type selects flow). BNPL providers are rows with type='bnpl'; provider selection is config-driven.
  • The platform config accessorb1's typed, cached platform_configs reader. Read the mock commission %, settlement-timing class, and currency through it; never hardcode.
  • The b0 foundation: REST surface, BaseController, OperationResult<T>, CQRS via martinothamar/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.md and ../_shared/backend-conventions-checklist.md — especially the Performance, caching, money, idempotency block (IRR BIGINT, append-only balanced ledger, idempotent money writes, webhook dedup, Redis lock on the money path).
  • product/business/09-installments-bnpl.mdthe 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.mdthe provider mechanics: the SnappPay verb set (eligibility → token → verify → settle → revert/cancel/update), commission-as-config (anecdotal 715%; Torob Pay's published 6.6%; read the actual deducted amount from the settlement, never hardcode), settlement timing is NOT instant (daily/T+13/weekly/15-day, per-transaction settled_at), Toman↔Rial conversion at the boundary, and the async ~710-business-day customer refund window.
  • product/data-model/08-bnpl.mdthe canonical schema for bnpl_transactions (every column + the state machine) and the bnpl_settlement_entries DEFERRED note. Mirror these field names exactly.
  • product/payments/escrow-ledger.md — the BNPL-settle ledger posting (card-capture legs PLUS DEBIT bnpl_fee_expense / CREDIT escrow_held for the commission, so escrow reflects net cash) and the refund/revert legs.
  • Code to mirror: b10's Features/Payments/** command structure, the ConfirmPaymentAndPostLedger ledger helper, the payment_webhook_events upsert-first-then-mutate idempotency pattern, the IWebhookVerifier usage, and the IDistributedLock lock helper; b11's Features/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.md and money-and-types.md (IRR BIGINT as a string on the wire, the envelope, refund_channel enum, Toman is display-only).
  • Prior handoffs: dev/shared-working-context/backend/handoff/after-backend-phase-10.md and …-11.md, and reports/mocks-registry.md (the IWebhookVerifier/IPaymentProvider/IDistributedLock rows 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.md exactly):
    • 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's gross_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/TOMAN at 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+13/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_revert on a reversal.
    • callback_payload_json (NVARCHAR(MAX), nullable) — raw verify/settle/revert payload.
    • audit + soft-delete fields per conventions.
  • Constraints / invariants:
    • payment_transaction_id UNIQUE (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 replayed settle/revert is 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; shares payment_webhook_events for callback idempotency; the revert creates a refunds row (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 (~710 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), and AdminBnplController (admin policy, payout/refund-sensitive endpoints rate-limited). All sealed : BaseController, inject ISender, return base.OperationResult(...), snake_case [controller]/[action] routes, CancellationToken threaded.
  • Validators: FluentValidation on InitiateBnplOrderCommand (valid provider_code, positive amount, booking in pending_payment) and the id-bearing commands; RevertBnplOrderCommand validates 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. (Ref product/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_count is informational only.
  • Multiple-provider BNPL routing / failover — DEFERRED; this phase ships the mock with one impl per provider_code and 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 (the require_bnpl_settlement_for_payout config flag) — do not couple payout to BNPL settlement here; just record settled_at faithfully.

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) is long/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_entries subsystem was deleted. installment_count is informational only.
  • bnpl_commission_irr is the provider's merchant discount = a PLATFORM EXPENSE (the bnpl_fee_expense leg) and NEVER touches the nurse's payout. The settle ledger reflects NET cash — escrow shows settled_amount_irr, not order_amount_irr.
  • The nurse's payout is invariant to payment method — computed from gross_price_irr balinyaar_commission_irr (the booking split), never from settled_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:
    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)
    
    Never UPDATE/DELETE a ledger row; corrections are new balancing postings.
  • settled_amount_irr = order_amount_irr bnpl_commission_irr, and the commission + settlement timing are read from the actual settlement record, never hardcoded.
  • settled_at is per-transaction and contract-defined (daily/T+13/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. Redis lock(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_id is 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 tokennever 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 ~710 business days (surface expected_customer_refund_eta).
  • Escrow is a ledger, not a status flag — every BNPL inbound/reversal is double-entry ledger_entries.
  • Never trust the callback aloneSettleBnplOrderCommand/VerifyBnplOrderCommand re-check amount + reference server-side against the stored order_amount_irr before posting money.
  • Tenancy: the customer view of GetBnplOrderStatusQuery is scoped to ICurrentUser; 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_transactions exists via one migration, with its IEntityTypeConfiguration<T>, the UNIQUE(payment_transaction_id) 1:1 guard, the BnplStatus state-machine enum + central transition guard, the settled_amount_irr = order_amount_irr bnpl_commission_irr invariant, nullable settled_at/provider_commission_reversed_amount, and soft-delete/audit wiring per conventions.
  • All §3.2 commands/queries implemented (CQRS, OperationResult, projected + paginated reads, validators), with CheckoutBnplController + WebhooksBnplController + AdminBnplController.
  • IBnplProvider (one impl per provider_code) and ICurrencyNormalizer introduced (Application interfaces, Infrastructure mocks, DI registration via a ServiceConfiguration/ extension, config-selected). No if (mock) in handlers.
  • The settle posts the net-of-fee ledger group including the bnpl_fee_expense leg 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 with refund_channel='bnpl_revert'.
  • The nurse_payable accrual equals the card-path amount (payout invariant to method) — covered by a test that settles a BNPL order and asserts nurse_payable matches the card-capture path.
  • Handler unit tests (NSubstitute) for eligibility, the initiate→verify→settle posting (incl. the bnpl_fee_expense leg and the payout-invariance assertion), the replayed-settle no-op, the revert/reversal posting, and the strict-1:1 + state-machine guards; ≥1 WebApplicationFactory integration test per controller (happy path, 401/403, validation 400). dotnet build Baya.sln zero new warnings; dotnet test Baya.sln green.
  • The Baya.Application/Features/Bnpl/** area is reflected in the Project map in server/CLAUDE.md; the IBnplProvider + ICurrencyNormalizer seams noted where seams are documented.
  • The contract dev/contracts/domains/bnpl.md written and the swagger.json snapshot 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%).

  1. EligibilityPOST api/v1/checkout_bnpl/eligibility for the booking → eligible with the plan summary (4 installments, 0% interest, provider-financed); a bnpl_transactions row exists with eligibility_status set and status='eligible'.
  2. InitiatePOST api/v1/checkout_bnpl/initiatestatus='token_issued', a deterministic external_payment_token + redirect URL returned; the row is 1:1 with the payment_transaction; a second initiate for the same payment_transaction is rejected by the UNIQUE guard.
  3. Verify → settle (the ledger) — drive the callback POST api/v1/webhooks_bnpl/snapppay (or the admin settle) → status walks verified → settled; settled_amount_irr = order_amount_irr bnpl_commission_irr (e.g. 10% commission), bnpl_commission_irr and settled_at recorded; the ledger shows the balanced group: DEBIT escrow_held gross / CREDIT platform_revenue commission + CREDIT nurse_payable payout plus DEBIT bnpl_fee_expense commission / CREDIT escrow_held commission — so the net escrow_held equals settled_amount_irr.
  4. Payout invariance — assert the nurse_payable credited equals gross_price_irr balinyaar_commission_irr, i.e. identical to the card path and independent of settled_amount_irr / the BNPL commission.
  5. Replayed settle is a no-op — re-deliver the same settle callback (same external_event_id) → the payment_webhook_events dedup + the state guard reject it; no second ledger group, balances unchanged.
  6. RevertPOST api/v1/admin_bnpl/{id}/revertstatus='reverted', reverted_amount_irr/ revert_transaction_id/reverted_at set; a refunds row appears with refund_channel='bnpl_revert', external_revert_reference, and expected_customer_refund_eta (~710 business days); the reversal ledger legs post (fee-leg + payout-leg; clawback if the nurse was already paid).
  7. StatusGET api/v1/admin_bnpl/{id} → surfaces settlement amount/commission, the non-instant settled_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 the Features/Bnpl/** area + the IBnplProvider / ICurrencyNormalizer seams); if you discover/confirm a rule the product docs don't capture (e.g. the mock commission % config key, the provider_code-keyed resolver, the exact transition table), record it in product/business/09-installments-bnpl.md or product/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; the BnplStatus and refund_channel enums; the bnpl_transactions DTO shape (IRR BIGINT as a string, nullable settled_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 the swagger.json snapshot 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 ~710-day ETA — which endpoints/contracts are live, that the provider + currency are mocked behind IBnplProvider / ICurrencyNormalizer), append to backend/STATUS.md, write dev/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 settlement bnpl_settlement_entries, multi-provider routing, the b13 settled_at payout guard), and update dev/shared-working-context/reports/mocks-registry.md (the IBnplProvider + ICurrencyNormalizer rows → 🟡).
  • Memory: save a project memory note for the non-obvious decisions this phase fixes — a BNPL order is a net-of-fee card payment (no installment tracking), the bnpl_fee_expense settle leg so escrow shows net cash, the payout-invariant-to-method rule, the forward-only state machine + webhook dedup idempotency, the strict 1:1 payment_transaction_id UNIQUE, the customer↔provider↔Balinyaar revert routing, and the IBnplProvider (per-provider_code) + ICurrencyNormalizer seams — with a one-line pointer in MEMORY.md.