frontend phase 5 & backend phase 12

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