create mvp path

This commit is contained in:
hamid
2026-08-02 20:01:31 +03:30
parent 72ab290da1
commit fb58ca54e1
203 changed files with 863 additions and 156 deletions
+244
View File
@@ -0,0 +1,244 @@
# 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).