add build development phases
This commit is contained in:
@@ -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 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}/<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+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<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` (~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`.
|
||||
Reference in New Issue
Block a user