14 KiB
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 §5.
4. Capture and conversion
- A
bookingsrow exists only when the nurse accepted and payment was captured. So a payment is initiated against theaccepted_awaiting_paymentrequest, andpayment_transactions.booking_idis nullable, bound only when the confirm creates or loads the booking. - A booking request carries no money and no
bookingsrow. Accept only opens the payment window. - Conversion goes through the shared
BookingFactory/Features/Bookings/BookingConversionhelper. The card confirm and the BNPL settle both call it rather than re-implementing the split. IPaymentCaptureSimulatoris out of the production registration — production gets the fail-closedDisabledPaymentCaptureSimulator, and thebookings/convertpath 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.
BnplTransactionsis 1:1 with itspayment_transaction(UNIQUE(payment_transaction_id)).- The forward-only machine is
eligible → token_issued → verified → settled → reverted/cancelled/failed(BnplTransitions), mutated only through the entity'smark-*methods. - Settle posts the net-of-fee group (§2) so escrow reflects the net cash
(
settled_amount_irr = order − commission), and confirms the parentpayment_transaction— which triggers the booking conversion — exactly like a card capture. - The nurse's payout is invariant to payment method.
nurse_payablecomes from the booking split (gross − commission), never fromsettled_amount_irr. The BNPL commission is a platform expense. settled_atis 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. IBnplProvideris selected perprovider_codebyIBnplProviderResolver.balinyaaris 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'ANDdispute_window_ends_at < nowAND it has no active refund AND it isn't already in a link row.SetDisputeWindowis 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_idis 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 postsClawbackRecoveryand marks thenurse_clawbacksrowrecovered(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 − clawbackis a DB CHECK onNursePayouts.iban_snapshotis encrypted and[AuditRedacted], frozen from the verified primary account.- Holiday-aware.
period_endandprocessing_dateshift offis_bank_closeddays viaIHolidayCalendar; 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=1is 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
PayoutStatusmachine, the ledger-exists guard, and a batch idempotency key together. IBankTransferProvideris the PAYA/SATNA rail; PAYA vs SATNA is chosen by thepayout_satna_threshold_irrconfig. The real Jibit adapter is async: it accepts assubmitted, and the HMAC-verified callbackPOST webhooks/payouts/{provider}→ReconcilePayoutBatchCommandflipssubmitted → paid/failed.- The BNPL
settled_atguard is the default-offrequire_bnpl_settlement_for_payoutflag.
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)(configvat_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
InvoiceNumberSequencescounter, 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 itis_merchant_of_record, elseplatform. Never a hardcoded platform. IMoadianClientsubmits to سامانه مودیان; the mock leavesmoadian_status = pendingwith no reference. AMoadianReconciliationJobwalkspending/submitted → registeredevery 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).