# Backend phase 12 — BNPL: provider-financed installments (mocked) — report **Mission:** let a family pay for a booking with a provider-financed BNPL plan, and record it correctly — a BNPL order is, in Balinyaar's books, **a card payment that lands net-of-fee**. ## What was built ### Domain (`Baya.Domain/Entities/Bnpl/`) - **`BnplTransaction`** — one row per order, **1:1 with its `payment_transaction`**. Guarded `Status` mutated only through cohesive `MarkTokenIssued`/`MarkVerified`/`MarkSettled`/`MarkReverted`/`MarkCancelled`/`MarkFailed`; `MarkSettled` enforces `settled = order − commission` (≥0). Money is IRR `long`; `settled_at`, `provider_commission_reversed_amount`, and all settle/revert amounts are nullable. - **`BnplStatus`** + **`BnplTransitions`** — the forward-only state machine (`eligible → token_issued → verified → settled → reverted/cancelled/failed`), the idempotency spine. - **`BnplEligibilityStatus`** (`eligible`/`not_eligible`/`ceiling_exceeded`), **`BnplProviderCodes`** (`snapppay`/`digipay`/`tara`/`torobpay`). - **`LedgerPosting.BnplSettle`** — the net-of-fee group (card-capture legs **plus** `DEBIT bnpl_fee_expense / CREDIT escrow_held`), one balanced `transaction_group_id`, `SourceRefType = bnpl_transaction`. Throws if the capture legs don't reconcile. ### Application (`Baya.Application/Features/Bnpl/`) - Queries: **`CheckBnplEligibilityQuery`** (records `eligibility_status` on a created/updated row), **`GetBnplOrderStatusQuery`** (admin/customer, tenancy-scoped). - Commands: **`InitiateBnplOrderCommand`** (token + `eligible → token_issued`, under `lock(booking-request:{id}:payment)`, idempotency-keyed), **`VerifyBnplOrderCommand`**, **`SettleBnplOrderCommand`** (net-of-fee ledger + booking conversion, under `lock(bnpl:{id}:settle)`), **`RevertBnplOrderCommand`** (reuses b11 `CreateRefundCommand`), **`HandleBnplCallbackCommand`** (webhook dedup + dispatch by event type). - **`BnplOrderInitializer`** (shared find-or-create of the pending `payment_transaction` + 1:1 BNPL row) and the extracted **`BookingConversion`** helper (shared by b10 card capture + b12 settle — b10 was refactored to use it). - New seams in `Contracts/Payments/`: **`IBnplProvider`** (full verb set, supersedes the b11 revert-only stub), **`IBnplProviderResolver`**, **`ICurrencyNormalizer`**; `IBnplRepository` on `IUnitOfWork`. ### Infrastructure - **`Persistence`**: `BnplTransactionConfig` (`payments.BnplTransactions`, `UNIQUE(payment_transaction_id)`, `CK_BnplTransactions_SettleSplit`, filtered token index), `BnplRepository`, `UnitOfWork` wiring, one migration **`BnplTransactions`**. `IRefundRepository.GetExternalRevertReferenceAsync` added for the revert audit. - **`CrossCutting/Seams`**: `MockBnplProvider` (deterministic full state machine), `MockBnplProviderResolver`, `MockCurrencyNormalizer`; `SeamOptions` extended (`BnplOptions` + `CurrencyOptions`); DI registration in `AddCrossCuttingSeams`. - **`API`**: `CheckoutBnplController`, `WebhooksBnplController`, `AdminBnplController`. ## What is now testable and exactly how (per phase §7) Seed a `pending_payment`/accepted booking request with a known three-amount split and a `payment_gateways` row `type='bnpl', provider_code='snapppay'`; mock commission % via `Seams:Bnpl:CommissionRate` (default 10%). 1. **Eligibility** — `POST api/v1/checkout_bnpl/eligibility` → `eligible` + plan summary; a `bnpl_transactions` row exists with `eligibility_status` set and `status='eligible'`. *(Covered: `BnplHandlerTests.Eligibility_creates_row_eligible_and_returns_plan`, `BnplApiTests` eligibility.)* 2. **Initiate** — `POST api/v1/checkout_bnpl/initiate` → `status='token_issued'`, deterministic token + redirect; 1:1 with the `payment_transaction`; a second initiate reuses the same row. *(`BnplHandlerTests.Initiate_issues_token_and_is_strictly_one_to_one`.)* 3. **Verify → settle (the ledger)** — drive `POST api/v1/webhooks_bnpl/snapppay` (`order.verified` then `order.settled`) or the admin settle → `verified → settled`; `settled_amount = order − commission`, ledger shows the balanced net-of-fee group and net `escrow_held = settled_amount`. *(`BnplHandlerTests.Settle_posts_net_of_fee_group_…`, `BnplApiTests.Full_flow_…`, `BnplLedgerAndStateTests`.)* 4. **Payout invariance** — `nurse_payable` credited = `gross − balinyaar_commission`, **identical to the card path** and independent of the BNPL commission. *(Asserted in both the handler + api full-flow tests, compared against `LedgerPosting.CardCapture`.)* 5. **Replayed settle is a no-op** — re-deliver the same settle callback → webhook dedup + state guard reject it; no second ledger group. *(`BnplApiTests.Replayed_settle_callback_is_idempotent_no_second_ledger`, `BnplHandlerTests.Replayed_settle_is_a_noop_…`.)* 6. **Revert** — `POST api/v1/admin_bnpl/{id}/revert` → `status='reverted'`, revert audit set; a `refunds` row with `refund_channel='bnpl_revert'` + `expected_customer_refund_eta`; reversal ledger posts. *(`BnplApiTests.Admin_revert_reverses_a_settled_order_…`.)* 7. **Status** — `GET api/v1/admin_bnpl/{id}` (admin) / `GET api/v1/checkout_bnpl/{id}` (customer, own only). 8. **Guards** — settle-before-verify is a 409; the state machine rejects illegal edges. *(`BnplHandlerTests.Settle_before_verify_…`, `BnplLedgerAndStateTests.State_machine_…`.)* Full suite: **314 pass** (4 identity + 214 foundation + 96 api). Build clean, 0 new code warnings. ## What is mocked + how to make it real - **`IBnplProvider`** (per `provider_code` via **`IBnplProviderResolver`**) — deterministic mock, no network; settle returns `order − round(order × Seams:Bnpl:CommissionRate)` with the commission read from the response. Real: one adapter per code — **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`; creds from the encrypted `payment_gateways.config_json`; per-contract commission read from the settle response. **Do not use the unrelated Canadian `SnapPayInc/open-api-java-sdk`.** - **`ICurrencyNormalizer`** — mock ×10 Toman→IRR (`Seams:Currency:TomanToIrrMultiplier`). Real: read the multiplier/unit from provider config at the boundary. - **`settled_at` is nullable / non-instant** (`Seams:Bnpl:SettlementInstant`) — the mock models the daily/T+1–3/weekly reality. **b13 must not assume BNPL cash funds a payout.** ## Contracts produced / consumed - **Produced:** `dev/contracts/domains/bnpl.md`; `dev/contracts/openapi/swagger.v1.json` refreshed (386K → 411K, all BNPL paths present). - **Consumed:** b10 `payment_transactions`/`ledger_entries`/`payment_webhook_events`/`IWebhookVerifier`/ `IDistributedLock`/`LedgerPosting`; b11 `refunds`/`CreateRefundCommand`/`refund_channel`; b9 booking split + `BookingFactory`; b1 typed config accessor. ## Follow-ups - **b13:** the `settled_at`-gates-payout coupling — add `require_bnpl_settlement_for_payout` and gate a BNPL booking's payout on `bnpl_transactions.settled_at`. - **DEFERRED:** `bnpl_settlement_entries` (tranched settlement — modeled-but-not-built, additive migration later); multi-provider routing/failover (one active route today); `provider_commission_reversed_amount` reconciliation on revert (left null when the b11 refund path drives it). - The `BnplTransactions` migration was **not applied to a live DB** this session (no reachable SQL Server in the agent env); apply with `dotnet ef database update` before the next live run, and seed a `type='bnpl'` gateway.