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
+80
View File
@@ -0,0 +1,80 @@
[← Payments overview](index.md)
# 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](../data-model/06-payments-ledger-and-refunds.md).
## 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.