# 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.
