# 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](./backend-phase-9.md) (bookings + the three-amount split + `ConvertRequestToBooking`), [b1](./backend-phase-1.md) (typed cached `platform_configs`), [b0](./backend-phase-0.md) (`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`](../_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](./backend-phase-9.md) 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](./backend-phase-9.md) 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_payment` → `confirmed` → `in_progress` → `completed` → `disputed`/`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](./backend-phase-1.md) 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 & plumbing** — [b0](./backend-phase-0.md) 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` 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` 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_type`s they all post against are built here. ## 2. Required reading (do this first) - [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and [`../_shared/backend-conventions-checklist.md`](../_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`](../../../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`](../../../product/payments/index.md) and [`../../../product/payments/escrow-ledger.md`](../../../product/payments/escrow-ledger.md) — **the canonical ledger postings** (the six `account_type`s 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.md`](../../../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`](../../../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.md`](../../../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 + 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`](../../contracts/conventions/api-conventions.md) and [`money-and-types.md`](../../contracts/conventions/money-and-types.md) — **IRR `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//{Commands|Queries}//` layout, `IEntityTypeConfiguration`, 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` / `BIGINT` — **no floats anywhere**. The payments features live under `Baya.Application/Features/Payments/{Commands|Queries}//`; the entities in `Baya.Domain/Entities/Payments/`; one `IEntityTypeConfiguration` 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) / **`bnpl`** — *selects 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`](../../../product/data-model/06-payments-ledger-and-refunds.md)): `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`](../../../product/data-model/06-payments-ledger-and-refunds.md)): `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 path** — `ledger_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](./backend-phase-9.md)) — *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 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` / `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, invoices** — `refunds` (1:N, fee/payout decomposition, `refund_channel`), `nurse_clawbacks`, the refund/clawback ledger postings, and `invoices` (VAT on commission) are owned by **[b11](./backend-phase-11.md)**. 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](./backend-phase-12.md)**. This phase defines `bnpl_fee_expense` and routes BNPL callbacks through the **same** `payment_webhook_events` idempotency store. (DEFERRED → b12.) - **Payouts** — `nurse_payout_batches` / `nurse_payouts` / `nurse_payout_booking_links` and the payout ledger movement (DEBIT `nurse_payable` / CREDIT `escrow_held`) are owned by **[b13](./backend-phase-13.md)**, 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`](../../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](../_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`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_type`s 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 payment** — `POST 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 it** — `POST 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 accrual** — `GET api/v1/nurses/{nurseId}/payable_balance` → `19805000` (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`](../../../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`](../../../product/business/08-payments-and-escrow.md) or [`../../../product/payments/escrow-ledger.md`](../../../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`](../../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`](../../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_type`s reserved for b11–b13, 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_type`s; 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`.