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