Files
baya-monorepo/dev/phases/backend/backend-phase-10.md
T
2026-06-28 21:59:59 +03:30

38 KiB
Raw Blame History

Backend Phase 10 — Payments core: ledger, transactions, webhooks & card capture

Mission: stand up the money core — the append-only, double-entry ledger_entries that 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 whose UNIQUE(provider_code, external_event_id) is the single idempotency chokepoint); and payment_gateways (encrypted provider config for selection/failover). On top of these, build the card rail end-to-end: InitiatePayment against a pending_payment booking → a PSP webhook confirms it → the balanced card-capture ledger group posts (DEBIT escrow_held gross = CREDIT platform_revenue commission + nurse_payable payout) → the booking converts/confirms (the b9 ConvertRequestToBooking). Every mutation runs behind a Redis lock(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 cached platform_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 + conversionb9 built bookings (gross_price_irr, balinyaar_commission_irr, platform_fee_rate, nurse_payout_amount, the gross = commission + payout CHECK, all amounts ≥ 0), the booking status machine (pending_paymentconfirmedin_progresscompleteddisputed/closed/cancelled), dispute_window_ends_at, and the ConvertRequestToBooking command (creates the bookings row 1:1 from an accepted_awaiting_payment booking_requests, writes variant_snapshot_json + encrypted address_snapshot_json, computes the three amounts). This phase calls ConvertRequestToBooking on successful capture — it does not re-implement booking creation or the amount math. The CUT payout_released BIT stays CUT — "paid" derives from the ledger + payout links, never a boolean.
  • Config (typed, cached)b1 built platform_configs + the typed cached config accessor. Read commission_rate/vat_rate/dispute_window_hours and 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 & plumbingb0 built the REST surface (BaseController, base.OperationResult(...), snake_case [controller]/[action] routing, rate limiting), CQRS via martinothamar/Mediator (ISender/ICommand/IQuery, internal sealed handlers, OperationResult<T> for expected failures), the audit-field SaveChanges interceptor, and the seams IFieldEncryptor (encrypts payment_gateways.config_json), ICacheService, IDateTimeProvider (stamps created_at/received_at/processed_at). Reuse all of these — do not redefine them.
  • The IUnitOfWork/CommitAsync pattern, FluentValidation ValidateCommandBehavior, Mapster, soft-delete query filters, one IEntityTypeConfiguration<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.md and ../_shared/backend-conventions-checklist.md — especially Performance/caching/money/idempotency: money is IRR BIGINT, 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_entries is append-only and balanced (Σdebit = Σcredit per transaction_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.md and ../../../product/payments/escrow-ledger.mdthe canonical ledger postings (the six account_types and the exact card-capture group: DEBIT escrow_held gross = CREDIT platform_revenue commission + nurse_payable payout). Mirror the account names and posting discipline exactly.
  • ../../../product/payments/iranian-payment-reality.mdwhy 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-side verify re-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.mdthe canonical schemas: payment_gateways, payment_transactions (and its two NEW filtered uniques), payment_webhook_events (field table + the UNIQUE(provider_code, external_event_id) idempotency key), and ledger_entries (the field table, the account_type set, the canonical-postings table). Mirror field names exactly. (refunds, nurse_clawbacks, invoices in this doc are b11 — read for context only.)
  • Contract conventions: ../../contracts/conventions/api-conventions.md and money-and-types.mdIRR BIGINT serialized as a string of digits on the wire, the envelope, the payment/refund_channel enum codes, Toman is display-only and converted only inside a provider adapter at its boundary.
  • Code to mirror: b9's Features/Booking/** (the ConvertRequestToBooking command + the booking status machine you call/transition), b9's amount-bearing bookings config; b1's typed config accessor; b0's seam registration (ServiceConfiguration/ extension, config-selected impls) and the IFieldEncryptor usage on encrypted columns. Mirror their Features/<Area>/{Commands|Queries}/<Name>/ layout, IEntityTypeConfiguration<T>, and the IUnitOfWork/single-CommitAsync pattern.
  • Prior handoffs: dev/shared-working-context/backend/handoff/after-backend-phase-9.md, …-1.md, …-0.md, and reports/mocks-registry.md (the IFieldEncryptor/ICacheService/IDateTimeProvider rows you reuse, and the IPaymentProvider/ISettlementSplitProvider/IWebhookVerifier/IDistributedLock rows you flip to 🟡).

3. Scope — build this

All money is IRR long / BIGINTno 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: id BIGINT PK; provider_code NVARCHAR(50) (zarinpal / sadad / vandar / jibit …); type NVARCHAR(20) — standard (card IPG) / bnplselects the flow; display_name; config_json NVARCHAR(MAX) — ENCRYPTED via IFieldEncryptor (merchant id, terminal/IBAN registration for the تسهیم split, base_url, sandbox flag — provider-selection / failover config, NEVER per-transaction credentials); is_active BIT; priority INT (failover order); soft-delete + audit.
  • config_json is encrypted at rest and never logged in plaintext. Selection is config-driven: pick the active standard gateway by priority so 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): id BIGINT PK; booking_id BIGINT FK → bookings; customer_id BIGINT FK; gateway_id BIGINT FK → payment_gateways; amount BIGINT (IRR); currency NVARCHAR (always IRR internally); status NVARCHAR(20) — pending / succeeded / failed; gateway_transaction_id; gateway_reference_code NVARCHAR NULL; gateway_response_code; gateway_response_json NVARCHAR(MAX); is_installment BIT; 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.
  • 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: id BIGINT PK; provider_code NVARCHAR(50); external_event_id NVARCHAR(200); event_type NVARCHAR(80); signature_valid BIT; payload_json NVARCHAR(MAX) (raw callback); processing_status NVARCHAR(20) — received / processed / failed / ignored; related_payment_transaction_id BIGINT NULL; received_at, processed_at DATETIME2.
  • 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): id BIGINT PK; transaction_group_id UNIQUEIDENTIFIER (groups the balanced legs of one event); account_type NVARCHAR(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 lists psp_fee_expense/ bad_debt, include them if present in the canonical schema you mirror); nurse_id BIGINT FK NULL (set for nurse_payable / nurse_clawback_receivable); direction NVARCHAR(6)debit / credit; amount_irr BIGINT — always positive; direction carries the sign; booking_id BIGINT FK NULL; source_ref_type NVARCHAR(40) (payment_transaction / refund / nurse_payout / bnpl_transaction / clawback); source_ref_id BIGINT; memo NVARCHAR(300) NULL; created_at DATETIME2 — append-only, never updated.
  • No soft-delete, no audit-modified columns, no UPDATE/DELETE pathledger_entries is append-only; corrections are new balancing rows. Do not configure a ModifiedAt/IsDeleted flow 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) (for GetNursePayableBalance and 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_at from the originating request, b8/b9) has not lapsed.
  • Reads amount = the booking's gross_price_irr (already frozen by b9 — never recompute it here).
  • Calls IPaymentProvider.InitPaymentAsync(bookingId, amountIrr, idempotencyKey, ct) → returns the redirect URL + a deterministic gatewayReferenceCode; persists the pending payment_transactions row (with gateway_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): bookingId present; resolves to a pending_payment booking owned by the caller.
  • Idempotency: a repeat InitiatePayment for a booking that already has a succeeded transaction returns a 409 (the booking is already paid) — do not create a second attempt; the filtered UNIQUE(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):
    1. Verify the callback via IWebhookVerifier.Verify(provider, headers, rawBody)(signatureValid, externalEventId, eventType, parsedPayload). If the signature is invalid, store the event with signature_valid=0, processing_status='ignored', and stop (never mutate money on an unverified callback).
    2. Upsert payment_webhook_events FIRST keyed on (provider_code, external_event_id). If the row already exists (duplicate replay), no-op: mark/leave processing_status and return success without mutating any payment or ledger state. This is the idempotency guarantee — a replayed succeeded must never double-confirm and a replayed settled must never double-count.
    3. On a new event whose event_type indicates success, re-verify server-side (the integration-notes rule — never trust the callback alone): call IPaymentProvider.VerifyAsync(gatewayReferenceCode, expectedAmountIrr, ct) to re-check the amount and reference against the stored pending transaction, then dispatch ConfirmPaymentAndPostLedger (§3.4).
    4. Set processing_status='processed', processed_at, and related_payment_transaction_id.
  • The whole thing is wrapped in a Redis lock(booking:{id}:payment) via IDistributedLock so 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):
    1. Mark the payment_transactions row status='succeeded' — the filtered UNIQUE(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").
    2. Post the card-capture group to ledger_entries under one fresh transaction_group_id, reading the booking's three frozen amounts:
      DEBIT  escrow_held        gross_price_irr
        CREDIT platform_revenue   balinyaar_commission_irr
        CREDIT nurse_payable      nurse_payout_amount      (nurse_id set; = gross  balinyaar_commission)
      
      The group must balance: Σdebit (gross) = Σcredit (commission + payout). amount_irr is positive on every row; direction carries the sign. source_ref_type='payment_transaction', source_ref_id=paymentTransactionId, booking_id set, created_at from IDateTimeProvider.
    3. Register the تسهیم split via ISettlementSplitProvider.RegisterSplitAsync(bookingId, legs, ct) where legs = [(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 returns Settled.
    4. Trigger ConvertRequestToBooking (from b9) — or, if the booking row was already created at request-conversion time per b9's design, transition it pending_payment → confirmed. Follow whichever b9 actually did; do not duplicate the conversion/amount logic — call b9's command.
  • 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 ledgerAsNoTracking(), 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 / GetCommissionIncomeQuery as the same shape over their account types — thin admin reads; build nurse_payable now, the others are trivial siblings.)

3.6 DEFERRED (do not build; leave the account type / seam / pointer)

  • Refunds, clawbacks, invoicesrefunds (1:N, fee/payout decomposition, refund_channel), nurse_clawbacks, the refund/clawback ledger postings, and invoices (VAT on commission) are owned by b11. This phase defines the refund_payable and nurse_clawback_receivable account types in the ledger so b11 just posts against them, and exposes IPaymentProvider.RefundAsync in the seam (mock returns Succeeded) so b11 can call it — but builds no refund table or flow. (DEFERRED → b11.)
  • BNPL settle — the bnpl_transactions table, the BNPL-settle ledger group (card-capture legs plus DEBIT bnpl_fee_expense / CREDIT escrow_held so escrow reflects net cash), and the IBnplProvider seam are owned by b12. This phase defines bnpl_fee_expense and routes BNPL callbacks through the same payment_webhook_events idempotency store. (DEFERRED → b12.)
  • Payoutsnurse_payout_batches / nurse_payouts / nurse_payout_booking_links and the payout ledger movement (DEBIT nurse_payable / CREDIT escrow_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 InitPaymentAsyncdeterministic gatewayReferenceCode + a fake redirect URL; VerifyAsyncinstant Succeeded, echoes the amount (re-checks reference); RefundAsync → always Succeeded (for b11 to call). No external call. add a new row (🟡)
ISettlementSplitProvider introduced here (تسهیم) RegisterSplitAsyncaccepts any legs whose sum = gross, returns Registered then instant Settled; GetSplitStatusAsyncSettled. The platform never moves money — the mock just records the split intent. add a new row (🟡)
IWebhookVerifier introduced here VerifysignatureValid=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 a decimal/double on the money path.
  • Idempotency: always upsert payment_webhook_events on (provider_code, external_event_id) FIRST and no-op on duplicate — inside the same DB transaction that mutates payment state — so a replayed succeeded never double-confirms and a replayed settled never 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_entries is the single source of truth; every money event posts balanced rows; balances are derived by filter, never stored in a drifting column. (The payout_released BIT stayed CUT in b9 for exactly this reason.)
  • The card-capture posting is balanced: **DEBIT escrow_held gross = CREDIT platform_revenue commission
    • nurse_payable payout**, all under one transaction_group_id, amount_irr positive with direction carrying the sign, Σdebit = Σcredit. The three amounts are never conflated and come frozen from the booking (b9) — never recomputed here.
  • ledger_entries is append-only — never UPDATE or DELETE a 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 the UNIQUE(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) via IDistributedLock, 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 ISettlementSplitProvider to 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 by payment_gateways config. The ledger must survive a provider cut-off mid-cycle (Toman/Jibit Nov-2024 precedent).
  • payment_gateways.config_json is 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 their IEntityTypeConfiguration<T>s: the two filtered uniques on payment_transactions (gateway_reference_code WHERE NOT NULL; booking_id WHERE status='succeeded'), the UNIQUE(provider_code, external_event_id) on payment_webhook_events, the six (or eight) account_types and the append-only (no soft-delete/no-modify) config on ledger_entries, and the config_json encryption on payment_gateways.
  • InitiatePayment, HandlePaymentWebhook, ConfirmPaymentAndPostLedger, and GetNursePayableBalance are implemented per §3, behind the four seams, with FluentValidation on the input-bearing commands and AsNoTracking() + .Select(...) projection on the balance query.
  • The webhook handler upserts payment_webhook_events first and no-ops on duplicate, inside one transaction wrapped in IDistributedLock(booking:{id}:payment); the card-capture ledger group is balanced (Σdebit = Σcredit) and triggers b9's ConvertRequestToBooking/pending_payment→confirmed.
  • IPaymentProvider, ISettlementSplitProvider, IWebhookVerifier, IDistributedLock are introduced as Application interfaces with Infrastructure mocks, DI-registered via a ServiceConfiguration/ extension (config-selected; no if (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 succeeded transaction for a booking is blocked by the filtered unique; GetNursePayableBalance equals the signed ledger sum; an unverified-signature callback mutates nothing. ≥1 WebApplicationFactory integration test for POST api/v1/bookings/{id}/payments (happy path, 401, validation 400) and the webhook ingest (happy + duplicate-replay). dotnet build Baya.sln zero new warnings; dotnet test Baya.sln green (a reachable SQL Server is required — the filtered uniques are the test's whole point).
  • The Project map in server/CLAUDE.md reflects the Features/Payments/** area, the four tables, and the four new seams + where they're registered.
  • The contract dev/contracts/domains/payments.md is written and the swagger.json snapshot 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.

  1. Initiate a paymentPOST api/v1/bookings/{bookingId}/payments (as the customer) → 200 with a redirect URL + a pending payment_transactions row carrying the mock's deterministic gateway_reference_code. (No ledger rows yet, booking still pending_payment.)
  2. A webhook confirms itPOST api/v1/webhooks/payments/{provider} with a succeeded event for that reference → the transaction flips to succeeded; one balanced ledger group appears (DEBIT escrow_held 23300000 = CREDIT platform_revenue 3495000 + nurse_payable 19805000); the booking converts/confirms (pending_payment → confirmed, b9). Verify Σdebit = Σcredit for the group.
  3. Replaying the same webhook event is a no-op — POST the same external_event_id again → 200, but no second confirm and no second ledger group (the payment_webhook_events upsert short-circuits). Query the ledger: still exactly one capture group; payment_webhook_events still one row.
  4. GetNursePayableBalance reflects the accrualGET api/v1/nurses/{nurseId}/payable_balance19805000 (the credited nurse_payable, signed by direction). It is computed from the ledger, not a column.
  5. A second succeeded transaction for the same booking is blocked — attempt to confirm a different transaction for the same booking (or initiate again after capture) → blocked by the filtered UNIQUE(booking_id) WHERE status='succeeded' (409/idempotent no-op), never a second capture.
  6. Unverified callback mutates nothing — POST a webhook the mock verifier marks signature_valid=false → stored with processing_status='ignored', no transaction flip, no ledger rows.
  7. Encrypted gateway config — inspect payment_gateways.config_json in the DB → ciphertext, not plaintext; the active standard gateway is selected by type + priority.

8. Hand off & document (close the phase)

  • Docs to update: the Project map in server/CLAUDE.md (add the Features/Payments/** area, the four payments-core tables, the append-only ledger_entries note, and the four new seams + where they're registered). If you decide/confirm a rule the product/ 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.md or ../../../product/payments/escrow-ledger.md — don't invent rules. Note the new IPaymentProvider/IWebhookVerifier/ISettlementSplitProvider/IDistributedLock pattern in server/CONVENTIONS.md if it establishes a reusable money-path shape (lock-then-DB-constraint).
  • Contract to write: dev/contracts/domains/payments.md (per ../../contracts/domains/_TEMPLATE.md) — document POST 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, the processing_status enum), GET api/v1/nurses/{nurseId}/payable_balance (derived IRR BIGINT balance, authorization); the payment status enum (pending/succeeded/failed), the account_type set, the gateway.type enum (standard/bnpl); state that money is IRR BIGINT serialized 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 the swagger.json snapshot 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 to backend/STATUS.md, write dev/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, the account_types reserved for b11b13, contracts produced, follow-ups), and update dev/shared-working-context/reports/mocks-registry.md (the four new rows → 🟡).
  • Memory: save a project memory note for the non-obvious decisions this phase fixes — the upsert-webhook-event-first-then-no-op idempotency ordering; the two filtered uniques on payment_transactions as the anti-double-capture backstop; the balanced card-capture posting (DEBIT escrow_held gross = CREDIT platform_revenue + nurse_payable) and the six account_types; the append-only, derive-balances-by-filter ledger discipline; the lock-first / DB-constraint-backstop pattern via IDistributedLock; and the four money-path seams (PSP / تسهیم / webhook-verify / lock, mock-now/real-later) — with a one-line pointer in MEMORY.md.