Files
baya-monorepo/archive/docs/rules/server/money.md
T
2026-08-02 20:01:31 +03:30

245 lines
14 KiB
Markdown
Raw 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.
# 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 (~710) |
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).