add build development phases

This commit is contained in:
hamid
2026-06-28 21:59:59 +03:30
parent 1df3cd9f64
commit 53a40dc51d
52 changed files with 12379 additions and 0 deletions
+361
View File
@@ -0,0 +1,361 @@
# 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`.