121 lines
9.5 KiB
Markdown
121 lines
9.5 KiB
Markdown
# 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).
|