Domain 6 — Payments, Ledger & Refunds

← Database Model

Related: business requirements — Payments & escrow. Ledger postings are explained in depth in Escrow ledger.

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.

FieldTypeNotes
baseline fieldsid, 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
constraintsNEW: 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.

FieldTypeNotes
idBIGINT PK
provider_codeNVARCHAR(50)zarinpal / snapppay / …
external_event_idNVARCHAR(200)UNIQUE(provider_code, external_event_id)
event_typeNVARCHAR(80)
signature_validBIT
payload_jsonNVARCHAR(MAX)Raw callback
processing_statusNVARCHAR(20)received / processed / failed / ignored
related_payment_transaction_idBIGINT NULL
received_at, processed_atDATETIME2

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

FieldTypeNotes
baselineid, 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
cardinalityCHANGED to 1:N per payment_transaction (app invariant: Σ refunded ≤ captured). The old "1:1" relationship summary was wrong.
platform_fee_refunded_irrBIGINTNEW — fee-leg decomposition
nurse_payout_refunded_irrBIGINTNEW — payout-leg decomposition (drives clawback if nurse already paid)
refund_channelNVARCHAR(20)NEWpsp_card / bnpl_revert / manual_bank
external_revert_referenceNVARCHAR(200) NULLNEW — BNPL revert id
expected_customer_refund_etaDATE NULLNEW — the ~7–10 business-day BNPL window, surfaced in UI/reconciliation
cancellation_policy_code / refund_percentage_appliedNEW — 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.

FieldTypeNotes
idBIGINT PK
transaction_group_idUNIQUEIDENTIFIERGroups the balanced legs of one event
account_typeNVARCHAR(40)escrow_held / platform_revenue / nurse_payable / refund_payable / bnpl_fee_expense / psp_fee_expense / nurse_clawback_receivable / bad_debt
nurse_idBIGINT FK NULLSet for nurse_payable/nurse_clawback_receivable
directionNVARCHAR(6)debit / credit
amount_irrBIGINTAlways positive; direction carries the sign
booking_idBIGINT FK NULL
source_ref_typeNVARCHAR(40)payment_transaction / refund / nurse_payout / bnpl_transaction / clawback
source_ref_idBIGINT
memoNVARCHAR(300) NULL
created_atDATETIME2Append-only; never updated

Canonical postings:

EventDebitCredit
Card captureescrow_held (gross)platform_revenue (commission) + nurse_payable (payout)
BNPL settleas 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_payableescrow_held on confirm
Clawback (post-payout)nurse_clawback_receivable (+ platform_revenue leg)refund_payable
Clawback recoverednurse_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.

FieldTypeNotes
idBIGINT PK
nurse_idBIGINT FK → nurse_profiles
booking_idBIGINT FK → bookings
refund_idBIGINT FK → refunds
original_payout_idBIGINT FK → nurse_payouts NULL
amount_irrBIGINT
statusNVARCHAR(30)pending / recovered / written_off
recovered_in_payout_idBIGINT FK NULLBatch that netted it
created_at, resolved_atDATETIME2

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.

FieldTypeNotes
idBIGINT PK
booking_idBIGINT FK → bookings
invoice_numberNVARCHAR(40) UNIQUEOfficial sequential number
issuing_entity_typeNVARCHAR(20)platform / partner_center
gross_irrBIGINT
platform_commission_irrBIGINTThe VAT-relevant line
bnpl_commission_irrBIGINT NULL
vat_rateDECIMAL(5,4)Config-driven (0.10)
vat_irrBIGINT
moadian_reference_numberNVARCHAR(40) NULLسامانه مودیان 22-digit ref when issued
moadian_statusNVARCHAR(20) NULL
pdf_storage_keyNVARCHAR(512) NULL
issued_atDATETIME2

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

↑ Back to top