38 KiB
Backend Phase 10 — Payments core: ledger, transactions, webhooks & card capture
Mission: stand up the money core — the append-only, double-entry
ledger_entriesthat is the financial source of truth;payment_transactions(every attempt, with the two filtered-unique guards that make capture idempotent);payment_webhook_events(the at-least-once callback store whoseUNIQUE(provider_code, external_event_id)is the single idempotency chokepoint); andpayment_gateways(encrypted provider config for selection/failover). On top of these, build the card rail end-to-end: InitiatePayment against apending_paymentbooking → a PSP webhook confirms it → the balanced card-capture ledger group posts (DEBITescrow_heldgross = CREDITplatform_revenuecommission +nurse_payablepayout) → the booking converts/confirms (the b9ConvertRequestToBooking). Every mutation runs behind a Redislock(booking:{id}:payment)with the DB constraints as the authoritative backstop. This is the foundation refunds (b11), BNPL (b12), and payouts (b13) all post against — get the idempotency and the balanced posting exactly right and the rest of the money path is safe.Track: backend · Depends on: b9 (bookings + the three-amount split +
ConvertRequestToBooking), b1 (typed cachedplatform_configs), b0 (IFieldEncryptor,ICacheService,IDateTimeProvider, REST surface, audit interceptor) · Unlocks: refunds/invoices/clawbacks (b11), BNPL (b12), payouts (b13); frontend f9-b10 Before you start, read../_shared/agent-operating-rules.md. It is not optional.
1. Context — where this sits
This is backend phase b10, the inbound money rail. Until now the platform could create a booking but
never take a Rial: b9 built bookings carrying the frozen three-amount split
(gross_price_irr, balinyaar_commission_irr, nurse_payout_amount, with the
gross_price_irr = balinyaar_commission_irr + nurse_payout_amount CHECK) plus dispute_window_ends_at, and
the ConvertRequestToBooking command that turns an accepted_awaiting_payment request into a money-bearing
booking on capture — that conversion is the hook this phase fires. This phase makes "the family pays the
gross price by card" real and lawful: Balinyaar is merchant-of-record but never a cash custodian (a
پرداختیار may not hold deposits, run wallets, or move money between merchants), so "escrow" is modeled as an
internal double-entry ledger STATE over funds that legally sit at the licensed PSP/bank — never as
platform-held cash. The provider sits behind a swappable seam because Iranian provider cut-offs are real
(Toman/Jibit were abruptly suspended Nov 2024), and every callback is idempotency-deduplicated before any
money state mutates because PSP callbacks are at-least-once and retried.
What already exists (do not rebuild) — built by prior phases:
- Bookings + the three-amount split + conversion — b9 built
bookings(gross_price_irr,balinyaar_commission_irr,platform_fee_rate,nurse_payout_amount, thegross = commission + payoutCHECK, all amounts ≥ 0), the booking status machine (pending_payment→confirmed→in_progress→completed→disputed/closed/cancelled),dispute_window_ends_at, and theConvertRequestToBookingcommand (creates thebookingsrow 1:1 from anaccepted_awaiting_paymentbooking_requests, writesvariant_snapshot_json+ encryptedaddress_snapshot_json, computes the three amounts). This phase callsConvertRequestToBookingon successful capture — it does not re-implement booking creation or the amount math. The CUTpayout_releasedBIT stays CUT — "paid" derives from the ledger + payout links, never a boolean. - Config (typed, cached) — b1 built
platform_configs+ the typed cached config accessor. Readcommission_rate/vat_rate/dispute_window_hoursand any gateway-selection defaults through that accessor (cached), never hardcoded. (The amounts themselves are already frozen on the booking by b9; this phase reads config only where it must, e.g. dispute-window seeding lives on the booking already.) - Cross-cutting seams & plumbing — b0 built the REST surface (
BaseController,base.OperationResult(...), snake_case[controller]/[action]routing, rate limiting), CQRS viamartinothamar/Mediator(ISender/ICommand/IQuery,internal sealedhandlers,OperationResult<T>for expected failures), the audit-field SaveChanges interceptor, and the seamsIFieldEncryptor(encryptspayment_gateways.config_json),ICacheService,IDateTimeProvider(stampscreated_at/received_at/processed_at). Reuse all of these — do not redefine them. - The
IUnitOfWork/CommitAsyncpattern, FluentValidationValidateCommandBehavior, Mapster, soft-delete query filters, oneIEntityTypeConfiguration<T>per entity — established in b0/b1 and used by every phase since. Mirror them exactly.
What this phase introduces: the four payments-core tables (payment_gateways, payment_transactions,
payment_webhook_events, ledger_entries) + their EF configs + one migration; the capabilities
InitiatePayment, HandlePaymentWebhook, ConfirmPaymentAndPostLedger, GetNursePayableBalance; the four
money-path seams IPaymentProvider, ISettlementSplitProvider, IWebhookVerifier,
IDistributedLock (with faithful mocks); and the public/webhook REST surface. Refunds, clawbacks,
invoices (b11), BNPL settle (b12), and payouts (b13) are (DEFERRED) here — see §3.6 — but the ledger,
the idempotency store, and the six account_types they all post against are built here.
2. Required reading (do this first)
../_shared/agent-operating-rules.mdand../_shared/backend-conventions-checklist.md— especially Performance/caching/money/idempotency: money is IRRBIGINT, no floats; money-path writes are idempotent (webhook dedup on the unique external-event key; filtered unique on succeeded transaction) and guarded by a Redis distributed lock with the DB constraint as the authoritative backstop;ledger_entriesis append-only and balanced (Σdebit = Σcredit pertransaction_group_id).../../../product/business/08-payments-and-escrow.md— the inbound money path: card → PSP → Shaparak → registered IBANs; escrow is a ledger state, not held cash; every callback idempotency-deduplicated before money moves; provider swappable by config.../../../product/payments/index.mdand../../../product/payments/escrow-ledger.md— the canonical ledger postings (the sixaccount_types and the exact card-capture group: DEBITescrow_heldgross = CREDITplatform_revenuecommission +nurse_payablepayout). Mirror the account names and posting discipline exactly.../../../product/payments/iranian-payment-reality.md— why the platform may not custody funds (§2.2 پرداختیار custody prohibition), why تسهیم (settlement-sharing) is the lawful split primitive (§2.3), why a held platform pool is banned (§2.4), and why providers must be swappable (§2.5 Toman/Jibit cut-off). This is the legal shape your seams encode.../../../product/payments/integration-notes.md— the per-provider verb sets and the server-sideverifyre-check rule (never trust a callback alone); the "make it real" detail you record in the mock registry.../../../product/data-model/06-payments-ledger-and-refunds.md— the canonical schemas:payment_gateways,payment_transactions(and its two NEW filtered uniques),payment_webhook_events(field table + theUNIQUE(provider_code, external_event_id)idempotency key), andledger_entries(the field table, theaccount_typeset, the canonical-postings table). Mirror field names exactly. (refunds,nurse_clawbacks,invoicesin this doc are b11 — read for context only.)- Contract conventions:
../../contracts/conventions/api-conventions.mdandmoney-and-types.md— IRRBIGINTserialized as a string of digits on the wire, the envelope, thepayment/refund_channelenum codes, Toman is display-only and converted only inside a provider adapter at its boundary. - Code to mirror: b9's
Features/Booking/**(theConvertRequestToBookingcommand + the booking status machine you call/transition), b9's amount-bearingbookingsconfig; b1's typed config accessor; b0's seam registration (ServiceConfiguration/extension, config-selected impls) and theIFieldEncryptorusage on encrypted columns. Mirror theirFeatures/<Area>/{Commands|Queries}/<Name>/layout,IEntityTypeConfiguration<T>, and theIUnitOfWork/single-CommitAsyncpattern. - Prior handoffs:
dev/shared-working-context/backend/handoff/after-backend-phase-9.md,…-1.md,…-0.md, andreports/mocks-registry.md(theIFieldEncryptor/ICacheService/IDateTimeProviderrows you reuse, and theIPaymentProvider/ISettlementSplitProvider/IWebhookVerifier/IDistributedLockrows you flip to 🟡).
3. Scope — build this
All money is IRR long / BIGINT — no floats anywhere. The payments features live under
Baya.Application/Features/Payments/{Commands|Queries}/<Name>/; the entities in
Baya.Domain/Entities/Payments/; one IEntityTypeConfiguration<T> per entity in
Persistence/Configuration/PaymentsConfig/; the four seams in Application/Contracts/ with their mock
implementations in Infrastructure, DI-registered via a ServiceConfiguration/ extension (config-selected
so a real adapter swaps in later); one EF migration for the four tables and their indexes.
3.1 Entities + migration
payment_gateways [CORE] — config per connected PSP/BNPL provider; selection/failover.
- Fields:
idBIGINT PK;provider_codeNVARCHAR(50) (zarinpal/sadad/vandar/jibit…);typeNVARCHAR(20) —standard(card IPG) /bnpl— selects the flow;display_name;config_jsonNVARCHAR(MAX) — ENCRYPTED viaIFieldEncryptor(merchant id, terminal/IBAN registration for the تسهیم split, base_url, sandbox flag — provider-selection / failover config, NEVER per-transaction credentials);is_activeBIT;priorityINT (failover order); soft-delete + audit. config_jsonis encrypted at rest and never logged in plaintext. Selection is config-driven: pick the activestandardgateway bypriorityso a cut-off provider is swapped by config, not code change.
payment_transactions [CORE] — every payment attempt against a booking; the succeeded row triggers
confirmation; stores the full gateway_response_json and the Shaparak gateway_reference_code (definitive
proof for reconciliation/chargebacks).
- Fields (mirror
product/data-model/06):idBIGINT PK;booking_idBIGINT FK →bookings;customer_idBIGINT FK;gateway_idBIGINT FK →payment_gateways;amountBIGINT (IRR);currencyNVARCHAR (alwaysIRRinternally);statusNVARCHAR(20) —pending/succeeded/failed;gateway_transaction_id;gateway_reference_codeNVARCHAR NULL;gateway_response_code;gateway_response_jsonNVARCHAR(MAX);is_installmentBIT;ip_address;user_agent; soft-delete + audit timestamps. - The two structural idempotency guards (NEW — do not drop):
- filtered
UNIQUE(gateway_reference_code) WHERE gateway_reference_code IS NOT NULL— Shaparak ref dedupe. - filtered
UNIQUE(booking_id) WHERE status = 'succeeded'— at most one capturing transaction per booking; this is the authoritative anti-double-capture backstop.
- filtered
- Secondary index on
(booking_id, status)for the lookup in capture/initiate.
payment_webhook_events [CORE] — raw, deduplicated store of every PSP/BNPL callback; the idempotency
chokepoint.
- Fields:
idBIGINT PK;provider_codeNVARCHAR(50);external_event_idNVARCHAR(200);event_typeNVARCHAR(80);signature_validBIT;payload_jsonNVARCHAR(MAX) (raw callback);processing_statusNVARCHAR(20) —received/processed/failed/ignored;related_payment_transaction_idBIGINT NULL;received_at,processed_atDATETIME2. UNIQUE(provider_code, external_event_id)— the idempotency key. The handler inserts/upserts here first and no-ops on a duplicate, inside the same transaction that mutates payment state (§3.3).
ledger_entries [CORE] — the append-only, double-entry financial source of truth. Every money event
posts balanced rows sharing a transaction_group_id (Σdebit = Σcredit per group).
- Fields (mirror
product/data-model/06):idBIGINT PK;transaction_group_idUNIQUEIDENTIFIER (groups the balanced legs of one event);account_typeNVARCHAR(40) — the closed set:escrow_held/platform_revenue/nurse_payable/refund_payable/bnpl_fee_expense/nurse_clawback_receivable(define all six now even though this phase only posts the first three — b11/b12/b13 post the rest; the data-model doc also listspsp_fee_expense/bad_debt, include them if present in the canonical schema you mirror);nurse_idBIGINT FK NULL (set fornurse_payable/nurse_clawback_receivable);directionNVARCHAR(6) —debit/credit;amount_irrBIGINT — always positive;directioncarries the sign;booking_idBIGINT FK NULL;source_ref_typeNVARCHAR(40) (payment_transaction/refund/nurse_payout/bnpl_transaction/clawback);source_ref_idBIGINT;memoNVARCHAR(300) NULL;created_atDATETIME2 — append-only, never updated. - No soft-delete, no audit-modified columns, no UPDATE/DELETE path —
ledger_entriesis append-only; corrections are new balancing rows. Do not configure aModifiedAt/IsDeletedflow on this entity; it is insert-only by design (mark the entity so the audit interceptor never stamps a modify on it). - Indexes:
(account_type, nurse_id)(forGetNursePayableBalanceand later balance reads),transaction_group_id(to read a posting group),(source_ref_type, source_ref_id),booking_id.
Build-order rule (from the payments digest): the ledger + webhook idempotency come first; the provider adapters plug into the seams only after that foundation exists. Get the table shapes and the two filtered uniques right before writing a single capture.
3.2 InitiatePayment (start a card attempt)
InitiatePaymentCommand(bookingId) [CORE] — creates a pending payment_transactions row against a
booking in pending_payment, selects the active standard gateway (by payment_gateways.type='standard',
active, lowest priority), and calls the provider to start the IPG session.
- Validates the booking exists and is
pending_payment(tenancy: the caller is the booking's customer); the payment deadline (payment_deadline_atfrom the originating request, b8/b9) has not lapsed. - Reads
amount= the booking'sgross_price_irr(already frozen by b9 — never recompute it here). - Calls
IPaymentProvider.InitPaymentAsync(bookingId, amountIrr, idempotencyKey, ct)→ returns the redirect URL + a deterministicgatewayReferenceCode; persists thependingpayment_transactionsrow (withgateway_reference_code, honouring the filtered unique) and returns the redirect/token to the client. - Route:
POST api/v1/bookings/{bookingId}/payments(authenticated; rate-limited as a money endpoint; carries an idempotency key). Returns the redirect URL + the transaction id. - Validator (FluentValidation):
bookingIdpresent; resolves to apending_paymentbooking owned by the caller. - Idempotency: a repeat InitiatePayment for a booking that already has a
succeededtransaction returns a409(the booking is already paid) — do not create a second attempt; the filteredUNIQUE(booking_id) WHERE status='succeeded'is the backstop.
3.3 HandlePaymentWebhook (the idempotent callback ingest)
HandlePaymentWebhookCommand(provider, headers, rawBody) [CORE] — the verify-then-dedup-then-mutate path
for every inbound PSP callback.
- Route:
POST api/v1/webhooks/payments/{provider}(no user auth — authenticated by signature; rate-limited; tolerant of at-least-once retries by design). - Steps, all inside one DB transaction (single
CommitAsync):- Verify the callback via
IWebhookVerifier.Verify(provider, headers, rawBody)→(signatureValid, externalEventId, eventType, parsedPayload). If the signature is invalid, store the event withsignature_valid=0,processing_status='ignored', and stop (never mutate money on an unverified callback). - Upsert
payment_webhook_eventsFIRST keyed on(provider_code, external_event_id). If the row already exists (duplicate replay), no-op: mark/leaveprocessing_statusand return success without mutating any payment or ledger state. This is the idempotency guarantee — a replayedsucceededmust never double-confirm and a replayedsettledmust never double-count. - On a new event whose
event_typeindicates success, re-verify server-side (the integration-notes rule — never trust the callback alone): callIPaymentProvider.VerifyAsync(gatewayReferenceCode, expectedAmountIrr, ct)to re-check the amount and reference against the storedpendingtransaction, then dispatchConfirmPaymentAndPostLedger(§3.4). - Set
processing_status='processed',processed_at, andrelated_payment_transaction_id.
- Verify the callback via
- The whole thing is wrapped in a Redis
lock(booking:{id}:payment)viaIDistributedLockso a fast double-callback and a user retry don't both start money mutation; the DB uniques are the authoritative backstop if the lock is lost/expired or Redis is down.
3.4 ConfirmPaymentAndPostLedger (capture → ledger → convert booking)
ConfirmPaymentAndPostLedgerCommand(paymentTransactionId) [CORE] — flips the transaction to succeeded
under the filtered-unique guard, posts the card-capture ledger group, and triggers booking conversion.
- Steps (inside the same transaction/lock from §3.3):
- Mark the
payment_transactionsrowstatus='succeeded'— the filteredUNIQUE(booking_id) WHERE status='succeeded'makes a second succeeded row impossible (a concurrent double-confirm fails on the constraint, which the handler treats as "already captured → no-op success"). - Post the card-capture group to
ledger_entriesunder one freshtransaction_group_id, reading the booking's three frozen amounts:The group must balance: Σdebit (gross) = Σcredit (commission + payout).DEBIT escrow_held gross_price_irr CREDIT platform_revenue balinyaar_commission_irr CREDIT nurse_payable nurse_payout_amount (nurse_id set; = gross − balinyaar_commission)amount_irris positive on every row;directioncarries the sign.source_ref_type='payment_transaction',source_ref_id=paymentTransactionId,booking_idset,created_atfromIDateTimeProvider. - Register the تسهیم split via
ISettlementSplitProvider.RegisterSplitAsync(bookingId, legs, ct)wherelegs = [(nurseSheba, nurse_payout_amount, "nurse"), (platformSheba, balinyaar_commission_irr, "platform")]— the lawful split-by-ratio to registered IBANs (the provider credits each IBAN directly; Balinyaar never moves the money). The mock accepts any legs whose sum = gross and returnsSettled. - Trigger
ConvertRequestToBooking(from b9) — or, if the booking row was already created at request-conversion time per b9's design, transition itpending_payment → confirmed. Follow whichever b9 actually did; do not duplicate the conversion/amount logic — call b9's command.
- Mark the
- This command is never a public endpoint — it is dispatched only from
HandlePaymentWebhook(and, in tests, directly). The webhook is the only public confirm path.
3.5 GetNursePayableBalance (derived, never stored)
GetNursePayableBalanceQuery(nurseId) [CORE] — sums ledger_entries WHERE account_type='nurse_payable' AND nurse_id=@nurseId, signed by direction (credit adds, debit subtracts), to the IRR BIGINT balance
currently owed the nurse. Pure projection over the ledger — AsNoTracking(), a single aggregate query,
no cached wallet column ever. This is what b13 (payouts) reads to know what to pay, so it must be the
ledger truth, not a status flag.
- Route:
GET api/v1/nurses/{nurseId}/payable_balance(authorized: the nurse themself or admin). - (Optionally also expose
GetEscrowHeldQuery/GetCommissionIncomeQueryas the same shape over their account types — thin admin reads; buildnurse_payablenow, the others are trivial siblings.)
3.6 DEFERRED (do not build; leave the account type / seam / pointer)
- Refunds, clawbacks, invoices —
refunds(1:N, fee/payout decomposition,refund_channel),nurse_clawbacks, the refund/clawback ledger postings, andinvoices(VAT on commission) are owned by b11. This phase defines therefund_payableandnurse_clawback_receivableaccount types in the ledger so b11 just posts against them, and exposesIPaymentProvider.RefundAsyncin the seam (mock returnsSucceeded) so b11 can call it — but builds no refund table or flow. (DEFERRED → b11.) - BNPL settle — the
bnpl_transactionstable, the BNPL-settle ledger group (card-capture legs plus DEBITbnpl_fee_expense/ CREDITescrow_heldso escrow reflects net cash), and theIBnplProviderseam are owned by b12. This phase definesbnpl_fee_expenseand routes BNPL callbacks through the samepayment_webhook_eventsidempotency store. (DEFERRED → b12.) - Payouts —
nurse_payout_batches/nurse_payouts/nurse_payout_booking_linksand the payout ledger movement (DEBITnurse_payable/ CREDITescrow_held) are owned by b13, gated on the dispute window.GetNursePayableBalance(built here) is what it reads. (DEFERRED → b13.) - Real provider adapters (ZarinPal/Sadad/Vandar/Jibit card + تسهیم; real signature/HMAC verification; real Redis lock) — mock now behind the seams, recorded in the registry with the make-real steps. (DEFERRED.)
4. Mocks & seams in this phase
This phase introduces four money-path seams. Each is an Application interface with a faithful
Infrastructure mock, DI-registered via a ServiceConfiguration/ extension (config-selected — never an
if (mock) branch in a handler). All amounts crossing these interfaces are IRR BIGINT; Toman
conversion happens only inside the real adapter at the provider boundary, never internally.
| Seam | Owner | Mock behaviour | Registry |
|---|---|---|---|
IPaymentProvider |
introduced here | InitPaymentAsync → deterministic gatewayReferenceCode + a fake redirect URL; VerifyAsync → instant Succeeded, echoes the amount (re-checks reference); RefundAsync → always Succeeded (for b11 to call). No external call. |
add a new row (🟡) |
ISettlementSplitProvider |
introduced here (تسهیم) | RegisterSplitAsync → accepts any legs whose sum = gross, returns Registered then instant Settled; GetSplitStatusAsync → Settled. The platform never moves money — the mock just records the split intent. |
add a new row (🟡) |
IWebhookVerifier |
introduced here | Verify → signatureValid=true, extracts a test externalEventId + eventType from the body. Lets tests replay duplicate webhooks to prove idempotency. |
add a new row (🟡) |
IDistributedLock |
introduced here | no-op / in-process lock (a process-local semaphore keyed by the lock string) so the money-path code runs the same shape it will with real Redis. The DB unique/state-machine is the authoritative backstop — never rely on the lock alone. | add a new row (🟡) |
IFieldEncryptor |
reuse from b0 | encrypts/decrypts payment_gateways.config_json; never logs plaintext. |
reuse row |
ICacheService |
reuse from b0 | typed config accessor (b1) reads commission_rate/vat_rate through it. |
reuse row |
IDateTimeProvider |
reuse from b0 | stamps created_at/received_at/processed_at (deterministic in tests). |
reuse row |
Append the four new rows to
../../shared-working-context/reports/mocks-registry.md
(seam, file, what's faked, config keys, step-by-step how to make it real): for IPaymentProvider —
ZarinPal/Sadad/Vandar/Jibit as acquirer-with-تسهیم, merchant id + terminal/IBAN registration, Shaparak
gateway_reference_code, persist the full gateway response, golden-tier eligibility; for
ISettlementSplitProvider — each beneficiary's registered Sheba, split-by-ratio config, min-amount caveat
(~100,000 IRR), provider credits IBANs directly; for IWebhookVerifier — per-provider HMAC/signature scheme
(or, where none exists, the mandatory server-side verify re-check of amount + reference); for
IDistributedLock — StackExchange.Redis with a lease/expiry, key conventions booking:{id}:payment. A
IProviderRegistry/config-driven factory selects the concrete provider per payment_gateways.config_json so a
cut-off provider is swapped without code change.
5. Critical rules you must not get wrong
- Money is IRR
BIGINT, no floats anywhere — not in the DB, not in a handler, not on the wire. Toman conversion happens only inside a provider adapter at its boundary; the seam interfaces and the ledger speak IRR Rials only. Never introduce adecimal/doubleon the money path. - Idempotency: always upsert
payment_webhook_eventson(provider_code, external_event_id)FIRST and no-op on duplicate — inside the same DB transaction that mutates payment state — so a replayedsucceedednever double-confirms and a replayedsettlednever double-counts. This dedup is the single chokepoint for every PSP/BNPL replay; do it before any money state changes. - Escrow IS the ledger — never infer money state from status booleans or add money columns to "track" a
balance.
ledger_entriesis the single source of truth; every money event posts balanced rows; balances are derived by filter, never stored in a drifting column. (Thepayout_releasedBIT stayed CUT in b9 for exactly this reason.) - The card-capture posting is balanced: **DEBIT
escrow_heldgross = CREDITplatform_revenuecommissionnurse_payablepayout**, all under onetransaction_group_id,amount_irrpositive withdirectioncarrying the sign, Σdebit = Σcredit. The three amounts are never conflated and come frozen from the booking (b9) — never recomputed here.
ledger_entriesis append-only — neverUPDATEorDELETEa ledger row; corrections are new balancing rows, never edits. Configure the entity so the audit interceptor never stamps a modify and there is no soft-delete path.- The filtered
UNIQUE(booking_id) WHERE status='succeeded'is the structural anti-double-capture guard — do not drop it. It (and theUNIQUE(gateway_reference_code)) is what makes a retried success webhook unable to create a second capture even if the lock is lost. Treat a unique-violation on confirm as "already captured → idempotent no-op success", not an error to surface. - The Redis lock is the fast first line; the DB constraint is the authoritative backstop. Wrap
capture/verify in
lock(booking:{id}:payment)viaIDistributedLock, but never rely on the lock alone for correctness — if Redis is down or the lease expires, the DB uniques must still prevent a double-capture. - Escrow is a ledger state, not platform cash — never model a held pool. A پرداختیار may not hold
deposits, run wallets, or move money between merchants. The lawful split is تسهیم via
ISettlementSplitProviderto registered IBANs (the provider credits each directly); the ledger only mirrors money that legally sits at the provider/bank. Do not design "collect into a platform pool, hold until EVV, redistribute" — it is banned. - Provider swappable by config. Handlers depend on
IPaymentProvider/IWebhookVerifier/ISettlementSplitProvider, never on a concrete client; selection is bypayment_gatewaysconfig. The ledger must survive a provider cut-off mid-cycle (Toman/Jibit Nov-2024 precedent). payment_gateways.config_jsonis encrypted and is provider-selection/failover config — never per-transaction credentials, and never logged in plaintext (IFieldEncryptor).- Never trust a callback alone — on a success event, re-verify server-side via
IPaymentProvider.VerifyAsync(amount + reference) before confirming. An unverified-signature callback mutates nothing.
6. Definition of Done
The shared definition-of-done.md, plus:
- The four tables (
payment_gateways,payment_transactions,payment_webhook_events,ledger_entries) exist via one migration with theirIEntityTypeConfiguration<T>s: the two filtered uniques onpayment_transactions(gateway_reference_codeWHERE NOT NULL;booking_idWHERE status='succeeded'), theUNIQUE(provider_code, external_event_id)onpayment_webhook_events, the six (or eight)account_types and the append-only (no soft-delete/no-modify) config onledger_entries, and theconfig_jsonencryption onpayment_gateways. InitiatePayment,HandlePaymentWebhook,ConfirmPaymentAndPostLedger, andGetNursePayableBalanceare implemented per §3, behind the four seams, with FluentValidation on the input-bearing commands andAsNoTracking()+.Select(...)projection on the balance query.- The webhook handler upserts
payment_webhook_eventsfirst and no-ops on duplicate, inside one transaction wrapped inIDistributedLock(booking:{id}:payment); the card-capture ledger group is balanced (Σdebit = Σcredit) and triggers b9'sConvertRequestToBooking/pending_payment→confirmed. IPaymentProvider,ISettlementSplitProvider,IWebhookVerifier,IDistributedLockare introduced as Application interfaces with Infrastructure mocks, DI-registered via aServiceConfiguration/extension (config-selected; noif (mock)in handlers).- Handler/unit tests (NSubstitute): the card-capture group balances and posts the three correct legs;
a replayed webhook event is a no-op (no second confirm, no second ledger group); a second
succeededtransaction for a booking is blocked by the filtered unique;GetNursePayableBalanceequals the signed ledger sum; an unverified-signature callback mutates nothing. ≥1WebApplicationFactoryintegration test forPOST api/v1/bookings/{id}/payments(happy path, 401, validation 400) and the webhook ingest (happy + duplicate-replay).dotnet build Baya.slnzero new warnings;dotnet test Baya.slngreen (a reachable SQL Server is required — the filtered uniques are the test's whole point). - The Project map in
server/CLAUDE.mdreflects theFeatures/Payments/**area, the four tables, and the four new seams + where they're registered. - The contract
dev/contracts/domains/payments.mdis written and theswagger.jsonsnapshot republished.
7. How to test (what a human can verify after this phase)
Seed (or reuse from b9): one active standard payment_gateways row; a bookings row in
pending_payment for a known customer + nurse, with gross_price_irr = balinyaar_commission_irr +
nurse_payout_amount (e.g. gross 23300000, commission 3495000, payout 19805000 — adjust to your
config's commission rate). Configure the mock IWebhookVerifier/IPaymentProvider.
- Initiate a payment —
POST api/v1/bookings/{bookingId}/payments(as the customer) →200with a redirect URL + apendingpayment_transactionsrow carrying the mock's deterministicgateway_reference_code. (No ledger rows yet, booking stillpending_payment.) - A webhook confirms it —
POST api/v1/webhooks/payments/{provider}with asucceededevent for that reference → the transaction flips tosucceeded; one balanced ledger group appears (DEBITescrow_held23300000= CREDITplatform_revenue3495000+nurse_payable19805000); the booking converts/confirms (pending_payment → confirmed, b9). Verify Σdebit = Σcredit for the group. - Replaying the same webhook event is a no-op — POST the same
external_event_idagain →200, but no second confirm and no second ledger group (thepayment_webhook_eventsupsert short-circuits). Query the ledger: still exactly one capture group;payment_webhook_eventsstill one row. GetNursePayableBalancereflects the accrual —GET api/v1/nurses/{nurseId}/payable_balance→19805000(the creditednurse_payable, signed by direction). It is computed from the ledger, not a column.- A second
succeededtransaction for the same booking is blocked — attempt to confirm a different transaction for the same booking (or initiate again after capture) → blocked by the filteredUNIQUE(booking_id) WHERE status='succeeded'(409/idempotent no-op), never a second capture. - Unverified callback mutates nothing — POST a webhook the mock verifier marks
signature_valid=false→ stored withprocessing_status='ignored', no transaction flip, no ledger rows. - Encrypted gateway config — inspect
payment_gateways.config_jsonin the DB → ciphertext, not plaintext; the activestandardgateway is selected bytype+priority.
8. Hand off & document (close the phase)
- Docs to update: the Project map in
server/CLAUDE.md(add theFeatures/Payments/**area, the four payments-core tables, the append-onlyledger_entriesnote, and the four new seams + where they're registered). If you decide/confirm a rule theproduct/docs don't yet capture (e.g. the exact "upsert webhook event first, then re-verify server-side, then confirm" ordering, or treating a unique-violation on confirm as an idempotent no-op), record it in../../../product/business/08-payments-and-escrow.mdor../../../product/payments/escrow-ledger.md— don't invent rules. Note the newIPaymentProvider/IWebhookVerifier/ISettlementSplitProvider/IDistributedLockpattern inserver/CONVENTIONS.mdif it establishes a reusable money-path shape (lock-then-DB-constraint). - Contract to write:
dev/contracts/domains/payments.md(per../../contracts/domains/_TEMPLATE.md) — documentPOST api/v1/bookings/{bookingId}/payments(auth, idempotency key, rate-limited; request/redirect response),POST api/v1/webhooks/payments/{provider}(signature auth, at-least-once/idempotent, theprocessing_statusenum),GET api/v1/nurses/{nurseId}/payable_balance(derived IRRBIGINTbalance, authorization); thepaymentstatus enum (pending/succeeded/failed), theaccount_typeset, thegateway.typeenum (standard/bnpl); state that money is IRRBIGINTserialized as a string of digits, that the card-capture ledger group is balanced, and that internal account types are never exposed to the customer (the checkout UI shows gross + the commission/VAT breakdown only). Republish theswagger.jsonsnapshot per../../contracts/openapi/README.md. This is what f9-b10 consumes (Summary & pay (C6), card payment redirect, confirmation). - Handoff & report: write
dev/shared-working-context/backend/handoff/after-backend-phase-10.md(the money core is live — initiate → webhook confirm → balanced capture → booking confirm; what f9 can now build — checkout summary with commission/VAT/escrow notice (C6), card payment via the mock redirect, the succeeded/confirmed state; which endpoints/contract are live; that the PSP/تسهیم/webhook-verify/lock are mocked behind seams; that refunds (b11), BNPL settle (b12), and payouts (b13) post against this ledger next). Append tobackend/STATUS.md, writedev/shared-working-context/reports/backend-phase-10-report.md(what was built, what is now testable and exactly how per §7, what is mocked + how to make it real, theaccount_types reserved for b11–b13, contracts produced, follow-ups), and updatedev/shared-working-context/reports/mocks-registry.md(the four new rows → 🟡). - Memory: save a
projectmemory note for the non-obvious decisions this phase fixes — the upsert-webhook-event-first-then-no-op idempotency ordering; the two filtered uniques onpayment_transactionsas the anti-double-capture backstop; the balanced card-capture posting (DEBITescrow_heldgross = CREDITplatform_revenue+nurse_payable) and the sixaccount_types; the append-only, derive-balances-by-filter ledger discipline; the lock-first / DB-constraint-backstop pattern viaIDistributedLock; and the four money-path seams (PSP / تسهیم / webhook-verify / lock, mock-now/real-later) — with a one-line pointer inMEMORY.md.