# Balinyaar — Database Model (Refined Core)
> **Revision 2 — 2026-06-20.** This is a research-driven refinement of the original 13-domain model. It closes the financial-correctness gaps the previous version flagged in its own _Advices_ section, resolves the two open BNPL questions, and grounds every money decision in verified research on the Iranian payment landscape (SnappPay, Digipay, Tara, Torob Pay, Shaparak/پرداختیار rules). The previous revision is preserved in git history.
>
> Companion documents: **`business-requirements.md`** (per-section product requirements) and **`payments-and-installments.md`** (the BNPL/escrow deep-dive with sources).
---
## Platform Summary
Balinyaar is a trust-first home-nursing marketplace in Iran. Independent, individually-verified nurses register, list configurable services with their own pricing, and pass a multi-step verification pipeline anchored on the MoH **پروانه صلاحیت حرفهای** (professional-competency license). Families search — filtered by city/district **and same-gender caregiver preference** — pick a nurse and a service variant, submit a booking request, and pay **through the platform** after the nurse accepts. The platform records the money as an **internal escrow ledger state** (not platform-held cash — see Principle 2), the nurse performs one or more EVV-verified visits, and the platform pays the nurse **weekly, after the dispute window closes**, minus a platform commission. All post-booking communication runs through an admin-readable ticket system.
At launch the platform operates under a **partner licensed home-nursing center (مرکز مشاوره و ارائه مراقبتهای پرستاری در منزل)** — the Asanism-style model — which is the legal vehicle and the likely **merchant-of-record** for payments while Balinyaar's own MoH permit is in process.
---
## What changed in this revision (decision summary)
| # | Decision | Why | Schema effect |
|---|---|---|---|
| 1 | **Escrow is a ledger _state_, not held cash** | An Iranian پرداختیار (payment facilitator) is legally barred from custodying buyer funds; money flows card → PSP → Shaparak → registered IBANs. | New `ledger_entries` (double-entry); "escrow" derives from it. |
| 2 | **BNPL = full-upfront single settlement** | Verified: SnappPay/Digipay/Tara/Torob pay the *merchant* the whole amount minus commission in one lump and bear the customer-default risk. | **Cut** `installment_plans`/`installment_entries`; **replace** with one `bnpl_transactions` row. No customer-installment tracking. |
| 3 | **Nurse paid by Balinyaar, on its own weekly schedule** | The customer's installments are owned by the BNPL provider and decoupled from our payout. | Three-way money split on `bookings`; payout independent of BNPL. |
| 4 | **Clawback is first-class** | A booking can be disputed/refunded after the nurse was already paid; the old model had nowhere to record the receivable. | New `nurse_clawbacks` + `dispute_window_ends_at` gating. |
| 5 | **Webhook idempotency before real money** | PSP/BNPL callbacks are at-least-once and retried. | New `payment_webhook_events` keyed on `external_event_id`. |
| 6 | **Multi-session engagements** | Elder care is dominantly multi-day / شبانهروزی (live-in); one-visit-per-booking can't model it. | New `booking_sessions`; EVV + payout move to the session. |
| 7 | **Partner licensed-center entity** | The recommended launch path is subcontracting to an MoH-licensed center; it may be the merchant-of-record/invoice issuer. | New `partner_centers` + `nurse_profiles.partner_center_id`. |
| 8 | **Structured credential registry** | The "verified" trust badge and renewal alerts need queryable license numbers/expiries, not just opaque PDF uploads. | New `nurse_credentials`. |
| 9 | **Cheaper search** | Nurse search needed 4+ joins from day one. | New denormalized `nurse_search_index`. |
| 10 | **Cancellation policy + tax/invoice** | "Default 100% refund" is naive; Iranian commission marketplaces owe VAT on commission. | New `cancellation_policies`, `invoices`; VAT **10%** (configurable). |
| 11 | **Integrity hardening** | Drift, double-pay, and tenancy-leak gaps the critiques found. | Drop duplicate `verification_status` & `payout_released`; add uniqueness/CHECK/tenancy invariants. |
---
## Design Principles
1. **Money is `BIGINT` in Iranian Rials (IRR).** Toman is a display concern only; conversion happens **only at a provider's API boundary** (e.g. SnappPay quotes Toman) and never internally. No floats anywhere on the money path.
2. **The platform never legally holds buyer cash.** Funds settle through a licensed PSP/پرداختیار to **registered IBANs** (the platform's commission IBAN and the nurse's IBAN, via تسهیم settlement-sharing, or to one merchant-of-record account). "Escrow" and "nurse balance" are **derived ledger states** over money custodied at the provider/partner bank — represented in `ledger_entries`, never as a Balinyaar-owned cash balance.
3. **`ledger_entries` is the financial source of truth.** Every capture, commission, payout, refund, and clawback posts **balanced** double-entry rows. Per-table money fields (e.g. `bookings.gross_price_irr`) remain the operational/pricing record; the ledger is the reconciliation truth that answers "how much do we owe nurses right now" and "how much is held but unreleased."
4. **Fee split is captured per booking and never derived from live config**, so historical reporting survives commission-schedule changes. The booking stores three distinct amounts: `gross_price_irr`, `balinyaar_commission_irr`, `nurse_payout_amount`.
5. **PII fields** (national ID, IBAN, phone, addresses, clinical data) are marked **(encrypted)** — column- or application-level. Clinical data has stricter access than financial data.
6. **Two-stage clinical disclosure is a hard rule, not a convention.** At the request stage the nurse sees only `booking_requests.customer_notes`. The full encrypted `booking_care_instructions` are exposed **only after** the booking is confirmed. Enforced at the authorization layer.
7. **Soft deletes** on `users`/`nurse_profiles` via `deleted_at`. Audit, payment, ledger, and payout records are **never** deleted.
8. **Audit trail is append-only.** All state transitions on bookings, payments, refunds, payouts, verifications, reviews, and `platform_configs` produce an `audit_logs` row.
9. **Catalog/config tables are rows, not enums** (service categories, verification step types, cancellation policies, Iranian holidays) so the business evolves without migrations. They carry `name_fa`/`name_en`.
10. **Idempotency is mandatory on the money path.** Every PSP/BNPL callback is stored raw in `payment_webhook_events` and deduplicated on `external_event_id` **before** any money-state mutation.
11. **All timestamps are `DATETIME2(7)` UTC.** Persian-calendar display is a UI concern — **except** that bank-closure scheduling uses the `iranian_holidays` table, because PAYA/SATNA transfers fail on holidays.
12. **Derived flags must not drift.** `nurse_profiles.is_verified`, denormalized rating aggregates, and the search index are written **only** by the code path that owns their source of truth, inside the same transaction.
13. **Invariants are enforced, not just documented:** CHECK constraints (`gross = commission + payout`, `rating BETWEEN 1 AND 5`, amounts ≥ 0, `end_time > start_time`), filtered-UNIQUE for "one primary"/"one active", and tenancy checks (a booking's patient/address must belong to the same customer; its variant to the same nurse).
---
## The two questions this revision answers
These were the two hardest open questions (from `whatsInYourMind.txt`). Both are resolved against **verified** research that all mainstream Iranian provider-financed BNPLs use **full-upfront settlement** — the provider pays the merchant the whole amount minus commission and owns the customer's installments and default risk.
### Q1 — A booking paid by installments (BNPL) is cancelled or refunded mid-plan. What happens?
Money **always** flows `customer ↔ BNPL provider ↔ Balinyaar` — **never** nurse→customer, and **never** Balinyaar→customer directly for a BNPL order.
1. Balinyaar initiates the reversal through the provider's API (SnappPay `revert` for full / `cancel`/`update` for partial, using the stored `external_payment_token`).
2. The provider then **cancels the customer's unpaid installments**, restores their credit, and **refunds any already-paid installment to the customer's bank account in ~7–10 business days** (asynchronous, owned by the provider).
3. Balinyaar records a `refunds` row with `refund_channel = 'bnpl_revert'`, carrying `external_revert_reference` and `expected_customer_refund_eta`; `refund_status` stays `processing` until a reconciliation job confirms.
4. The refund **decomposes** across the two fee legs — `platform_fee_refunded_irr` and `nurse_payout_refunded_irr` — and posts **balanced ledger entries**.
5. **If the nurse has not yet been paid** (still inside the dispute window / not in a processed batch): the `nurse_payable` accrual is simply reversed; nothing leaves Balinyaar. Clean.
6. **If the nurse has already been paid:** this is the **clawback** path — a `nurse_clawbacks` receivable + negative ledger entry; recovered from the next payout batch or written off.
A shortened/partial visit maps to the provider's `update` endpoint with a reduced amount; record `refund_delta_irr` and reduce `bnpl_transactions.settled_amount_irr`.
### Q2 — Under BNPL, who pays the nurse and when?
**Balinyaar pays the nurse**, on its **own normal weekly payout schedule, after EVV completion and after the dispute window closes** — *exactly the same path as a card-funded booking*. The BNPL provider never pays the nurse and is indifferent to the internal split.
The nurse's payout is computed from the booking's **`gross_price_irr` minus `balinyaar_commission_irr`** — **never** from the BNPL provider's net `settled_amount_irr`. The provider's commission (`bnpl_commission_irr`) is a **platform cost of accepting BNPL**, borne by Balinyaar, and must never touch the nurse's payout. Hence three separately stored amounts:
| Amount | Meaning | Drives |
|---|---|---|
| `gross_price_irr` | What the customer is charged (the booking price) | The invoice, the refund base |
| `balinyaar_commission_irr` | Platform's own cut | Platform revenue |
| `bnpl_commission_irr` | The BNPL provider's merchant discount (on `bnpl_transactions`) | Platform **expense** (never the nurse's) |
`nurse_payout_amount = gross_price_irr − balinyaar_commission_irr`. The nurse receives the identical amount and on the identical schedule whether the family paid by card or by SnappPay.
---
## Diagrams
### 1. Domain map — how the clusters relate
```mermaid
flowchart LR
PARTNER["🏥 Partner Centers (launch)
partner_centers"]
IDENTITY["🧑 Identity & Access
users · nurse_profiles · customer_profiles
patients · customer_addresses · nurse_bank_accounts"]
GEO["📍 Geography
provinces · cities · districts · nurse_service_areas"]
VERIFY["✅ Verification
nurse_verifications · step_types · steps
documents · nurse_credentials"]
SERVICES["🩺 Services & Pricing
service_categories · option_groups · option_values
variants · variant_options · search_index · availability"]
BOOKING["📅 Booking & Scheduling
booking_requests · bookings · booking_sessions
care_instructions · visit_verifications · cancellation_policies"]
PAY["💳 Payments & Ledger
payment_gateways · payment_transactions · webhook_events
refunds · ledger_entries · nurse_clawbacks · invoices"]
BNPL["🧾 BNPL
bnpl_transactions"]
PAYOUT["🏦 Payouts
payout_batches · payouts · booking_links"]
REVIEW["⭐ Reviews & Records
reviews · review_tags · patient_care_records"]
MSG["💬 Messaging
tickets · participants · messages"]
NOTIFY["🔔 Notifications
notifications · support_alerts"]
AUDITCFG["📜 Audit & Config
audit_logs · system_events
platform_configs · iranian_holidays"]
PARTNER -. "sponsors / merchant-of-record" .-> VERIFY
IDENTITY --> VERIFY
VERIFY --> SERVICES
SERVICES --> GEO
IDENTITY --> BOOKING
SERVICES --> BOOKING
BOOKING --> PAY
PAY --> BNPL
PAY --> PAYOUT
BOOKING --> REVIEW
BOOKING --> MSG
PAY --> NOTIFY
PAY --> AUDITCFG
```
### 2. Core booking spine (who books whom)
```mermaid
erDiagram
users ||--o| nurse_profiles : "role=nurse"
users ||--o| customer_profiles : "role=customer"
partner_centers ||--o{ nurse_profiles : "sponsors"
customer_profiles ||--o{ patients : "registers"
customer_profiles ||--o{ customer_addresses : "saves"
nurse_profiles ||--o{ nurse_service_variants : "offers"
customer_profiles ||--o{ booking_requests : "submits"
nurse_profiles ||--o{ booking_requests : "receives"
patients ||--o{ booking_requests : "for patient"
nurse_service_variants ||--o{ booking_requests : "selects variant"
booking_requests ||--o| bookings : "converts on payment"
bookings ||--o{ booking_sessions : "has visits"
booking_sessions ||--o| visit_verifications : "EVV per visit"
bookings ||--o| booking_care_instructions : "clinical (encrypted)"
bookings ||--o| reviews : "one review"
booking_requests {
bigint id PK
string status
string required_caregiver_gender
datetime nurse_response_deadline_at
datetime payment_deadline_at
}
bookings {
bigint id PK
bigint gross_price_irr
bigint balinyaar_commission_irr
bigint nurse_payout_amount
smallint session_count
datetime dispute_window_ends_at
string status
}
booking_sessions {
bigint id PK
int session_index
date scheduled_date
string status
datetime payout_eligible_at
}
```
### 3. Payments, ledger & payouts
```mermaid
erDiagram
bookings ||--o{ payment_transactions : "paid by (attempts)"
payment_gateways ||--o{ payment_transactions : "via"
payment_gateways ||--o{ payment_webhook_events : "emits"
payment_transactions ||--o| bnpl_transactions : "if BNPL"
payment_transactions ||--o{ refunds : "may be refunded"
refunds ||--o| nurse_clawbacks : "if after payout"
nurse_profiles ||--o{ nurse_clawbacks : "owes"
bookings ||--o{ ledger_entries : "money postings"
bookings ||--o| invoices : "billed"
nurse_payout_batches ||--o{ nurse_payouts : "groups"
nurse_profiles ||--o{ nurse_payouts : "receives"
nurse_bank_accounts ||--o{ nurse_payouts : "to IBAN"
nurse_payouts ||--o{ nurse_payout_booking_links : "covers"
bookings ||--o| nurse_payout_booking_links : "settled in one"
ledger_entries {
bigint id PK
uuid transaction_group_id
string account_type
string direction
bigint amount_irr
}
refunds {
bigint id PK
bigint platform_fee_refunded_irr
bigint nurse_payout_refunded_irr
string refund_channel
}
bnpl_transactions {
bigint id PK
string provider_code
bigint settled_amount_irr
bigint bnpl_commission_irr
string status
}
```
### 4. Financial lifecycle — escrow → payout → clawback
```mermaid
flowchart TD
A["Family submits booking_request"] --> B{"Nurse responds in time?"}
B -->|"reject / expire"| X["request closed — no money moved"]
B -->|"accept"| C["30-min payment window"]
C --> D{"Payment method"}
D -->|"Card (IPG)"| E["payment_transactions = succeeded"]
D -->|"BNPL (SnappPay)"| F["bnpl_transactions = settled
full amount minus provider commission"]
E --> G["Ledger posting:
DR escrow_held / CR nurse_payable + platform_revenue"]
F --> G
G --> H["Booking confirmed (escrow held)"]
H --> I["Nurse EVV check-in / check-out per session"]
I --> J["Booking completed"]
J --> K["dispute_window_ends_at = completed_at + 72h"]
K --> L{"Window passed & no dispute?"}
L -->|"yes"| M["payout_eligible"]
M --> N["Weekly batch → PAYA to nurse IBAN
payout = gross − balinyaar_commission"]
K -.->|"refund BEFORE payout"| O["Clean ledger reversal
PSP refund / bnpl_revert"]
N --> P{"Refund AFTER payout?"}
P -->|"yes"| Q["nurse_clawbacks receivable
netted next batch or written off"]
P -->|"no"| Z["Settled and reconciled"]
```
---
# Entity Catalog
Each entity carries a **role**, a **why** (the reasoning), its **fields** (full tables for new/changed; **NEW**/**CHANGED**/**CUT** markers), and its **relations**. Scope tags: **[CORE]** launch-critical · **[MVP]** in first release · **[DEFERRED]** modeled now, inactive at launch.
---
## Domain 1 — Identity & Access
### `users` [CORE]
**Role:** The single identity record for every human actor — nurse, customer, admin. `role` decides which profile sub-table is populated. Phone is the primary login credential (OTP); national ID is filled only after KYC.
**Why:** One identity table avoids three near-duplicate user tables and lets auth, audit, and notifications treat everyone uniformly. Phone-as-primary matches Iranian OTP norms and is the key Shahkar matches against. National ID stays NULL until verified so an unverified registration can't masquerade as KYC-complete.
Fields unchanged from baseline: `id`, `email` (enc, nullable), `phone` (enc, unique), `national_id` (enc, nullable), `national_id_verified_at`, `first_name`, `last_name`, `gender` *(promoted here — see note)*, `avatar_url`, `role` (`nurse`/`customer`/`admin`), `is_active`, `email_verified_at`, `phone_verified_at`, `last_login_at`, `last_login_ip`, `preferred_language`, `created_at`, `updated_at`, `deleted_at`.
| Field | Type | Notes |
|---|---|---|
| `gender` | NVARCHAR(10) NULL | **NEW/clarified** — `male`/`female`. Needed because **same-gender caregiving is a near-hard requirement** in Iranian bodily-care; nurse gender (from here) is matched against `booking_requests.required_caregiver_gender`. |
| `shahkar_verified_at` | DATETIME2 NULL | **NEW** — when the phone↔national-id binding was confirmed via Shahkar. Re-set to NULL (re-verify) on phone change. |
**Relations:** 1:1 → `nurse_profiles` / `customer_profiles` (by role); 1:N → `user_sessions`, `user_roles`, `notifications`, `ticket_participants`. Admin users are referenced across the schema as `*_by_admin_id`.
### `user_sessions` [CORE]
**Role:** Refresh-token session records. **Why:** Enables logout-everywhere and stolen-token revocation without a heavyweight session store. Unchanged: `id`, `user_id`, `refresh_token_hash`, `device_info`, `ip_address`, `is_revoked`, `revoked_at`, `expires_at`, `created_at`. **Relations:** N:1 → `users`.
### `roles` / `user_roles` [CORE]
**Role:** RBAC for admin staff only (nurses/customers use `users.role`). **Why:** A small admin team still needs separable finance/support/moderation permissions and a revocation history. `user_roles` keeps `granted_by`/`granted_at`/`revoked_at` for an audit trail. **Relations:** `users` N:N `roles` via `user_roles`.
### `nurse_profiles` [CORE]
**Role:** Extended data for nurses, plus denormalized search/quality aggregates. **Why separated from `users`:** keeps the base identity table lean and isolates the (large) nurse-only attributes and the aggregates that search reads on every query.
| Field | Type | Notes |
|---|---|---|
| `id` | BIGINT PK | |
| `user_id` | BIGINT FK → users UNIQUE | 1:1 |
| `partner_center_id` | BIGINT FK → partner_centers NULL | **NEW** — the licensed center that legally sponsors this nurse at launch (Asanism model). NULL once Balinyaar holds its own permit. |
| `bio`, `years_of_experience`, `education_level`, `education_field`, `specializations_json` | … | Unchanged. |
| `is_verified` | BIT NOT NULL DEFAULT 0 | **Guarded** — set **only** inside the transaction that confirms all required `verification_steps.status='passed'`. No direct write API (Principle 12). |
| ~~`verification_status`~~ | — | **CUT** — duplicated `nurse_verifications.status`; two copies drifted. `nurse_verifications.status` is now the single source of truth. |
| `is_accepting_bookings` | BIT NOT NULL DEFAULT 0 | Nurse can pause without losing verified status. |
| `average_rating`, `total_reviews`, `total_completed_bookings` | … | Denormalized. **Recompute rule now documented**: updated on **every** review status transition (publish → +, hide/reject/unpublish → −) and on booking completion/dispute-reversal, plus a nightly reconciliation job. Fixes the "hide a 1★ review → rating stays inflated" drift. |
| ~~`response_rate`, `avg_response_time_hours`, `profile_completion_score`~~ | — | **CUT for MVP** — analytics columns on no money/safety path, each needing a maintenance job. Compute offline later. |
| `created_at`, `updated_at`, `deleted_at` | … | |
**Relations:** 1:1 → `users`, `nurse_verifications`; 1:N → `nurse_service_variants`, `nurse_service_areas`, `nurse_bank_accounts`, `nurse_credentials`, `bookings`, `nurse_payouts`, `nurse_clawbacks`; N:1 → `partner_centers`.
### `customer_profiles` [CORE]
**Role:** Lightweight extension for customers. **Why intentionally thin:** most customer reality lives in their `patients`, `customer_addresses`, and `bookings`. KYC for customers is deferred. Unchanged: `id`, `user_id` (unique), `default_emergency_contact_name`/`_phone` (enc), `created_at`, `updated_at`. **CUT for MVP:** `national_id_verified_at` (anti-fraud customer KYC — add when actually built). **Relations:** 1:1 → `users`; 1:N → `patients`, `customer_addresses`, `booking_requests`, `bookings`.
### `patients` [CORE]
**Role:** The person receiving care, **separate from the payer**. **Why:** the payer (adult child, spouse) is usually not the patient (elderly parent, newborn, post-surgical adult); one customer registers many patients, each with its own clinical baseline and longitudinal record. Unchanged: `id`, `customer_id`, `display_name`, `first_name`, `last_name`, `birth_date`, `gender`, `blood_type`, `initial_medical_notes` (enc), `is_active`, timestamps. **Relations:** N:1 → `customer_profiles`; 1:N → `booking_requests`, `patient_care_records`. **Tenancy invariant:** a `booking_request.patient_id` must belong to the same `customer_id`.
### `customer_addresses` [CORE]
**Role:** Saved service locations; the encrypted address + coordinates for EVV distance checks. **Why coordinates:** EVV check-in compares the nurse's GPS against the booking address within tolerance. Unchanged fields, plus: **filtered `UNIQUE(customer_id) WHERE is_primary=1`** so exactly one primary exists (prevents ambiguous default). **Relations:** N:1 → `customer_profiles`, `cities`, `districts`; referenced by `booking_requests`/`bookings`.
### `nurse_bank_accounts` [CORE]
**Role:** Payout destination (IBAN/Sheba). **Why hardened:** the IBAN is the single place real money leaves the platform — the original "admin eyeballs the IBAN" check is exactly the forgeable, money-mule-risk link the research warns about.
| Field | Type | Notes |
|---|---|---|
| `id`, `nurse_id`, `bank_name`, `account_holder_name` (enc), `iban` (enc), `is_primary`, `is_verified`, `verified_by_admin_id`, `verified_at`, timestamps | … | Baseline. |
| `iban_hash` | NVARCHAR(64) | **NEW** — deterministic hash for a **UNIQUE** constraint (same IBAN must not silently serve two nurses). |
| `matched_national_id` | BIT NULL | **NEW** — result of an automated **IBAN-owner ↔ national-id inquiry (استعلام شبا)** via a KYC vendor. First payout is gated on a match, not on admin eyeballing. |
| `account_holder_from_bank` | NVARCHAR(200) NULL | **NEW** — name returned by the bank inquiry, snapshot. |
| `ownership_vendor_ref` | NVARCHAR(200) NULL | **NEW** — vendor transaction id for audit. |
Constraints: **filtered `UNIQUE(nurse_id) WHERE is_primary=1`**; `UNIQUE(iban_hash)`. **Relations:** N:1 → `nurse_profiles`; 1:N → `nurse_payouts`.
---
## Domain 2 — Geographic Data
### `provinces` / `cities` / `districts` [CORE]/[MVP]
**Role:** The geo hierarchy backing service areas, addresses, and search. **Why a table, not a static list:** new cities/districts launch without a deploy, and `sort_order`/`is_active` drive ordered, toggleable dropdowns. `districts` map to Tehran's 22 municipal districts or major neighborhoods elsewhere; they are **optional** (a nurse can cover a whole city). Fields unchanged. **Relations:** `provinces` 1:N `cities` 1:N `districts`; referenced by `customer_addresses` and `nurse_service_areas`.
### `nurse_service_areas` [CORE]
**Role:** Where a nurse will travel. A row with `district_id = NULL` means the entire city. **Why a join table (not a radius):** Iranian nurses think in named districts, not GPS radii; this also drives the geographic filter in search cheaply. Unchanged, with `UNIQUE(nurse_id, city_id, district_id)`. **Relations:** N:1 → `nurse_profiles`, `cities`, `districts`.
---
## Domain 3 — Services & Pricing
The service model keeps the original three admin layers (category → option group → option value) and two nurse layers (variant → variant option). This **EAV-style configurability is deliberately kept** — it lets admins add a new pricing dimension (e.g. "شبانهروزی / live-in", "number of patients") without a migration, and lets each nurse price every combination independently. The only addition is a denormalized read model for search.
### `service_categories` [CORE]
**Role:** Admin-managed top-level care types (Elderly, Post-Surgery, Infant, Chronic, Companionship). The primary search dimension. **Why admin-managed rows:** the catalog is a business lever, not a code constant. Fields unchanged. **Relations:** 1:N → `service_option_groups`, `nurse_service_variants`.
### `service_option_groups` [CORE] / `service_option_values` [CORE]
**Role:** The configurable dimensions (group, e.g. "نوع شیفت") and their concrete choices (value, e.g. "شبانهروزی"). A NULL `service_category_id` on a group = cross-category (e.g. shift type applies everywhere). **Why two tables:** separating dimension from choice lets a dimension be `is_required` and reused across categories. Fields unchanged. **Relations:** `service_categories` 1:N `service_option_groups` 1:N `service_option_values`.
### `nurse_service_variants` [CORE]
**Role:** The atomic **bookable unit** — a specific nurse offering a category with a chosen option combination at a price. **Why this is the bookable unit (not the nurse):** a nurse offers many priced combinations; search and booking operate on the exact thing the customer pays for. The `price_unit` (`per_hour`/`per_session`/`per_half_day`/`per_day`/`per_24h`) determines display and, with `session_count`, the engagement total. Fields unchanged. **Consider** a uniqueness strategy on `(nurse_id, category, option-set)` to prevent duplicate identical listings. **Relations:** N:1 → `nurse_profiles`, `service_categories`; 1:N → `nurse_service_variant_options`, `booking_requests`.
### `nurse_service_variant_options` [CORE]
**Role:** The option values that define a variant's configuration. **Why:** one row per dimension makes the variant's meaning explicit and queryable. `UNIQUE(variant_id, option_group_id)` — one value per dimension. **Relations:** N:1 → `nurse_service_variants`, `service_option_groups`, `service_option_values`.
### `nurse_search_index` [CORE] — **NEW**
**Role:** A denormalized, one-row-per-bookable-variant read model holding every search-relevant field flat: nurse (verified + accepting), variant (category, price, unit), areas (city/district), gender, rating, partner center. **Why:** nurse search otherwise needs 4+ joins (`nurse_profiles → variants → variant_options → service_areas`) plus a rating sort from day one — slow at modest scale. A maintained-on-write flat table is far cheaper than adding Elasticsearch at MVP stage.
| Field | Type | Notes |
|---|---|---|
| `id` | BIGINT PK | |
| `variant_id` | BIGINT FK → nurse_service_variants | |
| `nurse_id` | BIGINT FK → nurse_profiles | |
| `service_category_id` | BIGINT FK | |
| `price`, `price_unit` | … | Copied from variant |
| `city_id`, `district_id` | BIGINT | One row per covered area (fan-out) |
| `nurse_gender` | NVARCHAR(10) | For same-gender filtering |
| `average_rating`, `total_reviews`, `total_completed_bookings` | … | Copied from profile |
| `is_searchable` | BIT | True **only** when nurse `is_verified=1`, not suspended, accepting, and variant `is_active=1` |
| `updated_at` | DATETIME2 | |
**Relations (read-only projection):** maintained on writes to `nurse_profiles`, `nurse_service_variants`, `nurse_service_areas`, `reviews`. **Invariant:** a row is `is_searchable=1` only when its source nurse/variant are bookable.
### `nurse_availability_slots` [MVP] / `nurse_availability_exceptions` [MVP]
**Role:** Recurring weekly windows + date overrides. **Why soft-constraint:** these are **guidance only** — the nurse still accepts/rejects each request; they inform search but never block a request. `day_of_week` uses the Shamsi week (0=Saturday … 6=Friday). Fields unchanged, with CHECK `end_time > start_time`. **Relations:** N:1 → `nurse_profiles`.
---
## Domain 4 — Verification & Credentials
The pipeline stays **data-driven**: step types are rows, so a new regulatory requirement (e.g. professional liability insurance) is one INSERT. This revision adds a **structured credential registry** because the brand *is* "verified trust" and renewal tracking needs queryable license numbers, not opaque PDFs.
### `nurse_verifications` [CORE]
**Role:** The master per-nurse verification record; aggregates step outcomes into one status (the single source of truth for verification state). **Why a header table:** one place to drive the overall lifecycle and the `is_verified` flip. Fields unchanged: `id`, `nurse_id` (unique), `status` (`not_started`/`pending`/`in_review`/`approved`/`rejected`/`suspended`), `submitted_at`, `approved_at`, `rejected_at`, `suspended_at`, `rejection_reason`, `reviewed_by_admin_id`, `internal_notes`, timestamps. **Relations:** 1:1 → `nurse_profiles`; 1:N → `verification_steps`.
### `verification_step_types` [CORE]
**Role:** Admin catalog of pipeline steps with stable machine `code`s (`identity_kyc`, `shahkar_match`, `moh_competency_license`, `ino_membership`, `criminal_record`, `bank_account_verification`). **Why rows + `is_automated`/`automation_provider`:** the Iranian credential reality is fragmented across regulators and partly automatable (Shahkar, liveness) and partly manual (license PDF) — data-driving it absorbs that without code changes. Fields unchanged. **Relations:** 1:N → `verification_steps`.
### `verification_steps` [CORE]
**Role:** One row per step per nurse; tracks status, the raw `external_response_json` (KYC vendor audit), and `expires_at` for time-limited steps (the عدم سوء پیشینه certificate expires → step reverts to `pending` + raises a `support_alert`). **Why snapshot `is_automated`:** historical records survive later step-type edits. Fields unchanged, with `UNIQUE(nurse_verification_id, step_type_id)`. **Relations:** N:1 → `nurse_verifications`, `verification_step_types`; 1:N → `verification_documents`.
### `verification_documents` [CORE]
**Role:** Uploaded evidence metadata (object-storage key + integrity hash); files live in S3-compatible storage behind signed URLs, never public. **Why metadata-only:** keeps PII bytes out of the DB and access controlled. Fields unchanged. **Relations:** N:1 → `verification_steps`.
### `nurse_credentials` [MVP] — **NEW**
**Role:** Structured, queryable registry of the actual Iranian credentials — beyond the opaque document uploads. **Why:** no public B2B API exists for MoH/INO, so an admin manually verifies an uploaded credential against the official portal — but the old model gave them **nowhere to record the verified license number** for renewal alerts, the public trust badge, or cross-check. This makes the badge and expiry monitoring real and survives a future INO/MoH API.
| Field | Type | Notes |
|---|---|---|
| `id` | BIGINT PK | |
| `nurse_id` | BIGINT FK → nurse_profiles | |
| `credential_type` | NVARCHAR(50) | `moh_competency_license` (پروانه صلاحیت حرفهای) / `ino_membership` (نظام پرستاری) / `criminal_record` (عدم سوء پیشینه) |
| `credential_number` | NVARCHAR(100) (enc) | License/membership number |
| `holder_name_snapshot` | NVARCHAR(200) | Name as printed, for ID cross-check |
| `issuing_authority` | NVARCHAR(200) | |
| `issued_at`, `expires_at` | DATE NULL | Drives renewal alerts |
| `verification_source` | NVARCHAR(300) NULL | Portal URL / method |
| `verification_method` | NVARCHAR(20) | `manual` / `portal` / `api` |
| `verified_by_admin_id` | BIGINT FK → users NULL | |
| `created_at`, `updated_at` | … | |
**Relations:** N:1 → `nurse_profiles`. Cross-referenced by the relevant `verification_steps`.
---
## Domain 5 — Booking & Scheduling
Two distinct phases: the **request phase** (pre-payment intent) and the **booking phase** (post-payment commitment). The previous model's biggest domain gap — **single-visit-only bookings** — is fixed here with `booking_sessions`, because elder care is dominantly multi-day / live-in.
### `booking_requests` [CORE]
**Role:** A customer's pre-payment intent for a nurse, service, date, and time. No money involved. **Why separate from `bookings`:** a request may be rejected, expire, or have its payment window lapse without a booking ever existing — merging would mean many nullable fields and tangled status logic. The deadlines are **computed once and stored** so they're immune to later config changes.
| Field | Type | Notes |
|---|---|---|
| `id`, `customer_id`, `nurse_id`, `patient_id`, `variant_id`, `customer_address_id` | … | Baseline FKs. **Tenancy invariant:** patient & address belong to `customer_id`; variant belongs to `nurse_id`. |
| `required_caregiver_gender` | NVARCHAR(10) NULL | **NEW** — `male`/`female`/`any`. Same-gender care is decisive in Iranian bodily-care; surfaced as a first-class filter and matched against nurse gender. |
| `requested_date`, `requested_time_start`, `requested_time_end` | … | For multi-day engagements these are the engagement start; sessions carry the per-visit schedule. |
| `customer_notes` | NVARCHAR(1000) NULL | **Unencrypted, request-stage only** — the *only* clinical context the nurse sees before accepting (Principle 6). |
| `status` | NVARCHAR(50) | `pending_nurse_response` → `accepted_awaiting_payment` → `converted` / `rejected_by_nurse` / `expired_no_response` / `payment_deadline_expired` / `cancelled_by_customer` |
| `nurse_response_deadline_at` | DATETIME2 | From config at creation; auto-expire after. |
| `payment_deadline_at` | DATETIME2 NULL | Set on accept; 30-min window. |
| `nurse_rejection_reason` | NVARCHAR(500) NULL | |
| timestamps | … | |
**Relations:** N:1 → `customer_profiles`, `nurse_profiles`, `patients`, `nurse_service_variants`, `customer_addresses`; 1:1 → `bookings` (on conversion).
### `bookings` [CORE]
**Role:** The confirmed engagement — exists **only** when the nurse accepted **and** payment was captured. Source of truth for the service event and its money split. **Why snapshots:** `variant_snapshot_json` and `address_snapshot_json` freeze the service and address at booking time, so later edits/deletes can't corrupt history or disputes.
| Field | Type | Notes |
|---|---|---|
| `id` | BIGINT PK | |
| `booking_request_id` | BIGINT FK UNIQUE | 1:1 — the request that created it |
| `customer_id`, `nurse_id`, `patient_id`, `variant_id`, `customer_address_id` | … | Denormalized for query performance |
| `variant_snapshot_json` | NVARCHAR(MAX) | Variant + option labels at booking time |
| `address_snapshot_json` | NVARCHAR(MAX) (enc) | Full address at booking time |
| `partner_center_id` | BIGINT FK → partner_centers NULL | **NEW** — the licensed center legally covering this visit / merchant-of-record |
| `gross_price_irr` | BIGINT | **CHANGED (renamed)** — total charged to customer |
| `balinyaar_commission_irr` | BIGINT | **CHANGED (renamed from `platform_fee_amount`)** — platform's own cut |
| `platform_fee_rate` | DECIMAL(5,4) | Rate snapshot for audit |
| `nurse_payout_amount` | BIGINT | `= gross_price_irr − balinyaar_commission_irr`. **CHECK enforced.** |
| `psp_fee_amount` | BIGINT NULL | **NEW** — gateway cost on this payment, for true margin/reconciliation |
| `session_count` | SMALLINT NOT NULL DEFAULT 1 | **NEW** — 1 = single visit; >1 = multi-session engagement |
| `scheduled_date`, `scheduled_time_start`, `scheduled_time_end` | … | Engagement-level; per-visit lives in `booking_sessions` |
| `status` | NVARCHAR(30) | `pending_payment` → `confirmed` → `in_progress` → `completed` → (`disputed` → `closed`) / `cancelled`. **Now guarded by an allowed-transition table/CHECK** so it can't contradict EVV. |
| `confirmed_at`, `cancelled_at`, `cancellation_reason`, `cancelled_by`, `completed_at` | … | |
| `dispute_window_ends_at` | DATETIME2 NULL | **NEW** — `completed_at + config(dispute_window_hours, default 72)`. Payout eligible only after this passes with no open dispute. |
| ~~`payout_released`~~ | — | **CUT** — second source of truth for "paid"; now derived from a `nurse_payout_booking_links` row + ledger. |
| timestamps | … | |
CHECK: `gross_price_irr = balinyaar_commission_irr + nurse_payout_amount`; all amounts ≥ 0. **Relations:** 1:1 ← `booking_requests`; 1:N → `booking_sessions`, `payment_transactions`, `ledger_entries`; 1:1 → `booking_care_instructions`, `visit_verifications` *(see note)*, `reviews`, `invoices`; referenced by `nurse_payout_booking_links`, `refunds`, `nurse_clawbacks`.
### `booking_sessions` [MVP] — **NEW**
**Role:** One row per **visit** within a booking. A 10-day post-op package or a month of nightly شبانهروزی care is **one booking with N sessions**, each with its own schedule, EVV, and payout eligibility. **Why:** the single-visit model literally cannot represent the dominant elder-care engagement; and money for a long engagement must release **per completed session**, not as one whole-month escrow held for weeks. Mid-engagement cancellation then cleanly refunds only the un-started sessions.
| Field | Type | Notes |
|---|---|---|
| `id` | BIGINT PK | |
| `booking_id` | BIGINT FK → bookings | |
| `session_index` | INT | 1-based ordinal |
| `scheduled_date`, `scheduled_time_start`, `scheduled_time_end` | … | Per-visit |
| `visit_payout_amount` | BIGINT | Portion of `nurse_payout_amount` for this session |
| `status` | NVARCHAR(20) | `scheduled` / `in_progress` / `completed` / `missed` / `cancelled` |
| `payout_eligible_at` | DATETIME2 NULL | Per-session dispute-window close |
| `cancellation_event_id` | BIGINT NULL | If this session was cancelled |
| timestamps | … | |
**Relations:** N:1 → `bookings`; 1:1 → `visit_verifications`. **Note:** for a single-visit booking, exactly one session is created so the EVV/payout path is uniform.
### `booking_care_instructions` [CORE]
**Role:** Encrypted clinical/logistical context provided at booking time, visible **only post-confirmation** to the assigned nurse and admin. **Why separate + encrypted:** keeps the financial/scheduling table clean and enforces the two-stage disclosure boundary (Principle 6) with stricter access controls. Fields unchanged (current conditions, medications, allergies, special instructions, emergency contact — all enc). **Relations:** 1:1 → `bookings`.
### `visit_verifications` [CORE]
**Role:** Electronic Visit Verification — the authoritative record that a visit happened and for how long; **required for payout**. **Why:** in-home care is unobserved; GPS + timestamped check-in/out is the proof that safely releases escrow. `check_in_address_match` is advisory (a mismatch triggers admin review, not auto-cancel). **CHANGED:** the FK moves to **`booking_session_id`** so each visit in a multi-session engagement is verified independently; the mapping between `visit_verifications.status` and the parent `bookings.status` is documented so the two state machines cannot silently diverge. Fields otherwise unchanged. **Relations:** 1:1 → `booking_sessions`.
### `cancellation_policies` [MVP] — **NEW**
**Role:** Config-driven, snapshot-able cancellation/refund tiers by lead time and initiating actor. **Why:** "default 100% refund" is naive — a free cancel 24h ahead, a 50% charge inside 24h, and a nurse-no-show full refund + nurse penalty are different money flows, and the applicable rule must be **frozen onto the booking** at cancel time (the same snapshot discipline used for `platform_fee_rate`).
| Field | Type | Notes |
|---|---|---|
| `id` | BIGINT PK | |
| `code` | NVARCHAR(50) UNIQUE | e.g. `standard_24h` |
| `applies_to` | NVARCHAR(20) | `customer` / `nurse` / `admin` |
| `hours_before_start_min`, `hours_before_start_max` | INT NULL | Tier bounds |
| `refund_percentage` | DECIMAL(5,2) | 0–100 |
| `fee_amount_or_rate` | … | Cancellation fee / nurse penalty |
| `is_active`, timestamps | … | |
**Relations:** referenced (snapshot) by `refunds` and the `cancellation_event` recorded on a session/booking.
---
## Domain 6 — Payments, Ledger & Refunds
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).
---
## Domain 7 — Payouts to Nurses
### `nurse_payout_batches` [CORE]
**Role:** Weekly aggregation of amounts owed for completed, payout-eligible, unpaid bookings/sessions. **Why batched:** matches the operational rhythm and the PAYA settlement cycle; an admin (or scheduled job) initiates it. **Holiday-aware:** `period_end`/processing dates shift off bank-closed days using `iranian_holidays` (a weekly payout landing on a multi-day Nowruz closure would otherwise fail). Fields unchanged: period, `total_amount`, `payout_count`, `status`, `initiated_by_admin_id`, `processed_at`, `failure_notes`, timestamps. CHECK: `total_amount = Σ payouts`. **Relations:** 1:N → `nurse_payouts`.
### `nurse_payouts` [CORE]
**Role:** One row per nurse per batch — the exact amount transferred, the IBAN snapshot, and the bank transfer reference. **Why these additions:** a batch must be able to **net prior clawbacks** so it doesn't overpay a nurse who owes money back.
| Field | Type | Notes |
|---|---|---|
| baseline | … | `id`, `batch_id`, `nurse_id`, `bank_account_id`, `iban_snapshot` (enc), `amount`, `booking_count`, `status`, `transfer_reference`, `paid_at`, `failure_reason`, `created_at` |
| `gross_earnings_irr` | BIGINT | **NEW** — sum of eligible session/booking payouts |
| `clawback_applied_irr` | BIGINT | **NEW** — clawbacks netted this batch |
| `net_amount_irr` | BIGINT | **NEW** — `gross_earnings − clawback`; `amount` = actually transferred net |
**Relations:** N:1 → `nurse_payout_batches`, `nurse_profiles`, `nurse_bank_accounts`; 1:N → `nurse_payout_booking_links`; referenced by `nurse_clawbacks`.
### `nurse_payout_booking_links` [CORE]
**Role:** Join from a payout to the specific bookings it covers, with `booking_id` **UNIQUE** to guarantee a booking is paid in exactly one batch. **Why:** per-booking reconciliation and the structural anti-double-pay guard (the previous model's strongest correctness feature — kept). Fields unchanged. **Relations:** N:1 → `nurse_payouts`; 1:1 → `bookings`.
---
## Domain 8 — BNPL / Installments
**Resolved.** Because verified research shows Iranian provider-financed BNPL settles the **full amount to the merchant in one lump** (provider owns the customer's installments and default risk), a BNPL order is — in our books — a card payment that lands net-of-fee. The original `installment_plans` + `installment_entries` subsystem (which tried to track the customer's repayment schedule and default) is **deleted**: it modeled a receivable Balinyaar never owns and a risk it never bears.
### `bnpl_transactions` [MVP] — **NEW (replaces `installment_plans`)**
**Role:** One row per BNPL order, 1:1 with its `payment_transaction` — the single inbound settlement to reconcile, plus the revert path. **Why one row, not a plan+entries tree:** there is nothing to amortize on our side; we track the settlement, the provider's commission, and the reversal.
| Field | Type | Notes |
|---|---|---|
| `id` | BIGINT PK | |
| `payment_transaction_id` | BIGINT FK UNIQUE | 1:1 |
| `provider_code` | NVARCHAR(50) | `snapppay` / `digipay` / `tara` / `torobpay` |
| `merchant_of_record` | NVARCHAR(40) | Balinyaar entity or partner center |
| `external_payment_token` | NVARCHAR(200) | For verify/settle/revert |
| `external_transaction_id` | NVARCHAR(200) | |
| `eligibility_status` | NVARCHAR(30) | |
| `order_amount_irr` | BIGINT | Gross order |
| `settled_amount_irr` | BIGINT | Net of provider commission actually received |
| `bnpl_commission_irr` | BIGINT | Provider's merchant discount = platform **expense** (never the nurse's) |
| `currency` | NVARCHAR(5) | `IRR`/`TOMAN` at boundary; convert in |
| `installment_count` | TINYINT | Informational (default 4) — owned by the provider |
| `status` | NVARCHAR(30) | State machine: `eligible`/`token_issued`/`verified`/`settled`/`reverted`/`cancelled`/`failed` |
| `settled_at` | DATETIME2 NULL | **Per-transaction** — timing is contract-defined (daily/T+1-3/weekly), never assumed instant |
| `revert_transaction_id`, `reverted_amount_irr`, `reverted_at` | … | Reversal path |
| `refund_channel` | NVARCHAR(20) | |
| `callback_payload_json` | NVARCHAR(MAX) | Raw verify/settle payload |
| timestamps | … | |
**Relations:** 1:1 → `payment_transactions`. **State-machine guard** on `status` for idempotency.
### `bnpl_settlement_entries` [DEFERRED]
**Role/Why:** Only needed if a future provider uses **tranched** settlement (pays the platform over time). No mainstream Iranian provider does today, so it's modeled-but-inactive; adding it later is a purely additive migration.
---
## Domain 9 — Messaging (Ticket System)
### `tickets` / `ticket_participants` / `ticket_messages` [CORE]
**Role:** All post-booking communication, admin-readable, with no direct nurse↔customer channel. **Why intentionally constrained:** it protects vulnerable patients, creates dispute evidence, and prevents disintermediation (families and nurses pairing off-platform). `ticket_messages.is_internal` keeps admin-only notes out of user view. `reference_code` is the human-facing support id. **On-site emergencies** stay an operational playbook ("call the emergency contact from the app, then open a ticket") — surfaced prominently in the booking UI; no schema change, but documented so nurses don't seek the family's number off-platform. Fields unchanged; `UNIQUE(ticket_id, user_id)` on participants. **Relations:** `tickets` 1:N `ticket_participants`/`ticket_messages`; `tickets` optionally ↔ `bookings`, `refunds`.
---
## Domain 10 — Reviews & Patient Records
### `reviews` [CORE]
**Role:** One customer review per **completed** booking; enters `pending_moderation`, published only after admin/AI approval; a low rating raises a `support_alert`. **Why these guards:** (a) review creation is allowed **only for completed/closed bookings** (a review for a cancelled booking is nonsense); (b) **every** status transition (publish/hide/reject/unpublish) recomputes `nurse_profiles` aggregates, fixing the inflated-rating-after-hide drift. Fields unchanged (rating 1–5 with CHECK, body, status, moderation fields). **Relations:** 1:1 → `bookings`; N:1 → `customer_profiles`, `nurse_profiles`; 1:N → `review_tag_links`.
### `review_tags_master` [MVP] / `review_tag_links` [MVP]
**Role:** Standardized tags for quantitative aggregation of qualitative feedback (e.g. "% punctual"). **Why MVP-not-core:** free-text + rating is enough to launch; structured tags are a phase-2 analytics nicety (additive leaf tables). **Relations:** `reviews` N:N `review_tags_master`.
### `patient_care_records` [MVP]
**Role:** Nurse-authored clinical notes after a visit, accumulating into a **patient-scoped longitudinal history** (not booking-scoped). **Why patient-scoped:** when a different nurse takes over, they read the history before accepting, enabling continuity of care without the family repeating everything. Strict access: owning customer, nurses with a confirmed booking for that patient, admin. All fields encrypted. **Relations:** N:1 → `patients`, `bookings`, `nurse_profiles`.
---
## Domain 11 — Notifications
### `notifications` [CORE]
**Role:** In-app notifications (no push at launch); `data_json` carries a typed payload for front-end navigation. **Why polled, not pushed:** push is out of MVP scope; a retention job hard-deletes read notifications older than 90 days to bound growth. Fields unchanged. **Relations:** N:1 → `users`.
### `support_alerts` [CORE]
**Role:** Internal-only alerts (low ratings, EVV no-shows, expired verification steps, EVV location mismatch, payment anomalies), with an owner and resolution trail. **Why distinct from notifications:** these are staff worklist items, never shown to users. Fields unchanged. **Relations:** polymorphic (`entity_type`,`entity_id`) — validated at the application layer; consider nullable typed FKs (`booking_id`,`review_id`) for the common cases.
---
## Domain 12 — Audit, Config & Reference
### `audit_logs` [CORE]
**Role:** Immutable, append-only record of every state change on sensitive entities — now explicitly **including `platform_configs`** (so finance can prove the commission rate at any moment). **Why:** compliance and accountability; `changed_fields_json` enables fast filtering. Plan month-partitioning + 2–3yr cold-storage archival before launch. Fields unchanged. **Relations:** polymorphic, append-only.
### `system_events` [MVP]
**Role:** High-volume behavioral/analytics event log. **Why kept but de-emphasized:** product analytics, not compliance. It grows unbounded — at scale, pipe it to an analytics sink/warehouse rather than the transactional DB. Fields unchanged.
### `platform_configs` [CORE]
**Role:** Key-value runtime business parameters — change without a deploy. **Why typed values:** `data_type` tells the app how to parse. **New keys** this revision: `dispute_window_hours` (default 72), `vat_rate` (0.10), `bnpl_merchant_of_record`, `bnpl_provider_commission_rate`, `bnpl_settlement_timing`, cancellation-tier defaults — alongside the existing `platform_fee_rate`, `booking_payment_deadline_minutes`, `nurse_response_deadline_hours`, `nurse_payout_interval_days`, `evv_location_tolerance_meters`, `min_rating_for_support_alert`. **Relations:** referenced everywhere; changes audited.
### `iranian_holidays` [MVP] — **NEW**
**Role:** Shared official/religious holiday calendar (movable, partly lunar-Hijri), with a `is_bank_closed` flag. **Why a real table:** Iran's holidays are numerous and partly movable, and they drive **payout bank-closure scheduling** (PAYA/SATNA closed → a weekly payout shifts to the next business day), optional holiday pricing, and business-hour deadline math — none of which a purely manual per-nurse availability exception can express.
| Field | Type | Notes |
|---|---|---|
| `id` | BIGINT PK | |
| `holiday_date` | DATE | |
| `name_fa` | NVARCHAR(200) | |
| `type` | NVARCHAR(20) | `official` / `religious` / `national` |
| `is_bank_closed` | BIT | Drives payout date shifting |
**Relations:** referenced by payout scheduling and (optionally) pricing.
---
## Domain 13 — Partner Centers (launch) & Future
### `partner_centers` [MVP] — **NEW**
**Role:** A licensed home-nursing center (مرکز مشاوره و ارائه مراقبتهای پرستاری در منزل) that **sponsors** nurses and is plausibly the **merchant-of-record** and invoice issuer at launch. **Why this is the single most launch-critical addition:** the research's #1 go-to-market recommendation is to operate by subcontracting to an already-licensed center (the Asanism model) while Balinyaar's own MoH permit is pending — the center is what makes the operation legal, may be who the IPG settles to, and clears the BNPL onboarding gate (which needs a جواز کسب + eNamad the company/center holds, not each nurse). Distinct from the future `organizations` (employer) table — this is the **licensing/launch sponsor**.
| Field | Type | Notes |
|---|---|---|
| `id` | BIGINT PK | |
| `name` | NVARCHAR(300) | |
| `legal_entity_type` | NVARCHAR(30) | |
| `moh_establishment_permit_no` | NVARCHAR(100) | پروانه تأسیس |
| `technical_director_nurse_user_id` | BIGINT FK → users NULL | مسئول فنی |
| `technical_director_license_no` | NVARCHAR(100) NULL | |
| `enamad_code` | NVARCHAR(100) NULL | |
| `settlement_iban` | NVARCHAR(34) (enc) NULL | If merchant-of-record |
| `is_merchant_of_record` | BIT | |
| `commission_rate` | DECIMAL(5,4) NULL | Center's cut, if any |
| `admin_user_id` | BIGINT FK → users | Center's dashboard account |
| `is_active`, `verified_at`, timestamps | … | |
**Relations:** 1:N → `nurse_profiles` (sponsors), `bookings` (legally covered by), `invoices` (issuer).
### `organizations` / `organization_nurses` [DEFERRED]
**Role/Why:** The future **employer** model (nursing companies adding their employed nurses). Modeled-but-inactive — no launch table references them, so adding them later is a pure additive migration. Kept distinct from `partner_centers` (launch licensing) to avoid conflating "sponsor for legality" with "employer."
### `fraud_flags` [DEFERRED]
**Role/Why:** Output of a future ML fraud service. Premature for a no-traffic MVP; `support_alerts` (`fraud_signal` type) covers rule-based signals manually. Inactive stub.
### `recurring_booking_schedules` [DEFERRED]
**Role/Why:** RFC-5545 recurrence for repeating care patterns. Note: the concrete multi-day need is now met by `booking_sessions`; this remains deferred for true open-ended recurrence. Inactive stub.
---
## Relationship Summary
| Relationship | Type | Notes |
|---|---|---|
| `users` → `nurse_profiles` / `customer_profiles` | 1:1 | by `role` |
| `partner_centers` → `nurse_profiles` | 1:N | launch sponsor (NEW) |
| `customer_profiles` → `patients` / `customer_addresses` | 1:N | |
| `nurse_profiles` → `nurse_service_variants` / `nurse_service_areas` / `nurse_bank_accounts` / `nurse_credentials` | 1:N | |
| `nurse_service_variants` → `nurse_service_variant_options` | 1:N | option combination |
| `nurse_profiles` → `nurse_verifications` | 1:1 | |
| `nurse_verifications` → `verification_steps` → `verification_documents` | 1:N → 1:N | |
| `booking_requests` → `bookings` | 1:1 | on nurse-accept + payment |
| `bookings` → `booking_sessions` | 1:N | **NEW** — multi-visit engagements |
| `booking_sessions` → `visit_verifications` | 1:1 | **CHANGED** — EVV per session |
| `bookings` → `booking_care_instructions` / `reviews` / `invoices` | 1:1 | |
| `bookings` → `payment_transactions` | 1:N | attempts |
| `payment_transactions` → `bnpl_transactions` | 1:1 | if BNPL (**replaces** installment_plans) |
| `payment_transactions` → `refunds` | **1:N** | **CHANGED** — partials allowed |
| `payment_gateways` → `payment_webhook_events` | 1:N | **NEW** — idempotency |
| `bookings` / nurses → `ledger_entries` | 1:N | **NEW** — money source of truth |
| `refunds` → `nurse_clawbacks` | 1:1 (opt) | **NEW** — refund-after-payout |
| `nurse_payout_batches` → `nurse_payouts` → `nurse_payout_booking_links` | 1:N → 1:N | `booking_id` UNIQUE |
| `nurse_payout_booking_links` → `bookings` | 1:1 | exactly one payout per booking |
| `patients` → `patient_care_records` | 1:N | longitudinal history |
| `tickets` → `ticket_participants` / `ticket_messages` | 1:N | |
| Sensitive entities → `audit_logs` | *:N | append-only |
---
## Final MVP table list
**Identity & Access:** `users` · `user_sessions` · `roles` · `user_roles` · `nurse_profiles` · `customer_profiles` · `patients` · `customer_addresses` · `nurse_bank_accounts` — all **[CORE]**
**Geography:** `provinces` · `cities` **[CORE]** · `districts` **[MVP]** · `nurse_service_areas` **[CORE]**
**Services & Pricing:** `service_categories` · `service_option_groups` · `service_option_values` · `nurse_service_variants` · `nurse_service_variant_options` · **`nurse_search_index`** *(NEW)* — **[CORE]**; `nurse_availability_slots` · `nurse_availability_exceptions` — **[MVP]**
**Verification:** `nurse_verifications` · `verification_step_types` · `verification_steps` · `verification_documents` **[CORE]**; **`nurse_credentials`** *(NEW)* **[MVP]**
**Booking & Scheduling:** `booking_requests` · `bookings` · `booking_care_instructions` · `visit_verifications` **[CORE]**; **`booking_sessions`** *(NEW)* · **`cancellation_policies`** *(NEW)* **[MVP]**
**Payments & Ledger:** `payment_gateways` · `payment_transactions` · **`payment_webhook_events`** *(NEW)* · `refunds` · **`ledger_entries`** *(NEW)* · **`nurse_clawbacks`** *(NEW)* · `nurse_payout_batches` · `nurse_payouts` · `nurse_payout_booking_links` **[CORE]**; **`invoices`** *(NEW)* **[MVP]**
**BNPL:** **`bnpl_transactions`** *(NEW — replaces `installment_plans`)* **[MVP]**; ~~`installment_plans`~~ · ~~`installment_entries`~~ **CUT**; `bnpl_settlement_entries` **[DEFERRED]**
**Messaging:** `tickets` · `ticket_participants` · `ticket_messages` **[CORE]**
**Reviews & Records:** `reviews` **[CORE]**; `review_tags_master` · `review_tag_links` · `patient_care_records` **[MVP]**
**Notifications:** `notifications` · `support_alerts` **[CORE]**
**Audit & Config:** `audit_logs` **[CORE]** · `system_events` **[MVP]** · `platform_configs` **[CORE]** · **`iranian_holidays`** *(NEW)* **[MVP]**
**Partner / Launch:** **`partner_centers`** *(NEW)* **[MVP]**
**Future (modeled, inactive):** `organizations` · `organization_nurses` · `fraud_flags` · `recurring_booking_schedules` · `bnpl_settlement_entries` — all **[DEFERRED]**
**Net change vs the original 45:** −2 cut (`installment_plans` replaced, `installment_entries` removed), +10 added (`ledger_entries`, `nurse_clawbacks`, `payment_webhook_events`, `nurse_search_index`, `booking_sessions`, `cancellation_policies`, `invoices`, `partner_centers`, `nurse_credentials`, `iranian_holidays`), 1 replaced (`bnpl_transactions`). The financial core is now a single ledger, BNPL is one settlement row, and the clawback / dispute-window / idempotency / license / multi-session gaps are all closed.
---
## Key Design Decisions (the reasoning, in one place)
1. **Escrow as a ledger state, not platform cash** — because an Iranian پرداختیار legally cannot custody buyer funds. Everything else in the money domain follows from honestly representing "we don't hold the cash; we hold a claim/obligation tracked in the ledger over funds at a licensed provider." This is also why payouts are modeled as provider-side settlement to **verified, ownership-checked** IBANs.
2. **A BNPL order is a net-of-fee inbound payment, full stop** — the verified full-upfront settlement model means there is no customer receivable, no default risk, and no installment schedule for Balinyaar to track. Deleting `installment_entries` removed an entire fragile subsystem and replaced it with one reconciliation row.
3. **Three separate money amounts** so the platform's two fee deductions (its own commission, and the BNPL provider's discount) are never conflated, and the nurse is paid identically regardless of payment method.
4. **Double-entry over status flags** — the previous model could not answer "how much do we owe nurses right now" without fragile joins, and had nowhere to record a refund-after-payout. One append-only ledger + a `nurse_clawbacks` receivable fixes both and makes bank/Shaparak reconciliation possible.
5. **Dispute window gates payout** — preferring a *holding period* over a *clawback*, because clawback against an already-paid nurse IBAN is largely unenforceable. The clawback path exists for the cases that slip through.
6. **Idempotency before money** — `payment_webhook_events` keyed on the provider event id, written first, is the cheapest insurance against the most damaging payments bug (double-confirm / double-settle on callback retries).
7. **Multi-session engagements are the norm, not an edge case** — `booking_sessions` makes long elder-care arrangements representable, lets escrow release per completed visit instead of holding a month of money, and makes mid-engagement cancellation accounting clean.
8. **Partner center is launch-critical** — it is the legal vehicle and likely merchant-of-record; without it the recommended go-to-market and the money flow are not representable.
9. **Verified-trust must be queryable** — `nurse_credentials` turns the brand promise into renewal alerts, a real badge, and audit defensibility, surviving the future arrival of an INO/MoH API.
10. **Keep the configurable service EAV; cut the analytics scaffolding** — the category/option model earns its complexity (admin-extensible pricing dimensions without migrations); `response_rate`/`profile_completion_score`/`system_events`-in-SQL do not, at launch.
---
## Open items to confirm before building (not schema blockers)
- **BNPL provider contract:** does SnappPay/Digipay permit a multi-vendor marketplace re-disbursing to many nurses as a single merchant? (Publicly undocumented — confirm with sales.) The schema assumes **one lump to Balinyaar/the center, internal allocation to nurses**, so this is an ops confirmation, not a schema dependency.
- **Commission %** and **settlement SLA** per provider (and whether the provider returns its commission on a refund — full or pro-rata).
- **PSP/تسهیم provider** for MVP (ZarinPal Multiplexing vs Vandar vs Jibit) and whether it permits the hold-then-weekly-payout timing, or whether a bank-grade escrow (Vandar میندو) is needed.
- **VAT exemption** ruling on the nursing service itself (the commission line is taxable regardless) — `vat_rate` is config-driven so either ruling is a value change.
- **مودیان** enrollment thresholds for the platform and high-earning nurses.
> Confirm decades-old regulations, provider fee/settlement specifics, and tax thresholds against current primary sources and the provider's compliance team before building the payment integration. See `payments-and-installments.md` for the full source-cited analysis.