5.4 KiB
Backend Phase 10 report — Payments core: ledger, transactions, webhooks & card capture
What was built
paymentsschema, four tables, one migration (PaymentsCoreLedger):PaymentGateways— PSP config; encryptedconfig_json(IFieldEncryptor); selection index on(type, is_active, priority).PaymentTransactions— every attempt; the two filtered uniques:UNIQUE(gateway_reference_code) WHERE NOT NULLandUNIQUE(booking_id) WHERE status='succeeded' AND booking_id IS NOT NULL.booking_idis 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. ImplementsIEntityonly (noITimeModification/audit-modify, no soft-delete) so the audit interceptor never stamps it;created_atset explicitly fromIDateTimeProvider.
- Domain:
LedgerPosting.CardCapture(the balanced-group builder — throws ifgross ≠ 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 inAddCrossCuttingSeams. - Persistence:
IPaymentRepository+PaymentRepositoryonIUnitOfWork;config_jsonencryption wired inApplicationDbContext. - Controllers:
PaymentsController(POST bookings/{bookingRequestId}/payments),WebhooksController(publicPOST webhooks/payments/{provider}),NursePayableBalanceController(GET nurses/{id}/payable_balance). - Shared conversion: extracted
BookingFactoryfrom b9'sConvertRequestToBookinghandler 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)
- Initiate
POST api/v1/bookings/{requestId}/payments(as the customer) →200with a redirect URL + apendingtransaction carrying the deterministicgatewayReferenceCode; no ledger rows, no booking yet. - Webhook confirms
POST api/v1/webhooks/payments/{provider}with asucceededevent for that reference → the transaction flipssucceeded; one balanced ledger group posts (DEBITescrow_held23300000 = CREDITplatform_revenue3495000 +nurse_payable19805000); the booking is created & confirmed. - Replay the same
external_event_id→200withduplicate: true; no second confirm, no second ledger group; still one webhook-event row. GET api/v1/nurses/{nurseId}/payable_balance→19805000(signed ledger sum; a debit reduces it).- Second
succeededtransaction for a booking is blocked by the filteredUNIQUE(booking_id) WHERE status='succeeded'— an idempotent no-op success, never a second capture. - Unverified callback (mock verifier marks it invalid) → stored
ignored, no transaction flip, no ledger. - Encrypted gateway config —
payment_gateways.config_jsonis 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.jsonrefreshed (the three b10 paths —bookings/{bookingRequestId}/payments,webhooks/payments/{provider},nurses/{nurseId}/payable_balance— and theInitiatePaymentResult/WebhookIngestResult/NursePayableBalanceDtoschemas are present).
Follow-ups
- b11 removes the last coupling to
IPaymentCaptureSimulatoronce the refund path exercises the real rail end to end; consider retiring thebookings/convertmock 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 ifIUnitOfWorkgrows a transaction scope.