Files
baya-monorepo/product/database-model.md
T
2026-06-21 00:05:07 +03:30

70 KiB
Raw Blame History

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 ↔ Balinyaarnever 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 ~710 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 closesexactly 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_irrnever 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

flowchart LR
  PARTNER["🏥 Partner Centers (launch)<br/>partner_centers"]
  IDENTITY["🧑 Identity & Access<br/>users · nurse_profiles · customer_profiles<br/>patients · customer_addresses · nurse_bank_accounts"]
  GEO["📍 Geography<br/>provinces · cities · districts · nurse_service_areas"]
  VERIFY["✅ Verification<br/>nurse_verifications · step_types · steps<br/>documents · nurse_credentials"]
  SERVICES["🩺 Services & Pricing<br/>service_categories · option_groups · option_values<br/>variants · variant_options · search_index · availability"]
  BOOKING["📅 Booking & Scheduling<br/>booking_requests · bookings · booking_sessions<br/>care_instructions · visit_verifications · cancellation_policies"]
  PAY["💳 Payments & Ledger<br/>payment_gateways · payment_transactions · webhook_events<br/>refunds · ledger_entries · nurse_clawbacks · invoices"]
  BNPL["🧾 BNPL<br/>bnpl_transactions"]
  PAYOUT["🏦 Payouts<br/>payout_batches · payouts · booking_links"]
  REVIEW["⭐ Reviews & Records<br/>reviews · review_tags · patient_care_records"]
  MSG["💬 Messaging<br/>tickets · participants · messages"]
  NOTIFY["🔔 Notifications<br/>notifications · support_alerts"]
  AUDITCFG["📜 Audit & Config<br/>audit_logs · system_events<br/>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)

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

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

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<br/>full amount minus provider commission"]
  E --> G["Ledger posting:<br/>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<br/>payout = gross  balinyaar_commission"]
  K -.->|"refund BEFORE payout"| O["Clean ledger reversal<br/>PSP refund / bnpl_revert"]
  N --> P{"Refund AFTER payout?"}
  P -->|"yes"| Q["nurse_clawbacks receivable<br/>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/clarifiedmale/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 codes (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 NEWmale/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_responseaccepted_awaiting_paymentconverted / 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_paymentconfirmedin_progresscompleted → (disputedclosed) / 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 NEWcompleted_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) 0100
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) NEWpsp_card / bnpl_revert / manual_bank
external_revert_reference NVARCHAR(200) NULL NEW — BNPL revert id
expected_customer_refund_eta DATE NULL NEW — the ~710 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_payableescrow_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 NEWgross_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.

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 15 with CHECK, body, status, moderation fields). Relations: 1:1 → bookings; N:1 → customer_profiles, nurse_profiles; 1:N → review_tag_links.

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 + 23yr 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
usersnurse_profiles / customer_profiles 1:1 by role
partner_centersnurse_profiles 1:N launch sponsor (NEW)
customer_profilespatients / customer_addresses 1:N
nurse_profilesnurse_service_variants / nurse_service_areas / nurse_bank_accounts / nurse_credentials 1:N
nurse_service_variantsnurse_service_variant_options 1:N option combination
nurse_profilesnurse_verifications 1:1
nurse_verificationsverification_stepsverification_documents 1:N → 1:N
booking_requestsbookings 1:1 on nurse-accept + payment
bookingsbooking_sessions 1:N NEW — multi-visit engagements
booking_sessionsvisit_verifications 1:1 CHANGED — EVV per session
bookingsbooking_care_instructions / reviews / invoices 1:1
bookingspayment_transactions 1:N attempts
payment_transactionsbnpl_transactions 1:1 if BNPL (replaces installment_plans)
payment_transactionsrefunds 1:N CHANGED — partials allowed
payment_gatewayspayment_webhook_events 1:N NEW — idempotency
bookings / nurses → ledger_entries 1:N NEW — money source of truth
refundsnurse_clawbacks 1:1 (opt) NEW — refund-after-payout
nurse_payout_batchesnurse_payoutsnurse_payout_booking_links 1:N → 1:N booking_id UNIQUE
nurse_payout_booking_linksbookings 1:1 exactly one payout per booking
patientspatient_care_records 1:N longitudinal history
ticketsticket_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 moneypayment_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 casebooking_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 queryablenurse_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.