# Backend Phase 10 report — Payments core: ledger, transactions, webhooks & card capture ## What was built - **`payments` schema, four tables, one migration (`PaymentsCoreLedger`):** - `PaymentGateways` — PSP config; **encrypted `config_json`** (`IFieldEncryptor`); selection index on `(type, is_active, priority)`. - `PaymentTransactions` — every attempt; the **two filtered uniques**: `UNIQUE(gateway_reference_code) WHERE NOT NULL` and `UNIQUE(booking_id) WHERE status='succeeded' AND booking_id IS NOT NULL`. `booking_id` is **nullable** (bound at confirm — a b9 booking exists only on capture). - `PaymentWebhookEvents` — the idempotency store; **`UNIQUE(provider_code, external_event_id)`**. - `LedgerEntries` — the append-only double-entry source of truth. Implements `IEntity` **only** (no `ITimeModification`/audit-modify, no soft-delete) so the audit interceptor never stamps it; `created_at` set explicitly from `IDateTimeProvider`. - **Domain:** `LedgerPosting.CardCapture` (the balanced-group builder — throws if `gross ≠ commission + payout`); `LedgerAccountType` (all 8 codes), `LedgerDirection`/`LedgerSourceRefType`, `PaymentTransactionStatus`, `WebhookProcessingStatus`, `PaymentGatewayType`. - **Features (`Baya.Application/Features/Payments/`):** `InitiatePayment`, `HandlePaymentWebhook`, `ConfirmPaymentAndPostLedger` (internal — dispatched only from the webhook + tests), `GetNursePayableBalance`. - **Seams (`Application/Contracts/Payments/`) + mocks (`CrossCutting/Seams/`):** `IPaymentProvider`, `ISettlementSplitProvider`, `IWebhookVerifier`, `IDistributedLock`; registered in `AddCrossCuttingSeams`. - **Persistence:** `IPaymentRepository` + `PaymentRepository` on `IUnitOfWork`; `config_json` encryption wired in `ApplicationDbContext`. - **Controllers:** `PaymentsController` (`POST bookings/{bookingRequestId}/payments`), `WebhooksController` (public `POST webhooks/payments/{provider}`), `NursePayableBalanceController` (`GET nurses/{id}/payable_balance`). - **Shared conversion:** extracted **`BookingFactory`** from b9's `ConvertRequestToBooking` handler so b9 (mock capture) and b10 (real webhook capture) share the conversion/amount logic — b9 behaviour unchanged. ## What is now testable and exactly how (mirrors phase §7) 1. **Initiate** `POST api/v1/bookings/{requestId}/payments` (as the customer) → `200` with a redirect URL + a `pending` transaction carrying the deterministic `gatewayReferenceCode`; no ledger rows, no booking yet. 2. **Webhook confirms** `POST api/v1/webhooks/payments/{provider}` with a `succeeded` event for that reference → the transaction flips `succeeded`; **one balanced ledger group** posts (DEBIT `escrow_held` 23300000 = CREDIT `platform_revenue` 3495000 + `nurse_payable` 19805000); the booking is created & **confirmed**. 3. **Replay** the same `external_event_id` → `200` with `duplicate: true`; no second confirm, no second ledger group; still one webhook-event row. 4. **`GET api/v1/nurses/{nurseId}/payable_balance`** → `19805000` (signed ledger sum; a debit reduces it). 5. **Second `succeeded` transaction for a booking is blocked** by the filtered `UNIQUE(booking_id) WHERE status='succeeded'` — an idempotent no-op success, never a second capture. 6. **Unverified callback** (mock verifier marks it invalid) → stored `ignored`, no transaction flip, no ledger. 7. **Encrypted gateway config** — `payment_gateways.config_json` is ciphertext at rest. Tests: **12 Foundation** (`Baya.Test.Foundation/Payments/`: `LedgerPostingTests`, `PaymentConfirmTests`, `InitiatePaymentTests`, `NursePayableBalanceTests`, `PaymentWebhookTests`) + **4 Api integration** (`PaymentsApiTests`: 401, validation 400, full initiate→webhook→ledger flow, duplicate-replay). Whole suite green (Foundation 198, Identity 4, Api 83); `dotnet build Baya.sln` 0 new warnings. Filtered uniques + the balanced ledger are exercised against the real EF model on SQLite (partial indexes work there). ## What is mocked + how to make it real The four money-path seams — `IPaymentProvider`, `ISettlementSplitProvider`, `IWebhookVerifier`, `IDistributedLock` — see `reports/mocks-registry.md` (each row → 🟡 with step-by-step make-it-real). The mock `IPaymentCaptureSimulator` (b9) is **retained** (b9's `bookings/convert` endpoint + tests use it); b10's real capture uses `BookingFactory` directly. ## Account types reserved for b11–b13 `refund_payable`, `nurse_clawback_receivable` (b11); `bnpl_fee_expense` (b12); `psp_fee_expense`, `bad_debt` (reserved). All defined now so later phases only post against them. ## Contracts produced - `dev/contracts/domains/payments.md` (human contract) + **`dev/contracts/openapi/swagger.v1.json` refreshed** (the three b10 paths — `bookings/{bookingRequestId}/payments`, `webhooks/payments/{provider}`, `nurses/{nurseId}/payable_balance` — and the `InitiatePaymentResult`/`WebhookIngestResult`/ `NursePayableBalanceDto` schemas are present). ## Follow-ups - b11 removes the last coupling to `IPaymentCaptureSimulator` once the refund path exercises the real rail end to end; consider retiring the `bookings/convert` mock endpoint then. - The webhook confirm crosses two commits (booking creation, then txn+ledger) because the ledger legs need the DB-generated `booking_id`; the webhook dedup + forward-only request guard + the succeeded-unique keep it idempotent. A single explicit transaction is a future hardening if `IUnitOfWork` grows a transaction scope.