70 KiB
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) andpayments-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
- Money is
BIGINTin 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. - 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. ledger_entriesis 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."- 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. - PII fields (national ID, IBAN, phone, addresses, clinical data) are marked (encrypted) — column- or application-level. Clinical data has stricter access than financial data.
- 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 encryptedbooking_care_instructionsare exposed only after the booking is confirmed. Enforced at the authorization layer. - Soft deletes on
users/nurse_profilesviadeleted_at. Audit, payment, ledger, and payout records are never deleted. - Audit trail is append-only. All state transitions on bookings, payments, refunds, payouts, verifications, reviews, and
platform_configsproduce anaudit_logsrow. - 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. - Idempotency is mandatory on the money path. Every PSP/BNPL callback is stored raw in
payment_webhook_eventsand deduplicated onexternal_event_idbefore any money-state mutation. - All timestamps are
DATETIME2(7)UTC. Persian-calendar display is a UI concern — except that bank-closure scheduling uses theiranian_holidaystable, because PAYA/SATNA transfers fail on holidays. - 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. - 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.
- Balinyaar initiates the reversal through the provider's API (SnappPay
revertfor full /cancel/updatefor partial, using the storedexternal_payment_token). - 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).
- Balinyaar records a
refundsrow withrefund_channel = 'bnpl_revert', carryingexternal_revert_referenceandexpected_customer_refund_eta;refund_statusstaysprocessinguntil a reconciliation job confirms. - The refund decomposes across the two fee legs —
platform_fee_refunded_irrandnurse_payout_refunded_irr— and posts balanced ledger entries. - If the nurse has not yet been paid (still inside the dispute window / not in a processed batch): the
nurse_payableaccrual is simply reversed; nothing leaves Balinyaar. Clean. - If the nurse has already been paid: this is the clawback path — a
nurse_clawbacksreceivable + 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
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/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 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 | 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 CUT; installment_entriesbnpl_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)
-
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.
-
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_entriesremoved an entire fragile subsystem and replaced it with one reconciliation row. -
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.
-
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_clawbacksreceivable fixes both and makes bank/Shaparak reconciliation possible. -
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.
-
Idempotency before money —
payment_webhook_eventskeyed on the provider event id, written first, is the cheapest insurance against the most damaging payments bug (double-confirm / double-settle on callback retries). -
Multi-session engagements are the norm, not an edge case —
booking_sessionsmakes 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. -
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.
-
Verified-trust must be queryable —
nurse_credentialsturns the brand promise into renewal alerts, a real badge, and audit defensibility, surviving the future arrival of an INO/MoH API. -
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_rateis 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.mdfor the full source-cited analysis.