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