# Server money path

IRR integers, the append-only ledger, idempotency, and the invariants of refunds, BNPL, payouts and invoices.

> Last verified: 2026-07-30 against commit `d3ec723`.

Read this before touching anything under `Features/{Payments,Refunds,Invoices,Bnpl,Payouts}` or the
`payments` / `payouts` schemas. Every rule here is enforced in code **and** by a database constraint, and the
constraint is the authority.

---

## 1. Money is IRR `BIGINT`, integer-only

**Every monetary value is IRR Rials stored as `long` / `BIGINT`.** There is **no float or decimal path on
money** — not in entities, not in DTOs, not in the API, not in arithmetic. If a money value object is ever
introduced it must be integer-only.

- **Toman is display-only**, and converts to/from Rials **only inside a provider adapter at its boundary** —
  never in domain or shared code.
- On the wire, money is a **digit string** (IRR aggregates exceed JS's safe integer range).
- Currency is normalized to IRR **at the provider boundary only**, via `ICurrencyNormalizer`.

### The three booking amounts always reconcile

```
gross_price_irr = balinyaar_commission_irr + nurse_payout_amount        (all ≥ 0)
```

This is a **DB CHECK** *and* a handler invariant. Commission is `integer-round(gross × platform_fee_rate)`
with the rate **snapshotted onto the booking**; the payout is *derived*, never free-entered.

And per session: **`Σ(visit_payout_amount) = nurse_payout_amount` exactly** — an integer split with the
remainder on the last session (`BookingAmounts`).

### A rate change is never retroactive

Money-critical constants — commission percentage, VAT rate, deadlines, cancellation tiers — live in
`ops.PlatformConfigs` and are read via `IPlatformConfig.GetConfig<T>`. **Never hardcode one.**

> **Changing a rate must never retroactively alter an already-computed amount.** The rate is snapshotted at
> compute time. Do not live-re-read a rate for an already-priced row.

---

## 2. The ledger is the source of truth

`payments.LedgerEntries` is **append-only**: it implements `IEntity` only, with **no `ITimeModification`** (so
the audit interceptor never stamps it) and **no soft delete**. There is no update or delete path.

Every posting group is **balanced** — Σdebit = Σcredit per `transaction_group_id` — and built through
**`LedgerPosting`**, which throws if the frozen amounts don't reconcile. Never hand-write a leg.

| Posting group | Legs |
| --- | --- |
| `CardCapture` | DEBIT `escrow_held` gross = CREDIT `platform_revenue` commission + `nurse_payable` payout |
| `BnplSettle` | The card-capture legs **plus** DEBIT `bnpl_fee_expense` / CREDIT `escrow_held` for the provider commission |
| `RefundReversalPrePayout` | DEBIT `nurse_payable` — a clean reversal |
| `ClawbackReversalPostPayout` | DEBIT `nurse_clawback_receivable` — the nurse was already paid |
| `RefundPayableClearing` | Posted only once the customer cash-back confirms |
| `ClawbackWriteOff` | An admin write-off |
| `NursePayout` | DEBIT `nurse_payable` / CREDIT `escrow_held` for the paid net |
| `ClawbackRecovery` | DEBIT `nurse_payable` / CREDIT `nurse_clawback_receivable` |

**Escrow IS the ledger.** `GetNursePayableBalance` is the **signed sum** over `nurse_payable` legs — never a
stored column. There is no `payout_released` boolean anywhere: paid-ness is *derived* from a
`nurse_payout_booking_links` row plus the ledger.

The lawful split is **تسهیم via `ISettlementSplitProvider`** to registered IBANs. **The platform never moves
money itself.**

---

## 3. Idempotency

Three patterns, all mandatory on this path.

**Upsert the webhook event first.** `HandlePaymentWebhook` upserts on `(provider_code, external_event_id)` and
**no-ops on a duplicate** before doing anything else. On a *new* success event it **re-verifies server-side**
(`IPaymentProvider.VerifyAsync`) — never trusting the payload — then dispatches
`ConfirmPaymentAndPostLedger`, all under `IDistributedLock("booking-request:{id}:payment")`.
**A unique-violation on confirm is an idempotent no-op success, not an error.**

**Claim first, execute second.** Persist the state claim *before* the external call. The refund row is
persisted (approved) before the channel call for exactly this reason — it is the crash-window fix, and it
matches the webhook handler's shape. A crash between claim and execute leaves a recoverable record; a crash
between execute and claim leaves money moved with nothing recording it.

**The DB constraint is the authoritative backstop** behind every friendly pre-check. The two filtered uniques
on `payment_transactions` — `UNIQUE(gateway_reference_code) WHERE NOT NULL` and
`UNIQUE(booking_id) WHERE status='succeeded'` — are the anti-double-capture guard, not the handler's `if`.

A **forward-only status machine** is the idempotency spine of each money entity: a replayed transition that
would re-drive a completed edge is an idempotent no-op. See [persistence.md](persistence.md) §5.

---

## 4. Capture and conversion

- A `bookings` row exists **only** when the nurse accepted **and** payment was captured. So a payment is
  initiated against the `accepted_awaiting_payment` **request**, and `payment_transactions.booking_id` is
  **nullable**, bound only when the confirm creates or loads the booking.
- A booking request carries **no money and no `bookings` row**. Accept only opens the payment window.
- Conversion goes through the shared **`BookingFactory` / `Features/Bookings/BookingConversion`** helper. The
  card confirm and the BNPL settle both call it rather than re-implementing the split.
- `IPaymentCaptureSimulator` is **out of the production registration** — production gets the fail-closed
  `DisabledPaymentCaptureSimulator`, and the `bookings/convert` path is a Dev/Testing affordance. Production
  converts through the webhook confirm.

---

## 5. Refunds and clawbacks

A refund **decomposes across both fee legs and reverses the ledger.** `CreateRefundCommand` runs the whole
money path under `lock(booking:{id}:refund)`: it reads the booking's frozen split, the cancellation snapshot
and the captured transaction, splits `amount = platform_fee_refunded_irr + nurse_payout_refunded_irr`
**pro-rata at the resolved percentage**, enforces **`Σ refunded ≤ captured`** as a handler backstop, executes
the channel behind its seam, and posts the balanced reversal through `LedgerPosting`.

The channel-execution and ledger steps are cohesive **private** steps inside the handler, so they stay atomic.

### The pre-payout / post-payout fork

`INursePayoutStatus` answers *"was the nurse already paid?"*

| Answer | What the reversal debits | Plus |
| --- | --- | --- |
| Not yet paid | `nurse_payable` — a clean reversal | — |
| Already paid | `nurse_clawback_receivable` | Opens a `pending` `nurse_clawbacks` row **and** raises a `nurse_clawback` support alert |

The fork exists because **an Iranian IBAN transfer is irreversible.** Once money has left, the platform holds a
receivable, not a reversal.

The authoritative implementation is `NursePayoutLinkStatusService` — a booking is paid iff it is linked to a
`paid` payout.

### Channel parity

`psp_card` and `bnpl_revert` post the **same** reversal legs. Only three things differ:

| | `psp_card` | `bnpl_revert` |
| --- | --- | --- |
| Initial status | immediate `succeeded` | `processing` |
| Clearing | posts now | deferred to reconciliation |
| Customer ETA | immediate | `expected_customer_refund_eta` ≈ now + config **business** days (~7–10) |

The `refund_payable ↔ escrow_held` clearing posts **only once the customer cash-back confirms** — reached by
`ConfirmRefundSettlementCommand` (admin `POST admin_refunds/{id}/confirm_settlement`, or the BNPL cash-back
callback branch), which transitions `processing → succeeded`, stamps the settled instant, and posts
`RefundPayableClearing` in the same commit, idempotently under the refund lock.
`MarkRefundSettlementFailedCommand` is the counterpart.

The canonical wire code for the manual channel is **`manual`** (the data model calls it `manual_bank`).

**Clawback recovery is the payout engine's job** (§7), not the refund's. A refund only opens the receivable and
supports an admin `write_off`.

`refunds.ticket_id` is always non-null — `CreateRefundCommand` auto-opens a `category=refund` ticket when the
caller supplies none.

---

## 6. BNPL — provider-financed installments

**In our books, a BNPL order is a card payment that lands net-of-fee.** There is no customer-installment
tracking on our side: the provider owns the schedule and **100% of the default risk**.

- `BnplTransactions` is **1:1 with its `payment_transaction`** (`UNIQUE(payment_transaction_id)`).
- The forward-only machine is `eligible → token_issued → verified → settled → reverted/cancelled/failed`
  (`BnplTransitions`), mutated only through the entity's `mark-*` methods.
- **Settle** posts the net-of-fee group (§2) so escrow reflects the **net** cash
  (`settled_amount_irr = order − commission`), and confirms the parent `payment_transaction` — which triggers
  the booking conversion — exactly like a card capture.
- **The nurse's payout is invariant to payment method.** `nurse_payable` comes from the booking split
  (`gross − commission`), **never** from `settled_amount_irr`. **The BNPL commission is a platform expense.**
- **`settled_at` is per-transaction and nullable** — never assume it is instant. The commission is read from
  the **actual settlement**, never hardcoded.
- **Revert reuses the refund path** with `refund_channel='bnpl_revert'`. Money flows
  customer ↔ provider ↔ Balinyaar only.
- `IBnplProvider` is selected per `provider_code` by `IBnplProviderResolver`. **`balinyaar` is the in-house
  provider** and resolves to the net-of-fee model with no external API.
- `bnpl_settlement_entries` (tranched settlement) is **deferred — modelled but not built.** Do not create it.

---

## 7. Weekly payouts

- **Eligibility ≠ completed.** A booking enters a batch only when `status='completed'` **AND**
  `dispute_window_ends_at < now` **AND** it has no active refund **AND** it isn't already in a link row.
  `SetDisputeWindow` is the only eligibility trigger:
  `dispute_window_ends_at = completed_at + config(dispute_window_hours, 72)`.
- **One payout per booking, forever.** `nurse_payout_booking_links.booking_id` is an **unconditional** UNIQUE —
  *not* filtered on soft-delete. The "not already linked" filter is the fast first line; the UNIQUE is the
  backstop.
- **The payout drains `nurse_payable`.** A netted clawback posts `ClawbackRecovery` and marks the
  `nurse_clawbacks` row `recovered` (`recovered_in_payout_id` + `resolved_at`). **Netting recovers WHOLE
  pending clawbacks up to earnings** — never a negative net, never a partial single-clawback recovery.
- **`net = gross − clawback`** is a DB CHECK on `NursePayouts`. `iban_snapshot` is **encrypted** and
  `[AuditRedacted]`, frozen from the verified primary account.
- **Holiday-aware.** `period_end` and `processing_date` shift off `is_bank_closed` days via
  `IHolidayCalendar`; a retry **refuses on a bank-closed day**.
- **First-payout gate.** Only an account with `is_primary=1 AND is_verified=1 AND matched_national_id=1` is
  paid. A nurse without one is **skipped with a recorded reason**, never silently.
- **A retried process never double-sends an irreversible transfer**: the forward-only `PayoutStatus` machine,
  the ledger-exists guard, and a batch idempotency key together.
- `IBankTransferProvider` is the PAYA/SATNA rail; PAYA vs SATNA is chosen by the `payout_satna_threshold_irr`
  config. The real Jibit adapter is **async**: it accepts as `submitted`, and the HMAC-verified callback
  `POST webhooks/payouts/{provider}` → `ReconcilePayoutBatchCommand` flips `submitted → paid/failed`.
- The BNPL `settled_at` guard is the default-off `require_bnpl_settlement_for_payout` flag.

### Money movement stays human-approved

The `weekly_payout_generation` job schedules **generation only** — a `draft` batch, recorded system-initiated
(`NursePayoutBatch.InitiatedByAdminId` nullable = "no human initiator"). **The irreversible `process` step
remains an explicit admin action**, and `AdminPayoutsController` **neutralizes any request-supplied
`SystemInitiated` value** — that flag is scheduler-only.

---

## 8. Invoices

- **VAT is on the commission line only**: `vat_irr = round(platform_commission_irr × vat_rate)` (config
  `vat_rate`, default 0.10; `vat_rate = 0` ⇒ 0). **Never on the nurse payout.**
- **The invoice number is gap-free and sequential**, drawn from the single-row `InvoiceNumberSequences` counter,
  locked and committed with the invoice — **portable across SQL Server and SQLite, so no DB sequence.**
- **Idempotent per booking** (`UNIQUE(booking_id)`).
- The issuing entity follows the **merchant-of-record resolver**: booking → nurse → `partner_center_id`, and the
  target is the partner centre **only** when it `is_merchant_of_record`, else `platform`. Never a hardcoded
  platform.
- `IMoadianClient` submits to سامانه مودیان; the mock leaves `moadian_status = pending` with no reference. A
  `MoadianReconciliationJob` walks `pending/submitted → registered` every 6 hours.

---

## 9. Cancellation

The applicable `cancellation_policies` tier is resolved by **`(actor, lead-time bucket)`**, and its `code` +
`refund_percentage` + the computed refundable amount are **frozen onto the booking**.

**Only still-`scheduled` sessions are refundable.** A session already started or completed is not, and the
per-session split is what makes a partial refund on a multi-session package correct.

Cancellation itself **posts no refund ledger** — it snapshots the policy and computes the refundable amount.
The reversal is the refund path's job (§5).
