# Domain 6 — Payments, Ledger & Refunds

[← Database Model](index.md)

**Related:** business requirements — [Payments & escrow](../business/08-payments-and-escrow.md). Ledger postings are explained in depth in [Escrow ledger](../payments/escrow-ledger.md).

This is the most-changed domain. The previous model **inferred** money state from scattered status flags; this revision makes a **double-entry ledger** the source of truth and adds the **idempotency** and **clawback** primitives that any real-money platform needs before launch.

### `payment_gateways` [CORE]
**Role:** Config per connected PSP/BNPL provider; credentials encrypted. **Why `type`:** multiple gateways run at once (standard IPG, a BNPL provider, a failover) and `type` (`standard`/`bnpl`) selects the flow. **Why this matters for Iran:** provider cut-offs happen (Toman/Jibit were abruptly suspended in Nov 2024), so the gateway is abstracted to be swappable. BNPL provider secrets (client_id/secret, merchant number, base_url, sandbox flag) live encrypted in `config_json`. Fields unchanged. **Relations:** 1:N → `payment_transactions`, `payment_webhook_events`.

### `payment_transactions` [CORE]
**Role:** Every payment attempt against a booking; the `succeeded` row triggers confirmation. Stores the full `gateway_response_json` and the **Shaparak reference code** (definitive proof) for reconciliation and chargebacks. **Why hardened:** a retried PSP webhook could otherwise insert a second `succeeded` row and double-confirm.

| Field | Type | Notes |
|---|---|---|
| baseline fields | … | `id`, `booking_id`, `customer_id`, `gateway_id`, `amount`, `currency`, `status`, `gateway_transaction_id`, `gateway_reference_code`, `gateway_response_code`, `gateway_response_json`, `is_installment`, `ip_address`, `user_agent`, timestamps |
| **constraints** | | **NEW:** `UNIQUE(gateway_reference_code) WHERE NOT NULL`; filtered `UNIQUE(booking_id) WHERE status='succeeded'` — at most one capturing transaction per booking. |

**Relations:** N:1 → `bookings`, `payment_gateways`; 1:1 → `bnpl_transactions` (if BNPL); 1:N → `refunds`, `ledger_entries`.

### `payment_webhook_events` [CORE] — **NEW**
**Role:** Raw, deduplicated store of every PSP/BNPL callback. **Why mandatory:** callbacks are at-least-once and retried; without a unique store keyed on the provider's event id, a replayed "payment succeeded" double-confirms a booking and a replayed "settled" double-counts money. The handler **upserts here first** and no-ops on a duplicate, inside the same transaction that mutates payment state.

| Field | Type | Notes |
|---|---|---|
| `id` | BIGINT PK | |
| `provider_code` | NVARCHAR(50) | `zarinpal` / `snapppay` / … |
| `external_event_id` | NVARCHAR(200) | **UNIQUE(provider_code, external_event_id)** |
| `event_type` | NVARCHAR(80) | |
| `signature_valid` | BIT | |
| `payload_json` | NVARCHAR(MAX) | Raw callback |
| `processing_status` | NVARCHAR(20) | `received` / `processed` / `failed` / `ignored` |
| `related_payment_transaction_id` | BIGINT NULL | |
| `received_at`, `processed_at` | DATETIME2 | |

**Relations:** N:1 → `payment_gateways`; optional → `payment_transactions`.

### `refunds` [CORE]
**Role:** Admin-initiated refunds (no customer self-service), always linked to a support ticket. **Why these changes:** the previous model treated refunds as 1:1 with a transaction and as a single undecomposed amount — but partials exist (shortened visit) and a refund must say how much reverses the **platform fee** vs the **nurse payout**, and how the money actually moves (card vs BNPL revert).

| Field | Type | Notes |
|---|---|---|
| baseline | … | `id`, `payment_transaction_id`, `booking_id`, `requested_by_customer_id`, `ticket_id`, `amount`, `refund_percentage`, `reason_category`, `reason_notes`, `status`, approval/rejection fields, `gateway_refund_reference`, `processed_at`, `admin_notes`, timestamps |
| **cardinality** | | **CHANGED to 1:N** per `payment_transaction` (app invariant: Σ refunded ≤ captured). The old "1:1" relationship summary was wrong. |
| `platform_fee_refunded_irr` | BIGINT | **NEW** — fee-leg decomposition |
| `nurse_payout_refunded_irr` | BIGINT | **NEW** — payout-leg decomposition (drives clawback if nurse already paid) |
| `refund_channel` | NVARCHAR(20) | **NEW** — `psp_card` / `bnpl_revert` / `manual_bank` |
| `external_revert_reference` | NVARCHAR(200) NULL | **NEW** — BNPL revert id |
| `expected_customer_refund_eta` | DATE NULL | **NEW** — the ~7–10 business-day BNPL window, surfaced in UI/reconciliation |
| `cancellation_policy_code` / `refund_percentage_applied` | … | **NEW** — snapshot of the policy that produced this refund |

**Relations:** N:1 → `payment_transactions`, `bookings`, `customer_profiles`, `tickets`; 1:1 → `nurse_clawbacks` (only when refunding a booking whose nurse was already paid).

### `ledger_entries` [CORE] — **NEW**
**Role:** The append-only, double-entry financial **source of truth**. Every money event posts **balanced** rows sharing a `transaction_group_id` (Σ debit = Σ credit). Per-nurse payable balance derives by filtering `account_type='nurse_payable'` + `nurse_id`. **Why:** a marketplace that holds escrow and pays out weekly minus commission, with refunds and clawbacks, is exactly the shape double-entry was invented for. The alternative (more money columns + status booleans) cannot answer "how much do we owe nurses right now" or reconcile against the bank, and makes refund/clawback second-class. Cost: one table + posting discipline.

| Field | Type | Notes |
|---|---|---|
| `id` | BIGINT PK | |
| `transaction_group_id` | UNIQUEIDENTIFIER | Groups the balanced legs of one event |
| `account_type` | NVARCHAR(40) | `escrow_held` / `platform_revenue` / `nurse_payable` / `refund_payable` / `bnpl_fee_expense` / `psp_fee_expense` / `nurse_clawback_receivable` / `bad_debt` |
| `nurse_id` | BIGINT FK NULL | Set for `nurse_payable`/`nurse_clawback_receivable` |
| `direction` | NVARCHAR(6) | `debit` / `credit` |
| `amount_irr` | BIGINT | Always positive; `direction` carries the sign |
| `booking_id` | BIGINT FK NULL | |
| `source_ref_type` | NVARCHAR(40) | `payment_transaction` / `refund` / `nurse_payout` / `bnpl_transaction` / `clawback` |
| `source_ref_id` | BIGINT | |
| `memo` | NVARCHAR(300) NULL | |
| `created_at` | DATETIME2 | Append-only; never updated |

**Canonical postings:**

| Event | Debit | Credit |
|---|---|---|
| Card capture | `escrow_held` (gross) | `platform_revenue` (commission) + `nurse_payable` (payout) |
| BNPL settle | as card, **plus** `bnpl_fee_expense` (provider commission) | `escrow_held` (provider commission) → escrow reflects net cash actually received |
| Refund (pre-payout) | `nurse_payable` + `platform_revenue` (decomposed legs) | `refund_payable`; later `refund_payable` ↔ `escrow_held` on confirm |
| Clawback (post-payout) | `nurse_clawback_receivable` (+ `platform_revenue` leg) | `refund_payable` |
| Clawback recovered | `nurse_payable` (next batch) | `nurse_clawback_receivable` |

**Relations:** N:1 → `bookings`; logical links to `payment_transactions`/`refunds`/`nurse_payouts`/`bnpl_transactions` via `source_ref_*`.

### `nurse_clawbacks` [CORE] — **NEW**
**Role:** A first-class receivable when a booking is refunded/disputed **after** the nurse was already paid. **Why:** this was the model's own flagged critical gap — Iranian payouts are real, hard-to-reverse bank transfers, so paying before the dispute window closes can create uncollectable loss with nowhere to record it. Closing it = gate payout on `dispute_window_ends_at` **and** record any post-payout recovery here.

| Field | Type | Notes |
|---|---|---|
| `id` | BIGINT PK | |
| `nurse_id` | BIGINT FK → nurse_profiles | |
| `booking_id` | BIGINT FK → bookings | |
| `refund_id` | BIGINT FK → refunds | |
| `original_payout_id` | BIGINT FK → nurse_payouts NULL | |
| `amount_irr` | BIGINT | |
| `status` | NVARCHAR(30) | `pending` / `recovered` / `written_off` |
| `recovered_in_payout_id` | BIGINT FK NULL | Batch that netted it |
| `created_at`, `resolved_at` | DATETIME2 | |

**Relations:** N:1 → `nurse_profiles`, `bookings`; 1:1 → `refunds`; → `nurse_payouts` (original and recovering).

### `invoices` [MVP] — **NEW**
**Role:** Minimal official-invoice/receipt record per booking. **Why:** Iranian commission marketplaces face VAT/مودیان obligations on **their commission** (the Snapp/Tapsi precedent: the provider's earnings are the provider's own income; the platform's commission is the platform's taxable revenue). The smallest footprint that satisfies bookkeeping without a full tax engine. **VAT is 10%** (rose from 9% in 1403) and stored as a **config-driven rate** so a future exemption ruling on the nursing service itself is just a value change. Nurse-side income tax is the nurse's own responsibility.

| Field | Type | Notes |
|---|---|---|
| `id` | BIGINT PK | |
| `booking_id` | BIGINT FK → bookings | |
| `invoice_number` | NVARCHAR(40) UNIQUE | Official sequential number |
| `issuing_entity_type` | NVARCHAR(20) | `platform` / `partner_center` |
| `gross_irr` | BIGINT | |
| `platform_commission_irr` | BIGINT | The VAT-relevant line |
| `bnpl_commission_irr` | BIGINT NULL | |
| `vat_rate` | DECIMAL(5,4) | Config-driven (0.10) |
| `vat_irr` | BIGINT | |
| `moadian_reference_number` | NVARCHAR(40) NULL | سامانه مودیان 22-digit ref when issued |
| `moadian_status` | NVARCHAR(20) NULL | |
| `pdf_storage_key` | NVARCHAR(512) NULL | |
| `issued_at` | DATETIME2 | |

**Relations:** 1:1 → `bookings`; N:1 → `partner_centers` (when issuer).

### As built (backend-phase-11)
- **`refunds.refund_channel` canonical code is `manual`** (not `manual_bank`) for the out-of-band bank refund —
  the two are the same channel; `manual` is the value stored and served. Set: `psp_card` | `bnpl_revert` | `manual`.
- **`refunds.ticket_id`** ships as a **nullable column with no FK** (the `tickets` table arrives in b15); the
  "ticket required" rule is gated by the `refund_ticket_required` config flag (default off).
  **`nurse_clawbacks.original_payout_id` / `recovered_in_payout_id`** and **`invoices.partner_center_id`** are
  likewise nullable, FK-less join points that b13 / b15 fill.
- **`invoices.vat_irr = round(platform_commission_irr × vat_rate)`**, integer-only, on the commission line only; a
  `vat_rate = 0` exemption yields `vat_irr = 0`.
- **`invoices.invoice_number`** is drawn from a single-row **counter table** (`invoice_number_sequences`), locked
  and committed with the invoice insert — gap-free, unique, portable across SQL Server / SQLite (no DB sequence),
  never random/timestamp-derived. One issued invoice per booking (idempotent).
- The refund posts the balanced reversal via the b10 ledger helper; the **`refund_payable ↔ escrow_held` clearing**
  posts immediately for a succeeded card refund and is deferred to reconciliation for a `processing` BNPL/manual
  refund. Card and BNPL post the **same** reversal legs.
