# 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`. **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).