cleanup phases 6
This commit is contained in:
@@ -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+1–3/weekly reality. **b13 must not assume BNPL cash funds a payout.**
|
||||
|
||||
## Contracts produced / consumed
|
||||
- **Produced:** `dev/contracts/domains/bnpl.md`; `dev/contracts/openapi/swagger.v1.json` refreshed
|
||||
(386K → 411K, all BNPL paths present).
|
||||
- **Consumed:** b10 `payment_transactions`/`ledger_entries`/`payment_webhook_events`/`IWebhookVerifier`/
|
||||
`IDistributedLock`/`LedgerPosting`; b11 `refunds`/`CreateRefundCommand`/`refund_channel`; b9 booking split +
|
||||
`BookingFactory`; b1 typed config accessor.
|
||||
|
||||
## Follow-ups
|
||||
- **b13:** the `settled_at`-gates-payout coupling — add `require_bnpl_settlement_for_payout` and gate a BNPL
|
||||
booking's payout on `bnpl_transactions.settled_at`.
|
||||
- **DEFERRED:** `bnpl_settlement_entries` (tranched settlement — modeled-but-not-built, additive migration later);
|
||||
multi-provider routing/failover (one active route today); `provider_commission_reversed_amount` reconciliation
|
||||
on revert (left null when the b11 refund path drives it).
|
||||
- The `BnplTransactions` migration was **not applied to a live DB** this session (no reachable SQL Server in the
|
||||
agent env); apply with `dotnet ef database update` before the next live run, and seed a `type='bnpl'` gateway.
|
||||
Reference in New Issue
Block a user