Files
baya-monorepo/docs/rules/server/money.md
T
2026-07-30 02:26:52 +03:30

14 KiB
Raw Blame History

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_transactionsUNIQUE(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 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).