4.2 KiB
Handoff — after backend phase 10 (Payments core: ledger, transactions, webhooks & card capture)
The money core is live. A family can now pay the gross price by card: initiate → PSP webhook confirms → the balanced card-capture ledger group posts → the booking converts/confirms. The PSP acquirer, تسهیم split, webhook signature verify, and the distributed lock are mocked behind seams; the DB constraints are the authoritative money-path backstops.
What f9-b10 can now build
- Summary & pay (C6) — after the nurse accepts,
POST api/v1/bookings/{bookingRequestId}/payments(as the customer, with anIdempotency-Keyheader) →{ transactionId, redirectUrl, gatewayReferenceCode }. Show the checkout summary (gross + commission/VAT/escrow notice) and send the payer toredirectUrl. Never show the internalaccount_types — gross + the commission/VAT breakdown only. - Card payment redirect + confirmation — the (mock) redirect completes; the PSP callback hits
POST api/v1/webhooks/payments/{provider}and confirms. Poll the booking (b9bookings/list/bookings/get) → it appearsconfirmedonce the webhook captures. (A booking exists only on capture.) - Nurse payable balance —
GET api/v1/nurses/{nurseId}/payable_balance(the nurse themself or admin) →{ nurseId, balanceIrr }as a digit-string, derived from the ledger.
Live endpoints / contracts
- Contract:
dev/contracts/domains/payments.md. - Enums:
paymentstatus (pending/succeeded/failed),payment_gateways.type(standard/bnpl),processing_status(received/processed/failed/ignored), the eightaccount_types (internal).
Load-bearing rules the client must honour
- Money is IRR integer, on the wire as a digit-string. Never coerce to a JS number for math.
- The booking is created & confirmed on capture (the webhook), not on initiate. After
initiateyou have a redirect only; thebookingsrow appears when the PSP callback confirms. - Payment is idempotent end-to-end — a retried
initiate(sameIdempotency-Key) reuses the attempt; a replayed webhook is a no-op; a repeatinitiateafter capture is a409. - Internal account types are never exposed to the customer — checkout shows gross + commission/VAT only.
Design note (reconciling b10 with b9's as-built)
b9 creates the bookings row on capture (it never persists a standalone pending_payment booking). So b10
initiates payment against the accepted_awaiting_payment request; payment_transactions.booking_id is
nullable, bound only at confirm. Confirm reuses b9's conversion/amount logic through the extracted
BookingFactory (no duplication) and posts the ledger + registers the split. The mock
IPaymentCaptureSimulator + the POST bookings/convert endpoint stay (b9's own tests use them); the real
capture path uses BookingFactory directly, not that seam.
Mocked here → make real later (see reports/mocks-registry.md)
IPaymentProvider(🟡) — deterministic ref + fake redirect;VerifyAsyncinstant-succeeds. Real: ZarinPal/Sadad/Vandar/Jibit acquirer-with-تسهیم, merchant id from encryptedconfig_json, server-side verify.ISettlementSplitProvider(🟡) — records split intent, returnsSettled. Real: split-by-ratio to registered SHEBAs; provider credits IBANs directly.IWebhookVerifier(🟡) — signature valid unless a marker; parses a test JSON body. Real: per-provider HMAC / mandatory server-side verify.IDistributedLock(🟡) — in-process semaphore. Real: StackExchange.Redis lease, keybooking:{id}:payment.
Consumed by later backend phases
- b11 — refunds/clawbacks/invoices post against the ledger (
refund_payable/nurse_clawback_receivableaccount types are already defined);IPaymentProvider.RefundAsyncis ready to call. - b12 — BNPL settle routes callbacks through the same
payment_webhook_eventsidempotency store and posts the BNPL-settle group (addsbnpl_fee_expense). - b13 — payouts read
GetNursePayableBalance(the signednurse_payableledger sum) and post DEBITnurse_payable/ CREDITescrow_held, gated on b9'sdispute_window_ends_at.