# 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.
