← Payments overview

# Escrow as an Internal Ledger, Not Held Cash

Because Balinyaar cannot custody buyer funds (§2.2), "escrow" must be a software construct: a double-entry ledger STATE over money that legally sits at a licensed provider/bank. The original ~45-table model had no ledger — escrow was only inferable by joining `bookings.status`, `bookings.payout_released`, `payment_transactions.status`, and `refunds`, with no single answer to "how much do we owe nurses right now?" Three independent critiques rated this a critical gap. The fix is one append-only table.

The ledger entity is detailed in the data model: see payments, ledger & refunds.

## 3.1 `ledger_entries` — the financial source of truth #

Append-only, never updated or deleted. Every money event posts balanced rows sharing a `transaction_group_id` (Σ debit = Σ credit). Per-nurse balances are derived by filter, never cached in a drifting wallet-balance column.

Account types:

account_type | Meaning
--- | ---
`escrow_held` | Funds received and held (over provider custody) not yet released or refunded
`platform_revenue` | Balinyaar's own commission income
`nurse_payable` | What the platform owes the nurse (accrued, awaiting weekly payout)
`refund_payable` | Amount owed back to the customer / in-flight reversal
`bnpl_fee_expense` | The BNPL provider's merchant commission — a platform expense
`nurse_clawback_receivable` | Money a nurse owes back after a refund-after-payout

## 3.2 The postings #

Amounts are positive; `direction` carries the sign. The three-amount split (`gross_price_irr`, `balinyaar_commission_irr`, `bnpl_commission_irr`) is defined in §7.

(a) Card payment capture (inbound):

    DEBIT  escrow_held         gross_price_irr
      CREDIT platform_revenue    balinyaar_commission_irr
      CREDIT nurse_payable       nurse_payout_amount        (= gross − balinyaar_commission)

(b) BNPL settle (inbound) — identical to a card capture, plus the provider-fee leg:

    DEBIT  escrow_held         gross_price_irr
      CREDIT platform_revenue    balinyaar_commission_irr
      CREDIT nurse_payable       nurse_payout_amount
    DEBIT  bnpl_fee_expense    bnpl_commission_irr
      CREDIT escrow_held         bnpl_commission_irr        (escrow reflects NET cash actually received)

Posted once, idempotently, keyed on the settling transaction. No installment-level postings — the customer's repayment schedule is SnappPay's ledger, not ours.

(c) Refund — BEFORE the nurse is paid out (clean reversal):

    DEBIT  platform_revenue    platform_fee_refunded_irr
    DEBIT  nurse_payable       nurse_payout_refunded_irr
      CREDIT refund_payable      (sum)

Clear `refund_payable` when the PSP / SnappPay confirms the customer cash-back. Nothing leaves Balinyaar toward the nurse — the `nurse_payable` accrual is simply reversed.

(d) Clawback — refund AFTER the nurse was already paid: The nurse's `nurse_payable` was already drained by a processed payout batch, so there is nothing left to reverse. Instead the platform books a receivable:

    DEBIT  nurse_clawback_receivable   amount_irr   (nurse_id set; nurse now owes the platform)
      CREDIT refund_payable               amount_irr

Recovered by netting against the nurse's next `nurse_payable` at batch time, or marked `written_off` if uncollectable. A `nurse_clawbacks` row carries the lifecycle (`pending` / `recovered` / `written_off`). This is unavoidable because Iranian payouts are real bank transfers — hard/impossible to reverse — so the right defense is gating payout on the dispute window, with clawback as the fallback.

## 3.2a Implementation rules confirmed in build (backend b10) #

These are the exact orderings the card-capture rail was built to (see `dev/contracts/domains/payments.md`):

- Upsert the webhook event first, then re-verify server-side, then confirm. Every PSP callback lands in

`payment_webhook_events` keyed on `UNIQUE(provider_code, external_event_id)` before any money state changes; a duplicate replay is a no-op. Only a new, signature-valid success event proceeds — and it is re-verified against the gateway (amount + reference) before the capture posts. A callback with an invalid signature is stored `ignored` and mutates nothing (never trust a callback alone).

- A unique-violation on confirm is an idempotent no-op success, not an error. The filtered

`UNIQUE(booking_id) WHERE status='succeeded'` is the authoritative anti-double-capture backstop: if a concurrent confirm races past the Redis lock, the second one fails the constraint and is treated as "already captured → success".

- **Payment is initiated against the accepted request, not a booking.** Because a `bookings` row exists only

on capture, `payment_transactions.booking_id` is nullable and bound at confirm (when the booking is created/loaded). The captured gross is the request's frozen amount (variant price × session count).

## 3.3 Why the ledger, not more columns #

A marketplace that holds escrow, pays out weekly minus commission, and handles refunds + clawbacks has exactly the shape double-entry was invented for. The MVP cost is one table + posting discipline. The alternative (more money columns on bookings/payouts) cannot answer "how much is held but unreleased" without fragile joins and makes bank/Shaparak reconciliation nearly impossible. Keep the per-booking fee snapshot as the pricing record; the ledger is the financial-truth / reconciliation layer posted alongside.

↑ Back to top