create mvp path
This commit is contained in:
@@ -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 (~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).
|
||||
Reference in New Issue
Block a user