# 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](./backend-phase-10.md) (`payment_transactions`, `ledger_entries`, `payment_webhook_events`, the card-capture posting, `IWebhookVerifier`, `IDistributedLock`), [b11](./backend-phase-11.md) (`refunds` 1:N, fee/payout decomposition, `refund_channel`) · **Unlocks:** BNPL checkout; frontend **f11-b12** > **Before you start, read [`../_shared/agent-operating-rules.md`](../_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`](../../../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](./backend-phase-10.md) built `ledger_entries` (append-only, balanced, `transaction_group_id`, the six `account_type`s 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. - **Refunds** — [b11](./backend-phase-11.md) built `refunds` (1:N per `payment_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 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 split** — [b9](./backend-phase-9.md)'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_gateways`** — [b10](./backend-phase-10.md)'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 accessor** — [b1](./backend-phase-1.md)'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`, 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`](../_shared/agent-operating-rules.md) and [`../_shared/backend-conventions-checklist.md`](../_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.md`](../../../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`](../../../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-transaction `settled_at`), Toman↔Rial conversion at the boundary, and the async ~7–10-business-day customer refund window. - [`product/data-model/08-bnpl.md`](../../../product/data-model/08-bnpl.md) — **the 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`](../../../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`](../../contracts/conventions/api-conventions.md) and [`money-and-types.md`](../../contracts/conventions/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}//`; the entity in `Baya.Domain/Entities/Bnpl/`; one `IEntityTypeConfiguration` 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`](../../../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+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_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` (~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), 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`](../../../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`](../../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+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. 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 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** (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 alone** — `SettleBnplOrderCommand`/`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](../_shared/definition-of-done.md), plus: - [ ] `bnpl_transactions` exists via one migration, with its `IEntityTypeConfiguration`, 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. **Eligibility** — `POST 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. **Initiate** — `POST api/v1/checkout_bnpl/initiate` → `status='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. **Revert** — `POST api/v1/admin_bnpl/{id}/revert` → `status='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` (~7–10 business days); the **reversal ledger legs** post (fee-leg + payout-leg; clawback if the nurse was already paid). 7. **Status** — `GET 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`](../../../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`](../../../product/business/09-installments-bnpl.md) or [`product/data-model/08-bnpl.md`](../../../product/data-model/08-bnpl.md) — don't invent rules. - **Contract to write:** **`dev/contracts/domains/bnpl.md`** (per [`../../contracts/domains/_TEMPLATE.md`](../../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`](../../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 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`.