# 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<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_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/<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` / `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: `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<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_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`.
