Files
2026-06-28 21:59:59 +03:30

440 lines
38 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 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_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`.