Files
baya-monorepo/archive/build-chain/working-context/reports/backend-phase-10-report.md
T
2026-08-02 18:48:32 +03:30

5.4 KiB
Raw Blame History

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_id200 with duplicate: true; no second confirm, no second ledger group; still one webhook-event row.
  4. GET api/v1/nurses/{nurseId}/payable_balance19805000 (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 configpayment_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 b11b13

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.