Files
2026-06-28 21:59:59 +03:30

362 lines
33 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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`](../_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 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.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}/<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`](../../../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`](../../../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+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 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 ~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 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<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. **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` (~710 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 ~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`.