440 lines
38 KiB
Markdown
440 lines
38 KiB
Markdown
# 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`.
|