Files
baya-monorepo/product/balinyaar-business-and-data-model.html
T
2026-06-23 23:36:19 +03:30

2697 lines
231 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en" dir="ltr" data-theme="light">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Balinyaar — Business & Data Model Handbook</title>
<meta name="description" content="A comprehensive, step-by-step explanation of the Balinyaar home-nursing marketplace business and its ~53-table data model, with every business step mapped to its supporting entities." />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&display=swap" rel="stylesheet" />
<style>
/* ===================================================================
Balinyaar — Business & Data Model Handbook
Single self-contained document. Brand: teal (#1d4a40) + terracotta (#d98c6a).
Light is default; data-theme="dark" on <html> flips the token set.
Tokens mirror client/src/theme/tokens.css exactly.
=================================================================== */
/* ---- Light scheme (default) ---- */
:root,
html[data-theme="light"] {
--primary: #1d4a40;
--primary-light: #2f6b5e;
--primary-dark: #123029;
--primary-contrast: #f3efe9;
--secondary: #d98c6a;
--secondary-light: #e6a98a;
--secondary-dark: #bf6f4d;
--secondary-contrast: #2a1a12;
--bg-default: #faf9f5;
--bg-paper: #ffffff;
--bg-sunken: #f3efe9;
--text-primary: #1b2521;
--text-secondary: #5b655f;
--divider: rgba(29, 74, 64, 0.14);
--divider-strong: rgba(29, 74, 64, 0.28);
--success: #1f6b50;
--success-contrast: #f3efe9;
--error: #a8392a;
--error-contrast: #f3efe9;
--warning: #8a6418;
--warning-contrast: #f3efe9;
--info: #1d4a40;
--info-contrast: #f3efe9;
/* soft tinted callout fills derived from the feedback hues */
--fill-primary: rgba(29, 74, 64, 0.06);
--fill-secondary: rgba(217, 140, 106, 0.12);
--fill-success: rgba(31, 107, 80, 0.10);
--fill-error: rgba(168, 57, 42, 0.09);
--fill-warning: rgba(138, 100, 24, 0.10);
--fill-info: rgba(29, 74, 64, 0.07);
--table-head-bg: #1d4a40;
--table-head-text: #f3efe9;
--table-zebra: rgba(29, 74, 64, 0.045);
--table-hover: rgba(217, 140, 106, 0.10);
--code-bg: #f0ece3;
--code-text: #123029;
--shadow-sm: 0 1px 2px rgba(18, 48, 41, 0.06), 0 1px 3px rgba(18, 48, 41, 0.05);
--shadow-md: 0 4px 14px rgba(18, 48, 41, 0.08);
--sidebar-bg: #ffffff;
}
/* ---- Dark scheme ---- */
html[data-theme="dark"] {
--primary: #6fc0ac;
--primary-light: #8fd2c1;
--primary-dark: #3f8a78;
--primary-contrast: #06120f;
--secondary: #e6a98a;
--secondary-light: #f0bfa3;
--secondary-dark: #d98c6a;
--secondary-contrast: #2a1a12;
--bg-default: #0f1c19;
--bg-paper: #16302a;
--bg-sunken: #0b1714;
--text-primary: #f3efe9;
--text-secondary: #9fb0a9;
--divider: rgba(243, 239, 233, 0.14);
--divider-strong: rgba(243, 239, 233, 0.26);
--success: #257659;
--success-contrast: #f3efe9;
--error: #b5402f;
--error-contrast: #f3efe9;
--warning: #97701f;
--warning-contrast: #f3efe9;
--info: #2f6b5e;
--info-contrast: #f3efe9;
--fill-primary: rgba(111, 192, 172, 0.10);
--fill-secondary: rgba(230, 169, 138, 0.14);
--fill-success: rgba(37, 118, 89, 0.20);
--fill-error: rgba(181, 64, 47, 0.18);
--fill-warning: rgba(151, 112, 31, 0.20);
--fill-info: rgba(47, 107, 94, 0.20);
--table-head-bg: #123029;
--table-head-text: #f3efe9;
--table-zebra: rgba(243, 239, 233, 0.045);
--table-hover: rgba(230, 169, 138, 0.10);
--code-bg: #0b1714;
--code-text: #8fd2c1;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.30), 0 1px 3px rgba(0, 0, 0, 0.28);
--shadow-md: 0 6px 18px rgba(0, 0, 0, 0.40);
--sidebar-bg: #11221e;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; scroll-padding-top: 84px; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: var(--bg-default);
color: var(--text-primary);
line-height: 1.65;
font-size: 16px;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
transition: background-color 0.25s ease, color 0.25s ease;
}
h1, h2, h3, h4, h5, h6 {
font-family: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: var(--primary);
line-height: 1.25;
margin: 0 0 0.5em;
letter-spacing: -0.01em;
}
h1, h2, h3, h4, h5 { font-weight: 700; }
h6 { font-weight: 600; }
p { margin: 0 0 1em; }
a { color: var(--primary); text-decoration: none; }
a:hover { text-decoration: underline; }
code, .mono {
font-family: "SFMono-Regular", "Consolas", "Liberation Mono", Menlo, monospace;
font-size: 0.88em;
background: var(--code-bg);
color: var(--code-text);
padding: 0.1em 0.38em;
border-radius: 5px;
word-break: break-word;
}
pre {
background: var(--code-bg);
color: var(--code-text);
border: 1px solid var(--divider);
border-radius: 10px;
padding: 14px 16px;
overflow-x: auto;
font-size: 0.86rem;
line-height: 1.5;
margin: 0 0 1.2em;
}
pre code { background: none; padding: 0; color: inherit; }
/* ---------- App shell ---------- */
.app {
display: grid;
grid-template-columns: 312px minmax(0, 1fr);
max-width: 1500px;
margin: 0 auto;
}
/* ---------- Top bar ---------- */
.topbar {
position: sticky;
top: 0;
z-index: 50;
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 28px;
background: color-mix(in srgb, var(--bg-paper) 88%, transparent);
backdrop-filter: saturate(140%) blur(10px);
border-bottom: 1px solid var(--divider);
}
.topbar .brand {
display: flex;
align-items: center;
gap: 12px;
font-family: "Space Grotesk", sans-serif;
font-weight: 700;
font-size: 1.05rem;
color: var(--primary);
}
.brand .logo {
width: 30px; height: 30px;
border-radius: 9px;
background: linear-gradient(135deg, var(--primary), var(--primary-light));
display: grid; place-items: center;
color: var(--primary-contrast);
font-weight: 700;
box-shadow: var(--shadow-sm);
flex: none;
}
.brand .sub {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-weight: 500; font-size: 0.78rem; color: var(--text-secondary);
}
.topbar-actions { display: flex; align-items: center; gap: 10px; }
.theme-toggle {
display: inline-flex; align-items: center; gap: 8px;
border: 1px solid var(--divider-strong);
background: var(--bg-paper);
color: var(--text-primary);
border-radius: 10px;
padding: 7px 13px;
font: inherit; font-size: 0.85rem; font-weight: 600;
cursor: pointer;
transition: background 0.18s ease, border-color 0.18s ease, transform 0.1s ease;
}
.theme-toggle:hover { border-color: var(--secondary); background: var(--fill-secondary); }
.theme-toggle:active { transform: translateY(1px); }
.theme-toggle .icon { width: 16px; height: 16px; display: inline-block; }
html[data-theme="dark"] .theme-toggle .label-dark { display: none; }
html[data-theme="light"] .theme-toggle .label-light { display: none; }
/* ---------- Sidebar ---------- */
.sidebar {
position: sticky;
top: 57px;
align-self: start;
height: calc(100vh - 57px);
overflow-y: auto;
background: var(--sidebar-bg);
border-right: 1px solid var(--divider);
padding: 22px 14px 60px 22px;
}
.sidebar h2 {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.10em;
color: var(--text-secondary);
margin: 18px 8px 8px;
font-weight: 700;
}
.sidebar h2:first-child { margin-top: 0; }
.toc { list-style: none; margin: 0 0 6px; padding: 0; }
.toc li { margin: 1px 0; }
.toc a {
display: block;
padding: 5px 10px;
border-radius: 8px;
font-size: 0.86rem;
color: var(--text-secondary);
border-left: 2px solid transparent;
line-height: 1.35;
}
.toc a:hover { background: var(--fill-primary); color: var(--primary); text-decoration: none; }
.toc a.active { background: var(--fill-secondary); color: var(--primary); border-left-color: var(--secondary); font-weight: 600; }
.toc .num {
display: inline-block;
min-width: 1.5em;
font-variant-numeric: tabular-nums;
color: var(--secondary-dark);
font-weight: 600;
}
.toc .sub a { padding-left: 26px; font-size: 0.81rem; }
/* ---------- Content ---------- */
.content {
padding: 30px clamp(20px, 4vw, 64px) 120px;
min-width: 0;
}
.content section { scroll-margin-top: 80px; padding-top: 14px; }
.content section + section { margin-top: 8px; }
.eyebrow {
display: inline-block;
font-family: "Space Grotesk", sans-serif;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.13em;
color: var(--secondary-dark);
background: var(--fill-secondary);
padding: 4px 11px;
border-radius: 999px;
margin-bottom: 10px;
}
.content h2.section-title {
font-size: clamp(1.5rem, 2.4vw, 2.05rem);
padding-bottom: 12px;
border-bottom: 2px solid var(--divider);
margin-bottom: 18px;
scroll-margin-top: 80px;
}
.content h2.section-title .step-no {
color: var(--secondary);
font-weight: 700;
margin-right: 10px;
}
.content h3 { font-size: 1.28rem; margin-top: 1.8em; }
.content h4 { font-size: 1.05rem; margin-top: 1.5em; color: var(--primary-dark); }
html[data-theme="dark"] .content h4 { color: var(--primary-light); }
.content h5 { font-size: 0.95rem; margin-top: 1.3em; color: var(--text-primary); }
.lead {
font-size: 1.06rem;
color: var(--text-secondary);
max-width: 78ch;
}
.content ul, .content ol { padding-left: 1.35em; margin: 0 0 1.1em; }
.content li { margin: 0.34em 0; }
.content li > ul, .content li > ol { margin-top: 0.34em; margin-bottom: 0.4em; }
.content strong { color: var(--text-primary); font-weight: 700; }
html[data-theme="dark"] .content strong { color: #fdfbf7; }
.fa { font-style: normal; }
hr.rule { border: none; border-top: 1px solid var(--divider); margin: 40px 0; }
/* ---------- Cards ---------- */
.card {
background: var(--bg-paper);
border: 1px solid var(--divider);
border-radius: 10px;
padding: 20px 22px;
box-shadow: var(--shadow-sm);
margin: 0 0 18px;
}
.grid2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; }
.grid3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; }
/* ---------- Callouts / admonitions ---------- */
.callout {
border-radius: 10px;
padding: 14px 16px 14px 18px;
margin: 16px 0;
border-left: 4px solid var(--primary);
background: var(--fill-primary);
position: relative;
}
.callout > .callout-title {
display: flex; align-items: center; gap: 8px;
font-family: "Space Grotesk", sans-serif;
font-weight: 700;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 6px;
}
.callout p:last-child, .callout ul:last-child, .callout ol:last-child { margin-bottom: 0; }
.callout .badge-ico {
width: 20px; height: 20px; border-radius: 6px; flex: none;
display: grid; place-items: center; font-size: 0.7rem; font-weight: 700;
color: #fff;
}
.callout.iran { border-left-color: var(--secondary-dark); background: var(--fill-secondary); }
.callout.iran .callout-title { color: var(--secondary-dark); }
.callout.iran .badge-ico { background: var(--secondary-dark); }
.callout.mvp { border-left-color: var(--success); background: var(--fill-success); }
.callout.mvp .callout-title { color: var(--success); }
.callout.mvp .badge-ico { background: var(--success); }
.callout.deferred { border-left-color: var(--warning); background: var(--fill-warning); }
.callout.deferred .callout-title { color: var(--warning); }
.callout.deferred .badge-ico { background: var(--warning); }
.callout.decision { border-left-color: var(--info); background: var(--fill-info); }
.callout.decision .callout-title { color: var(--info); }
html[data-theme="dark"] .callout.decision .callout-title { color: var(--primary-light); }
.callout.decision .badge-ico { background: var(--info); }
.callout.open { border-left-color: var(--error); background: var(--fill-error); }
.callout.open .callout-title { color: var(--error); }
.callout.open .badge-ico { background: var(--error); }
.callout.note { border-left-color: var(--primary-light); background: var(--fill-primary); }
.callout.note .callout-title { color: var(--primary); }
html[data-theme="dark"] .callout.note .callout-title { color: var(--primary-light); }
/* ---------- Data-model mapping block ---------- */
.datamodel {
border: 1px solid var(--divider-strong);
border-radius: 10px;
margin: 22px 0;
overflow: hidden;
background: var(--bg-paper);
box-shadow: var(--shadow-sm);
}
.datamodel > .dm-head {
background: linear-gradient(120deg, var(--primary), var(--primary-light));
color: var(--primary-contrast);
padding: 11px 18px;
font-family: "Space Grotesk", sans-serif;
font-weight: 700;
font-size: 0.92rem;
display: flex; align-items: center; gap: 10px;
letter-spacing: 0.01em;
}
.datamodel > .dm-head .dm-ico {
width: 22px; height: 22px;
}
.datamodel > .dm-body { padding: 16px 18px 6px; }
.datamodel .dm-body > p:first-child { margin-top: 0; }
.entities { display: flex; flex-wrap: wrap; gap: 7px; margin: 2px 0 14px; }
.entity {
font-family: "SFMono-Regular", "Consolas", monospace;
font-size: 0.8rem;
background: var(--fill-primary);
border: 1px solid var(--divider-strong);
color: var(--primary);
padding: 3px 9px;
border-radius: 7px;
white-space: nowrap;
}
html[data-theme="dark"] .entity { color: var(--primary-light); }
.entity.key { background: var(--secondary); color: var(--secondary-contrast); border-color: var(--secondary-dark); font-weight: 700; }
.dm-rel { font-size: 0.93rem; }
.dm-rel li { margin: 0.3em 0; }
.dm-rel .ent { font-family: "SFMono-Regular", monospace; font-size: 0.85em; color: var(--secondary-dark); font-weight: 600; }
html[data-theme="dark"] .dm-rel .ent { color: var(--secondary); }
/* ---------- Tables ---------- */
.table-wrap { overflow-x: auto; border-radius: 10px; border: 1px solid var(--divider); margin: 0 0 20px; box-shadow: var(--shadow-sm); }
table.tbl {
border-collapse: collapse;
width: 100%;
font-size: 0.88rem;
background: var(--bg-paper);
}
table.tbl thead th {
background: var(--table-head-bg);
color: var(--table-head-text);
text-align: left;
font-family: "Space Grotesk", sans-serif;
font-weight: 600;
padding: 10px 13px;
position: sticky;
top: 0;
white-space: nowrap;
}
table.tbl tbody td {
padding: 9px 13px;
border-top: 1px solid var(--divider);
vertical-align: top;
}
table.tbl tbody tr:nth-child(even) { background: var(--table-zebra); }
table.tbl tbody tr:hover { background: var(--table-hover); }
table.tbl td code, table.tbl th code { font-size: 0.82em; }
table.tbl .col-field { font-family: "SFMono-Regular", "Consolas", monospace; font-size: 0.82rem; color: var(--primary); white-space: nowrap; }
html[data-theme="dark"] table.tbl .col-field { color: var(--primary-light); }
table.tbl .ty { font-family: "SFMono-Regular", monospace; font-size: 0.78rem; color: var(--text-secondary); white-space: nowrap; }
/* schema table: a compact variant */
table.schema td:first-child { width: 30%; }
/* ---------- Tag chips for scope ---------- */
.tag {
display: inline-block;
font-family: "Space Grotesk", sans-serif;
font-size: 0.66rem;
font-weight: 700;
letter-spacing: 0.04em;
padding: 2px 7px;
border-radius: 5px;
text-transform: uppercase;
vertical-align: middle;
margin-left: 4px;
}
.tag.core { background: var(--primary); color: var(--primary-contrast); }
.tag.mvp { background: var(--success); color: var(--success-contrast); }
.tag.def { background: var(--warning); color: var(--warning-contrast); }
.tag.new { background: var(--secondary); color: var(--secondary-contrast); }
.tag.cut { background: transparent; color: var(--error); border: 1px solid var(--error); text-decoration: line-through; }
.tag.chg { background: var(--info); color: var(--info-contrast); }
/* ---------- Step flow (numbered chips) ---------- */
.flow { counter-reset: stp; list-style: none; padding: 0; margin: 14px 0 18px; }
.flow > li {
position: relative;
padding: 2px 0 14px 44px;
margin: 0;
border-left: 2px solid var(--divider);
margin-left: 14px;
}
.flow > li:last-child { border-left-color: transparent; padding-bottom: 0; }
.flow > li::before {
counter-increment: stp;
content: counter(stp);
position: absolute;
left: -15px; top: -2px;
width: 28px; height: 28px;
border-radius: 50%;
background: var(--primary);
color: var(--primary-contrast);
display: grid; place-items: center;
font-family: "Space Grotesk", sans-serif;
font-weight: 700; font-size: 0.85rem;
box-shadow: var(--shadow-sm);
}
/* status pills inline in prose */
.pill {
font-family: "SFMono-Regular", monospace;
font-size: 0.78em;
background: var(--bg-sunken);
border: 1px solid var(--divider);
padding: 1px 6px;
border-radius: 5px;
white-space: nowrap;
}
/* lifecycle arrow row */
.lifecycle { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 10px 0 16px; font-family: "SFMono-Regular", monospace; font-size: 0.82rem; }
.lifecycle .st { background: var(--fill-primary); border: 1px solid var(--divider-strong); color: var(--primary); padding: 3px 9px; border-radius: 7px; }
html[data-theme="dark"] .lifecycle .st { color: var(--primary-light); }
.lifecycle .arw { color: var(--secondary); font-weight: 700; }
/* ---------- Mermaid ---------- */
.diagram {
background: var(--bg-paper);
border: 1px solid var(--divider);
border-radius: 10px;
padding: 18px;
margin: 18px 0 22px;
overflow-x: auto;
box-shadow: var(--shadow-sm);
}
.diagram .mermaid { display: flex; justify-content: center; min-width: 0; }
.diagram .dgm-cap {
font-size: 0.82rem;
color: var(--text-secondary);
margin-top: 10px;
text-align: center;
font-style: italic;
}
/* account-ledger posting blocks */
.posting {
border: 1px solid var(--divider-strong);
border-radius: 10px;
overflow: hidden;
margin: 0 0 16px;
background: var(--bg-paper);
}
.posting .pt {
background: var(--fill-info);
color: var(--primary);
font-family: "Space Grotesk", sans-serif;
font-weight: 700;
font-size: 0.86rem;
padding: 8px 14px;
border-bottom: 1px solid var(--divider);
}
html[data-theme="dark"] .posting .pt { color: var(--primary-light); }
.posting pre { margin: 0; border: none; border-radius: 0; background: var(--code-bg); }
/* footnote / source list */
.sources { font-size: 0.84rem; color: var(--text-secondary); }
.sources li { word-break: break-word; }
/* small helper */
.muted { color: var(--text-secondary); }
.center { text-align: center; }
.nowrap { white-space: nowrap; }
.back-to-top {
display: inline-block;
margin-top: 14px;
font-size: 0.8rem;
color: var(--secondary-dark);
}
html[data-theme="dark"] .back-to-top { color: var(--secondary); }
.legend { display: flex; flex-wrap: wrap; gap: 14px; margin: 8px 0 4px; font-size: 0.82rem; }
.legend span { display: inline-flex; align-items: center; gap: 6px; }
.legend .sw { width: 14px; height: 14px; border-radius: 4px; display: inline-block; }
/* responsive */
@media (max-width: 1020px) {
.app { grid-template-columns: 1fr; }
.sidebar {
position: static; height: auto; max-height: none;
border-right: none; border-bottom: 1px solid var(--divider);
}
html { scroll-padding-top: 12px; }
}
@media print {
.sidebar, .topbar { display: none; }
.app { display: block; }
.content { padding: 0; }
}
</style>
</head>
<body>
<div class="app">
<!-- ============ TOP BAR ============ -->
<header class="topbar">
<div class="brand">
<span class="logo">ب</span>
<span>
Balinyaar
<span class="sub">&nbsp;·&nbsp;Business &amp; Data Model Handbook</span>
</span>
</div>
<div class="topbar-actions">
<button class="theme-toggle" id="themeToggle" type="button" aria-label="Toggle light/dark theme">
<svg class="icon label-light" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/></svg>
<svg class="icon label-dark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
<span class="label-light">Dark</span>
<span class="label-dark">Light</span>
</button>
</div>
</header>
<!-- ============ SIDEBAR TOC ============ -->
<nav class="sidebar" id="sidebar" aria-label="Table of contents">
<h2>Overview</h2>
<ul class="toc">
<li><a href="#intro">Introduction &amp; how to read this</a></li>
<li><a href="#what-is">What Balinyaar is</a></li>
<li><a href="#ground-truths">Cross-cutting ground truths</a></li>
<li><a href="#principles">Data-model design principles</a></li>
<li><a href="#journey">The platform at a glance</a></li>
</ul>
<h2>The business, step by step</h2>
<ul class="toc">
<li><a href="#s1"><span class="num">1</span>Actors &amp; Onboarding</a></li>
<li><a href="#s2"><span class="num">2</span>Nurse Verification &amp; Credentials</a></li>
<li><a href="#s3"><span class="num">3</span>Service Catalog &amp; Pricing</a></li>
<li><a href="#s4"><span class="num">4</span>Search &amp; Matching</a></li>
<li><a href="#s5"><span class="num">5</span>Booking &amp; Scheduling</a></li>
<li><a href="#s6"><span class="num">6</span>EVV / Service Delivery</a></li>
<li><a href="#s7"><span class="num">7</span>Cancellation &amp; Refunds</a></li>
<li><a href="#s8"><span class="num">8</span>Payments &amp; Escrow</a></li>
<li><a href="#s9"><span class="num">9</span>Installments / BNPL</a></li>
<li><a href="#s10"><span class="num">10</span>Payouts to Nurses</a></li>
<li><a href="#s11"><span class="num">11</span>Reviews, Trust &amp; Safety</a></li>
<li><a href="#s12"><span class="num">12</span>Messaging &amp; Emergencies</a></li>
<li><a href="#s13"><span class="num">13</span>Tax, Invoicing &amp; Legal</a></li>
<li><a href="#s14"><span class="num">14</span>Notifications &amp; Admin</a></li>
</ul>
<h2>Deep dives</h2>
<ul class="toc">
<li><a href="#legal">Legal landscape (Iran)</a></li>
<li><a href="#verif-pipeline">Verification pipeline</a></li>
<li><a href="#escrow-ledger">Escrow as a ledger</a></li>
<li><a href="#bnpl-deep">BNPL mechanics &amp; Q1/Q2</a></li>
<li><a href="#market">Market &amp; competitors</a></li>
</ul>
<h2>The whole data model</h2>
<ul class="toc">
<li><a href="#dm-intro">Catalog overview &amp; legend</a></li>
<li class="sub"><a href="#dm-d1">D1 · Identity &amp; Access</a></li>
<li class="sub"><a href="#dm-d2">D2 · Geographic</a></li>
<li class="sub"><a href="#dm-d3">D3 · Services &amp; Pricing</a></li>
<li class="sub"><a href="#dm-d4">D4 · Verification</a></li>
<li class="sub"><a href="#dm-d5">D5 · Booking &amp; Scheduling</a></li>
<li class="sub"><a href="#dm-d6">D6 · Payments / Ledger</a></li>
<li class="sub"><a href="#dm-d7">D7 · Payouts</a></li>
<li class="sub"><a href="#dm-d8">D8 · BNPL</a></li>
<li class="sub"><a href="#dm-d9">D9 · Messaging</a></li>
<li class="sub"><a href="#dm-d10">D10 · Reviews &amp; Records</a></li>
<li class="sub"><a href="#dm-d11">D11 · Notifications</a></li>
<li class="sub"><a href="#dm-d12">D12 · Audit &amp; Config</a></li>
<li class="sub"><a href="#dm-d13">D13 · Partner Centers &amp; Future</a></li>
<li><a href="#dm-rel">Relationship summary</a></li>
<li><a href="#dm-decisions">Key design decisions</a></li>
<li><a href="#dm-open">Open items</a></li>
</ul>
<h2>Diagrams</h2>
<ul class="toc">
<li><a href="#dgm-domain">1 · Domain map</a></li>
<li><a href="#dgm-spine">2 · Booking spine</a></li>
<li><a href="#dgm-pay">3 · Payments &amp; payouts</a></li>
<li><a href="#dgm-life">4 · Financial lifecycle</a></li>
<li><a href="#dgm-er">5 · ER overview</a></li>
</ul>
</nav>
<!-- ============ MAIN CONTENT ============ -->
<main class="content">
<!-- ========== INTRO ========== -->
<section id="intro">
<span class="eyebrow">Internal Handbook</span>
<h1 style="font-size:clamp(2rem,4vw,2.8rem);margin-bottom:8px;">Balinyaar — Business &amp; Data Model</h1>
<p class="lead">A single, comprehensive walkthrough of <strong>what Balinyaar does</strong> and <strong>how its data is shaped</strong>. It walks the platform end to end &mdash; from a family searching for a nurse to the weekly bank transfer that pays that nurse &mdash; and, at every step, names the database entities that make the step real. A final reference assembles the entire <strong>~53-table model across 13 domains</strong>, with diagrams.</p>
<div class="callout note">
<div class="callout-title"><span class="badge-ico">i</span>How to read this document</div>
<p>Sections <strong>1&ndash;14</strong> are the business, in the order the platform actually runs. Each ends with a <strong>Data model</strong> block (teal header) listing the supporting tables and explaining how they connect to that step. The colored callouts recur throughout and always mean the same thing:</p>
<div class="legend" style="margin-top:10px;">
<span><span class="sw" style="background:var(--secondary-dark)"></span><strong>Iran-specific</strong> — local legal/fiscal/cultural reality</span>
<span><span class="sw" style="background:var(--success)"></span><strong>MVP</strong> — ships at launch</span>
<span><span class="sw" style="background:var(--warning)"></span><strong>Deferred</strong> — modeled now, inactive at launch</span>
<span><span class="sw" style="background:var(--info)"></span><strong>Design decision</strong> — a deliberate choice &amp; its reasoning</span>
<span><span class="sw" style="background:var(--error)"></span><strong>Open question</strong> — confirm before building</span>
</div>
<p style="margin-top:10px;">All monetary values are in <strong>IRR (Rials)</strong>, stored as <code>BIGINT</code>. Toman is display-only and is converted to/from Rials <em>only</em> at an external provider's API boundary. Persian credential and product names are kept in their original script for fidelity.</p>
</div>
<p class="muted" style="font-size:0.86rem;">Sources: <code>business-requirements.md</code> (14 business domains), <code>database-model.md</code> (Revision 2 — 53 tables / 13 domains / 4 diagrams), <code>payments-and-installments.md</code> (escrow, settlement &amp; BNPL deep-dive), and the market/legal/verification research report. Document date basis: 2026-06-20.</p>
</section>
<!-- ========== WHAT IS ========== -->
<section id="what-is">
<h2 class="section-title">What Balinyaar is</h2>
<p>Balinyaar is a <strong>trust-first home-nursing marketplace in Iran</strong>. Independent, individually-verified nurses (and, later, nursing-company employees) register, list configurable services at their own prices, and pass a multi-step verification pipeline anchored on the Ministry of Health <span class="fa">پروانه صلاحیت حرفه‌ای</span> (professional-competency license). Families search &mdash; filtered by city/district <strong>and same-gender caregiver preference</strong> &mdash; pick a nurse and a service variant, submit a booking request, and pay <strong>through the platform</strong> after the nurse accepts.</p>
<p>The platform records the money as an <strong>internal escrow ledger state</strong> (not platform-held cash), the nurse performs one or more EVV-verified visits, and the platform pays the nurse <strong>weekly, after a dispute window closes</strong>, minus a platform commission. All post-booking communication runs through an admin-readable ticket system &mdash; there is no direct nurse&harr;customer chat.</p>
<div class="grid3">
<div class="card">
<h6 style="margin-bottom:4px;">The three actors</h6>
<p style="font-size:0.9rem;margin:0;"><strong>Customer</strong> — the family member who pays.<br/><strong>Nurse</strong> — the independent caregiver who sells.<br/><strong>Admin</strong> — Balinyaar back-office (support, finance, moderation, super-admin).</p>
</div>
<div class="card">
<h6 style="margin-bottom:4px;">The fourth entity: the patient</h6>
<p style="font-size:0.9rem;margin:0;">The <strong>patient</strong> (care recipient) is first-class and distinct from the payer, because the payer is usually <em>not</em> the patient (an adult child, a spouse vs. an elderly parent, a newborn, a post-surgical adult).</p>
</div>
<div class="card">
<h6 style="margin-bottom:4px;">The launch legal vehicle</h6>
<p style="font-size:0.9rem;margin:0;">At launch the platform operates under a <strong>partner licensed home-nursing center</strong> (<span class="fa">مرکز مشاوره و ارائه مراقبت‌های پرستاری در منزل</span>) &mdash; the Asanism-style model &mdash; the legal vehicle and likely merchant-of-record while Balinyaar's own MoH permit is in process.</p>
</div>
</div>
<div class="callout iran">
<div class="callout-title"><span class="badge-ico">!</span>Why "Iran-specific" is a recurring theme</div>
<p>This is not a generic marketplace. Iranian payment law forbids the platform from holding customer cash; same-gender bodily-care is culturally decisive; home nursing is a licensed healthcare activity; VAT is 10%; bank transfers are effectively irreversible; and credential verification is fragmented across regulators with no public B2B API. Each of these reshapes a requirement &mdash; so each business section carries its own Iran-specific callout.</p>
</div>
</section>
<!-- ========== GROUND TRUTHS ========== -->
<section id="ground-truths">
<h2 class="section-title">Cross-cutting ground truths</h2>
<p>Four facts hold in <em>every</em> section below. They are the load-bearing constraints the whole design is built around.</p>
<ol class="flow">
<li>
<h5 style="margin-top:0;">Balinyaar cannot legally custody customer cash</h5>
<p style="margin:0;">Under Iranian rules a <span class="fa">پرداخت‌یار</span> (payment facilitator) is forbidden from holding deposits, running wallets, or moving money between merchants. Money always flows <strong>card &rarr; licensed PSP &rarr; Shaparak settlement &rarr; bank-registered IBANs</strong>. "Escrow" is therefore an <strong>internal ledger state</strong> over funds custodied at a licensed provider/partner bank &mdash; never a Balinyaar-owned cash balance. <span class="entity">ledger_entries</span></p>
</li>
<li>
<h5 style="margin-top:0;">VAT is 10%, configurable</h5>
<p style="margin:0;">It rose from 9% to 10% in 1403 (7% government + 3% municipal) and is treated as a configurable rate &mdash; it has moved two years running, so it is never hardcoded.</p>
</li>
<li>
<h5 style="margin-top:0;">BNPL is full-upfront</h5>
<p style="margin:0;">A BNPL provider settles <strong>one full-amount lump (net of its commission) to the merchant-of-record</strong>, bears 100% of customer-default risk, and owns the customer's installment repayment entirely. A BNPL order behaves in Balinyaar's books exactly like a card payment landing net-of-fee. <span class="entity">bnpl_transactions</span></p>
</li>
<li>
<h5 style="margin-top:0;">The nurse is paid by Balinyaar, weekly, on Balinyaar's own schedule</h5>
<p style="margin:0;">Gated on EVV completion and a closed dispute window &mdash; <em>regardless of how the family paid</em>. The nurse's payout is always <code>gross_price_irr &minus; balinyaar_commission_irr</code>, never a BNPL provider's net settlement.</p>
</li>
</ol>
</section>
<!-- ========== PRINCIPLES ========== -->
<section id="principles">
<h2 class="section-title">Data-model design principles</h2>
<p>The schema (Revision 2) follows thirteen principles. They explain <em>why</em> the entity tables look the way they do, and recur in the data-model mappings.</p>
<div class="table-wrap">
<table class="tbl">
<thead><tr><th style="width:34px;">#</th><th>Principle</th></tr></thead>
<tbody>
<tr><td>1</td><td><strong>Money is <code>BIGINT</code> in IRR.</strong> Toman is display-only; conversion happens only at a provider's API boundary. No floats anywhere on the money path.</td></tr>
<tr><td>2</td><td><strong>The platform never legally holds buyer cash.</strong> Funds settle through a licensed PSP to registered IBANs (commission IBAN + nurse IBAN via <span class="fa">تسهیم</span> settlement-sharing, or one merchant-of-record account). "Escrow"/"nurse balance" are <strong>derived ledger states</strong>, never a Balinyaar cash balance.</td></tr>
<tr><td>3</td><td><strong><code>ledger_entries</code> is the financial source of truth.</strong> Every capture, commission, payout, refund, and clawback posts <strong>balanced</strong> double-entry rows. Per-table money fields stay the operational/pricing record; the ledger answers "how much do we owe nurses right now" and "how much is held but unreleased."</td></tr>
<tr><td>4</td><td><strong>Fee split is captured per booking,</strong> never derived from live config, so historical reporting survives commission-schedule changes. The booking stores <code>gross_price_irr</code>, <code>balinyaar_commission_irr</code>, <code>nurse_payout_amount</code>.</td></tr>
<tr><td>5</td><td><strong>PII is marked (encrypted)</strong> &mdash; national ID, IBAN, phone, addresses, clinical data &mdash; column- or app-level. Clinical data has stricter access than financial data.</td></tr>
<tr><td>6</td><td><strong>Two-stage clinical disclosure is a hard rule.</strong> At the request stage the nurse sees only <code>booking_requests.customer_notes</code>; the full encrypted <code>booking_care_instructions</code> are exposed <strong>only after</strong> the booking is confirmed. Enforced at the authorization layer.</td></tr>
<tr><td>7</td><td><strong>Soft deletes</strong> on <code>users</code>/<code>nurse_profiles</code> via <code>deleted_at</code>. Audit, payment, ledger, and payout records are <strong>never</strong> deleted.</td></tr>
<tr><td>8</td><td><strong>Audit trail is append-only.</strong> All state transitions on bookings, payments, refunds, payouts, verifications, reviews, and <code>platform_configs</code> produce an <code>audit_logs</code> row.</td></tr>
<tr><td>9</td><td><strong>Catalog/config tables are rows, not enums</strong> (service categories, verification step types, cancellation policies, Iranian holidays) so the business evolves without migrations. They carry <code>name_fa</code>/<code>name_en</code>.</td></tr>
<tr><td>10</td><td><strong>Idempotency is mandatory on the money path.</strong> Every PSP/BNPL callback is stored raw in <code>payment_webhook_events</code> and deduplicated on <code>external_event_id</code> <strong>before</strong> any money-state mutation.</td></tr>
<tr><td>11</td><td><strong>All timestamps are <code>DATETIME2(7)</code> UTC.</strong> Persian-calendar display is a UI concern &mdash; except that bank-closure scheduling uses the <code>iranian_holidays</code> table, because PAYA/SATNA transfers fail on holidays.</td></tr>
<tr><td>12</td><td><strong>Derived flags must not drift.</strong> <code>nurse_profiles.is_verified</code>, rating aggregates, and the search index are written <strong>only</strong> by the code path that owns their source of truth, inside the same transaction.</td></tr>
<tr><td>13</td><td><strong>Invariants are enforced, not documented:</strong> CHECK constraints (<code>gross = commission + payout</code>, <code>rating BETWEEN 1 AND 5</code>, amounts &ge; 0, <code>end_time &gt; start_time</code>), filtered-UNIQUE for "one primary"/"one active", and tenancy checks (a booking's patient/address belongs to the same customer; its variant to the same nurse).</td></tr>
</tbody>
</table>
</div>
</section>
<!-- ========== JOURNEY ========== -->
<section id="journey">
<h2 class="section-title">The platform at a glance</h2>
<p>Before the detail, here is the end-to-end path a single engagement travels. Sections 1&ndash;14 expand each stage.</p>
<ol class="flow">
<li><strong>Onboard</strong> &mdash; customer registers with phone-OTP and adds a patient + address; a nurse registers and enters verification. <span class="muted">(§1)</span></li>
<li><strong>Verify the nurse</strong> &mdash; six-step pipeline (identity, Shahkar, MoH license, INO, criminal record, IBAN ownership); only then do the nurse's variants become bookable. <span class="muted">(§2)</span></li>
<li><strong>List &amp; price services</strong> &mdash; admin defines the catalog skeleton; each nurse creates priced variants. <span class="muted">(§3)</span></li>
<li><strong>Search &amp; match</strong> &mdash; family filters by category, city/district, price, rating, and same-gender preference against a denormalized search index. <span class="muted">(§4)</span></li>
<li><strong>Request &rarr; accept &rarr; pay &rarr; confirm</strong> &mdash; request (no money) becomes a booking (payment captured), which owns N sessions. <span class="muted">(§5)</span></li>
<li><strong>Deliver with EVV</strong> &mdash; the nurse clocks in/out per session with GPS; payout is gated on EVV completion. <span class="muted">(§6)</span></li>
<li><strong>Handle cancellation/refund</strong> &mdash; tiered, snapshotted policy; admin-only refunds decomposed across fee legs. <span class="muted">(§7)</span></li>
<li><strong>Move the money</strong> &mdash; card or BNPL capture posts a balanced ledger entry; escrow is a ledger state. <span class="muted">(§8&ndash;9)</span></li>
<li><strong>Pay the nurse</strong> &mdash; weekly batch to the verified IBAN after the dispute window, with clawback as fallback. <span class="muted">(§10)</span></li>
<li><strong>Review, communicate, invoice</strong> &mdash; one moderated review per booking; ticket-only messaging; a minimal commission invoice with 10% VAT. <span class="muted">(§11&ndash;14)</span></li>
</ol>
<p>The four reference diagrams (<a href="#dgm-domain">domain map</a>, <a href="#dgm-spine">booking spine</a>, <a href="#dgm-pay">payments &amp; payouts</a>, <a href="#dgm-life">financial lifecycle</a>) plus an <a href="#dgm-er">ER overview</a> render this visually.</p>
</section>
<hr class="rule" />
<!-- ================= SECTION 1 ================= -->
<section id="s1">
<span class="eyebrow">Business · Step 1</span>
<h2 class="section-title"><span class="step-no">1</span>Actors &amp; Onboarding</h2>
<p class="lead">Who can use the platform, how they prove who they are, and when. KYC is staged by role and risk &mdash; not collected up-front for everyone.</p>
<h3>How onboarding works</h3>
<ul>
<li><strong>Three actor types:</strong> <strong>customer</strong> (the family member / payer), <strong>nurse</strong> (the independent caregiver / seller), and <strong>admin</strong> (Balinyaar back-office: support, finance, moderation, super-admin).</li>
<li><strong>Phone number is the primary login credential.</strong> Authentication is <strong>phone-OTP</strong> (one-time SMS code). Email is optional/secondary (required only for admin accounts).</li>
<li>The <strong>patient</strong> (care recipient) is a first-class entity distinct from the customer, because the payer is frequently not the patient. A customer may register multiple patients.</li>
<li>Each successful login creates a refresh-token session that can be revoked (logout, stolen-token detection).</li>
</ul>
<h4>KYC timing is role- and risk-staged</h4>
<ul>
<li>A <strong>customer</strong> can register and browse with only a verified phone (OTP). National-ID KYC for customers is anti-fraud only and is <strong>deferred</strong> at launch.</li>
<li>A <strong>nurse</strong> must complete the full verification pipeline (§2) before any of their service variants become bookable. <code>national_id</code> is populated only after the identity step passes.</li>
<li>An <strong>admin</strong> is provisioned internally with RBAC roles.</li>
</ul>
<div class="callout iran">
<div class="callout-title"><span class="badge-ico">!</span>Iran-specific considerations</div>
<ul>
<li>Phone-OTP is the dominant Iranian login norm and is also the anchor for <strong>Shahkar</strong> SIM&harr;national-ID binding (§2).</li>
<li>Storing <code>national_id</code> only post-KYC matches the reality that identity is verified through gated vendor APIs, not collected casually at signup.</li>
<li>The booking flow must let a family member act on behalf of a patient who cannot self-advocate (infant, dementia, post-anesthesia). The <strong>customer/patient split is essential, not cosmetic</strong>.</li>
</ul>
</div>
<div class="grid2">
<div class="callout mvp" style="margin:0;">
<div class="callout-title"><span class="badge-ico">&check;</span>MVP</div>
<p>Phone-OTP login; customer/nurse/admin roles; customer&rarr;patient (1:N); session management; admin RBAC; nurse onboarding gated on verification.</p>
</div>
<div class="callout deferred" style="margin:0;">
<div class="callout-title"><span class="badge-ico">&hellip;</span>Deferred</div>
<p>Customer national-ID KYC (<code>customer_profiles.national_id_verified_at</code> exists but unused at launch); push notifications; social login; nursing-company (organization) self-onboarding.</p>
</div>
</div>
<div class="datamodel">
<div class="dm-head"><svg class="dm-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="6" rx="8" ry="3"/><path d="M4 6v6c0 1.7 3.6 3 8 3s8-1.3 8-3V6M4 12v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6"/></svg> Data model — Actors &amp; Onboarding</div>
<div class="dm-body">
<div class="entities">
<span class="entity key">users</span><span class="entity">user_sessions</span><span class="entity">roles</span><span class="entity">user_roles</span><span class="entity">nurse_profiles</span><span class="entity">customer_profiles</span><span class="entity key">patients</span><span class="entity">customer_addresses</span>
</div>
<ul class="dm-rel">
<li><span class="ent">users</span> is the single identity record for every human actor; <code>role</code> (<code>nurse</code>/<code>customer</code>/<code>admin</code>) decides which profile sub-table is populated. Phone is unique &amp; encrypted; <code>national_id</code> stays NULL until KYC; <code>gender</code> lives here because nurse gender is matched for same-gender care.</li>
<li><span class="ent">users</span> 1:1 &rarr; <span class="ent">nurse_profiles</span> / <span class="ent">customer_profiles</span> by role; 1:N &rarr; <span class="ent">user_sessions</span> (revocable refresh tokens) and <span class="ent">user_roles</span>.</li>
<li><span class="ent">roles</span> / <span class="ent">user_roles</span> implement RBAC for admin staff only (nurses/customers use <code>users.role</code>); <code>user_roles</code> keeps <code>granted_by</code>/<code>revoked_at</code> for an audit trail.</li>
<li><span class="ent">customer_profiles</span> 1:N &rarr; <span class="ent">patients</span> (the care recipients) and <span class="ent">customer_addresses</span> (saved, encrypted, with coordinates for EVV distance checks; exactly one <code>is_primary</code> via filtered UNIQUE).</li>
<li><strong>Tenancy invariant:</strong> a booking's <code>patient_id</code> and <code>customer_address_id</code> must belong to the same customer.</li>
</ul>
</div>
</div>
<a class="back-to-top" href="#intro">&uarr; Back to top</a>
</section>
<hr class="rule" />
<!-- ================= SECTION 2 ================= -->
<section id="s2">
<span class="eyebrow">Business · Step 2</span>
<h2 class="section-title"><span class="step-no">2</span>Nurse Verification &amp; Credentials</h2>
<p class="lead">Verified trust is the <strong>entire brand</strong>. Vetting is platform-owned, non-optional, and performed at the authoritative source &mdash; never delegated to families, never marketed as a check the platform does not actually perform. A nurse is bookable only after all <em>required</em> steps pass.</p>
<h3>A data-driven pipeline</h3>
<p>The set of steps lives as rows in <code>verification_step_types</code> (not a code enum), so a new regulatory requirement (e.g. professional liability insurance) is <strong>one INSERT, not a migration</strong>. Each step is <strong>automated</strong> (a KYC vendor API call) or <strong>manual</strong> (admin reviews an uploaded document). The aggregate <code>nurse_verifications</code> record rolls the step outcomes into a single status; <code>nurse_profiles.is_verified</code> flips to true <strong>only inside the same transaction</strong> that confirms every required step is <span class="pill">passed</span>.</p>
<h4>The six verification steps</h4>
<ol class="flow">
<li>
<h5 style="margin-top:0;">Identity (KYC) &mdash; <span style="color:var(--success)">automated</span></h5>
<p style="margin:0;">Match person &harr; <span class="fa">کد ملی</span> (national ID) &harr; phone &harr; face via one Iranian KYC vendor: national-ID validity/name match + photo/video <strong>liveness</strong> against the national-card / civil-registry (<span class="fa">ثبت احوال</span>) photo. Binds the profile to a real identity and a liveness selfie to defeat stolen-identity / alias fraud.</p>
</li>
<li>
<h5 style="margin-top:0;">Shahkar phone&harr;national-id binding &mdash; <span style="color:var(--success)">automated</span></h5>
<p style="margin:0;">Confirm the login SIM is registered to the nurse's own <span class="fa">کد ملی</span>. The binding result (when, which vendor, the reference) is recorded, and <strong>re-verification is triggered on phone change</strong>. The shared-SIM failure mode (a SIM owned by a family member) is an explicit, handled state.</p>
</li>
<li>
<h5 style="margin-top:0;">MoH <span class="fa">پروانه صلاحیت حرفه‌ای</span> &mdash; the single most important credential</h5>
<p style="margin:0;">The MoH-mandated professional-competency license for in-home nursing. It <strong>already bundles the criminal-record (<span class="fa">سوء پیشینه</span>) screen</strong> plus scientific/ethical/health vetting. Verified against the MoH source (Rn.behdasht.gov.ir). No public B2B API exists, so the realistic launch method is <strong>nurse-uploaded document + manual admin verification against the official record</strong>.</p>
</li>
<li>
<h5 style="margin-top:0;"><span class="fa">نظام پرستاری</span> (INO) membership &mdash; cross-check</h5>
<p style="margin:0;">The Iranian Nursing Organization membership number is captured and cross-checked (ino.ir) as a second source. Manual at launch.</p>
</li>
<li>
<h5 style="margin-top:0;"><span class="fa">عدم سوء پیشینه</span> (criminal-record certificate)</h5>
<p style="margin:0;">Consent-gated to the individual (obtained by the nurse via adliran.ir / their own <span class="fa">ثنا</span> password); <strong>no company/employer API exists</strong>. The nurse uploads it; it is <strong>time-limited</strong> &mdash; on expiry the step reverts to pending and a support alert is raised. Partly covered already by credential #3.</p>
</li>
<li>
<h5 style="margin-top:0;">IBAN ownership verification</h5>
<p style="margin:0;">The payout IBAN (Sheba) must be proven to belong to the verified nurse &mdash; the account-holder national ID must equal the verified nurse national ID. Done via automated IBAN-ownership inquiry (<span class="fa">استعلام شبا</span>) where available, gating the <strong>first payout</strong>, not merely an admin eyeballing the number. Prevents money-mule payout diversion.</p>
</li>
</ol>
<div class="callout decision">
<div class="callout-title"><span class="badge-ico">&loz;</span>Design decision — a structured credential registry</div>
<p>Beyond opaque uploaded files, the actual license <strong>numbers</strong>, issuing authority, holder-name-as-printed, and issue/expiry dates are stored as typed, queryable rows in <code>nurse_credentials</code>. This powers renewal/expiry alerts, the public "verified" trust badge, cross-checking against official portals, and audit defensibility &mdash; and survives the future arrival of an MoH/INO API. <strong>Continuous monitoring</strong>, not one-and-done: license validity and the criminal-record certificate are periodically re-verified; Shahkar is re-run on phone change; expiring credentials raise <code>support_alerts</code>.</p>
</div>
<div class="callout iran">
<div class="callout-title"><span class="badge-ico">!</span>Iran-specific considerations</div>
<ul>
<li>The license layer is <strong>fragmented across regulators</strong> (MoH vs INO) and has <strong>no public B2B API</strong> &mdash; manual verification against the official portal is the realistic MVP method; the structured registry makes that defensible and renewable.</li>
<li>The criminal-record check is <strong>consent-gated to the person</strong> and cannot be pulled by a company &mdash; hence nurse-uploaded + re-requested periodically, leaning on the MoH license which already embeds it.</li>
<li>Identity (Shahkar, liveness, national-ID match) is the <strong>easy</strong> layer because a competitive market of Iranian e-KYC vendors (Finnotech, U-ID, Jibbit, Farashensa, Verify, Kavoshak) already holds the regulator-gated upstream agreements. <strong>Buy this, don't build it.</strong></li>
<li>Document forgery is the documented attack (the "imposter nurse" pattern): verify at source, bind to national ID + liveness, never trust an uploaded PDF alone. (See the <a href="#verif-pipeline">verification pipeline deep-dive</a>.)</li>
</ul>
</div>
<div class="grid2">
<div class="callout mvp" style="margin:0;">
<div class="callout-title"><span class="badge-ico">&check;</span>MVP</div>
<p>All six steps; data-driven <code>verification_step_types</code>; structured <code>nurse_credentials</code> registry; manual MoH/INO verification; nurse-uploaded <span class="fa">عدم سوء پیشینه</span> with expiry; automated identity + Shahkar + IBAN-ownership via one KYC vendor; expiry-driven re-verification alerts; transactional <code>is_verified</code>.</p>
</div>
<div class="callout deferred" style="margin:0;">
<div class="callout-title"><span class="badge-ico">&hellip;</span>Deferred</div>
<p>Automated MoH/INO license lookup (pending a B2B API); ML-driven fraud scoring (<code>fraud_flags</code> modeled but inactive); a professional-liability-insurance step (addable as a row when required).</p>
</div>
</div>
<div class="datamodel">
<div class="dm-head"><svg class="dm-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 12l2 2 4-4"/><path d="M12 3l7 4v5c0 4.4-3 8.4-7 9.5C8 17.4 5 13.4 5 9V7l7-4z"/></svg> Data model — Verification &amp; Credentials</div>
<div class="dm-body">
<div class="entities">
<span class="entity key">nurse_verifications</span><span class="entity">verification_step_types</span><span class="entity">verification_steps</span><span class="entity">verification_documents</span><span class="entity key">nurse_credentials</span><span class="entity">nurse_bank_accounts</span><span class="entity">support_alerts</span><span class="entity">audit_logs</span>
</div>
<ul class="dm-rel">
<li><span class="ent">nurse_verifications</span> is the master per-nurse header (1:1 with <span class="ent">nurse_profiles</span>); its <code>status</code> is the <strong>single source of truth</strong> for verification state and drives the <code>is_verified</code> flip.</li>
<li><span class="ent">verification_step_types</span> is the admin catalog of pipeline steps with stable machine <code>code</code>s and an <code>is_automated</code> flag; 1:N &rarr; <span class="ent">verification_steps</span> (one row per step per nurse, with raw <code>external_response_json</code> from the KYC vendor and <code>expires_at</code> for time-limited steps).</li>
<li><span class="ent">verification_steps</span> 1:N &rarr; <span class="ent">verification_documents</span> (object-storage key + integrity hash; files behind signed URLs, never public).</li>
<li><span class="ent">nurse_credentials</span> is the structured registry: <code>credential_type</code>, encrypted <code>credential_number</code>, <code>holder_name_snapshot</code>, <code>issued_at</code>/<code>expires_at</code> &mdash; drives renewal alerts &amp; the trust badge, cross-referenced by the relevant step.</li>
<li><span class="ent">nurse_bank_accounts</span> carries the IBAN-ownership result (<code>matched_national_id</code>, <code>account_holder_from_bank</code>, <code>ownership_vendor_ref</code>) that gates the first payout; expiring steps/credentials raise <span class="ent">support_alerts</span>; every state change writes <span class="ent">audit_logs</span>.</li>
</ul>
</div>
</div>
<a class="back-to-top" href="#intro">&uarr; Back to top</a>
</section>
<hr class="rule" />
<!-- ================= SECTION 3 ================= -->
<section id="s3">
<span class="eyebrow">Business · Step 3</span>
<h2 class="section-title"><span class="step-no">3</span>Service Catalog &amp; Pricing</h2>
<p class="lead">An admin-defined skeleton that each nurse fills with their own priced offerings. The model is deliberately configurable (EAV-style) so admins add new pricing dimensions without a migration.</p>
<h3>Three admin layers, two nurse layers</h3>
<ul>
<li><strong>Admin defines the catalog skeleton:</strong> top-level <strong>service categories</strong> (e.g. <span class="fa">مراقبت از سالمند</span> / Elderly Care, <span class="fa">مراقبت پس از جراحی</span> / Post-Surgery Recovery, <span class="fa">مراقبت از نوزاد</span> / Infant Care, <span class="fa">مدیریت بیماری مزمن</span> / Chronic Illness Management) and configurable <strong>option groups</strong> (e.g. <span class="fa">تعداد بیمار</span> / patient count, <span class="fa">نوع شیفت</span> / shift type) each with concrete <strong>option values</strong> (e.g. <span class="fa">۱ نفر</span>, <span class="fa">۲ نفر</span>, <span class="fa">شبانه‌روزی</span>). New dimensions need no schema change.</li>
<li><strong>Each nurse defines their own offerings as variants.</strong> A <strong>variant</strong> is the atomic bookable unit: a category + a chosen combination of option values + the nurse's <strong>own price</strong> and <strong>price unit</strong>. A nurse may have many variants per category, one per combination they choose to price independently.</li>
</ul>
<h4>Price units that match real home nursing</h4>
<div class="lifecycle">
<span class="st">per_hour</span><span class="arw">·</span><span class="st">per_session</span><span class="arw">·</span><span class="st">per_half_day</span><span class="arw">·</span><span class="st">per_day</span><span class="arw">·</span><span class="st">per_24h</span> <span class="muted">(<span class="fa">شبانه‌روزی</span> / live-in)</span>
</div>
<p>For hourly variants an estimated duration helps the customer estimate total cost. The variant <code>display_name</code> auto-generates from option labels but is nurse-editable. Nurses can <strong>deactivate (not delete)</strong> a variant; deactivated variants cannot be booked. Catalog and prices are <strong>snapshotted onto the booking</strong> at booking time (<code>variant_snapshot_json</code>) so historical records survive later edits.</p>
<div class="callout iran">
<div class="callout-title"><span class="badge-ico">!</span>Iran-specific considerations</div>
<ul>
<li>Iranian competitors sell exactly these shapes &mdash; hourly / daily / 24-hour (<span class="fa">شبانه‌روزی</span>) shifts and multi-day packages &mdash; so <code>per_24h</code> and <code>per_day</code> are first-class, not edge cases.</li>
<li>Competitor pricing is opaque and <span class="fa">"توافقی"</span> (negotiable); <strong>transparent, upfront, nurse-set pricing is a deliberate differentiator</strong> families value.</li>
<li>All catalog tables carry <code>name_fa</code> / <code>name_en</code> pairs (Persian primary).</li>
</ul>
</div>
<div class="grid2">
<div class="callout mvp" style="margin:0;">
<div class="callout-title"><span class="badge-ico">&check;</span>MVP</div>
<p>Admin categories + option groups/values; nurse variants with own price + price unit across all five units; activate/deactivate; snapshotting.</p>
</div>
<div class="callout deferred" style="margin:0;">
<div class="callout-title"><span class="badge-ico">&hellip;</span>Deferred</div>
<p>Holiday/surge pricing rules; a lighter "companionship / daily-living" tier (modeled as a future category); dynamic/tiered commission per category.</p>
</div>
</div>
<div class="datamodel">
<div class="dm-head"><svg class="dm-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l1-5h16l1 5M4 9h16v10a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V9zM9 13h6"/></svg> Data model — Service Catalog &amp; Pricing</div>
<div class="dm-body">
<div class="entities">
<span class="entity">service_categories</span><span class="entity">service_option_groups</span><span class="entity">service_option_values</span><span class="entity key">nurse_service_variants</span><span class="entity">nurse_service_variant_options</span>
</div>
<ul class="dm-rel">
<li><span class="ent">service_categories</span> (admin-managed top-level care types) 1:N &rarr; <span class="ent">service_option_groups</span> (the configurable dimensions; a NULL <code>service_category_id</code> = cross-category) 1:N &rarr; <span class="ent">service_option_values</span> (concrete choices).</li>
<li><span class="ent">nurse_service_variants</span> is the <strong>atomic bookable unit</strong> &mdash; N:1 to <span class="ent">nurse_profiles</span> and <span class="ent">service_categories</span>; carries <code>price</code> and <code>price_unit</code>. Search and booking operate on the exact thing the customer pays for, not on "the nurse."</li>
<li><span class="ent">nurse_service_variant_options</span> records one option value per dimension that defines a variant's configuration (<code>UNIQUE(variant_id, option_group_id)</code>); N:1 to <span class="ent">service_option_groups</span> / <span class="ent">service_option_values</span>.</li>
<li>The variant feeds the denormalized <span class="ent">nurse_search_index</span> (§4) and is frozen into <code>bookings.variant_snapshot_json</code> at booking time.</li>
</ul>
</div>
</div>
<a class="back-to-top" href="#intro">&uarr; Back to top</a>
</section>
<hr class="rule" />
<!-- ================= SECTION 4 ================= -->
<section id="s4">
<span class="eyebrow">Business · Step 4</span>
<h2 class="section-title"><span class="step-no">4</span>Search &amp; Matching</h2>
<p class="lead">Families search by category, geography, price, and availability, sortable by rating &mdash; and the search must be cheap from day one. Same-gender matching is a near-hard requirement, surfaced before booking.</p>
<h3>How search works</h3>
<ul>
<li>Families search by <strong>service category</strong>, <strong>geography</strong> (city, optionally district), price, and availability, with results sortable by rating.</li>
<li><strong>Geography</strong> is driven by nurse-declared <strong>service areas</strong>: a nurse covers one or more cities, optionally specific districts; a city-level row (no district) means the whole city.</li>
</ul>
<div class="callout decision">
<div class="callout-title"><span class="badge-ico">&loz;</span>Design decision — a denormalized search index instead of Elasticsearch</div>
<p>The naive query joins nurse profile (verified + accepting) &rarr; variants (category/price) &rarr; variant options &rarr; service areas &rarr; rating across 4+ tables. Instead, a denormalized <code>nurse_search_index</code> holds one flat row per active, bookable variant with all search-relevant fields, maintained on write. A row exists <strong>only</strong> when the nurse is <code>is_verified</code> and not suspended and the variant <code>is_active</code>. This is far cheaper than introducing Elasticsearch at MVP stage.</p>
</div>
<div class="callout iran">
<div class="callout-title"><span class="badge-ico">!</span>Iran-specific considerations &mdash; same-gender matching</div>
<p><strong>Same-gender caregiver matching is the single most Iran-specific matching constraint.</strong> In Iranian bodily-care (bathing, toileting, intimate post-surgical care) same-gender caregiving is culturally decisive, not optional &mdash; every real elder/post-surgical bodily-care request implies it. The customer specifies a required caregiver gender on the request (<code>required_caregiver_gender</code>), and nurse gender is an exposed search filter so families narrow to same-gender caregivers <strong>up front, not after</strong>.</p>
<ul style="margin-top:8px;">
<li>District granularity varies: in Tehran, districts map to the 22 official municipal <span class="fa">مناطق</span>; in smaller cities they are major neighborhoods. Districts are optional.</li>
<li>White-space opportunity: incumbents concentrate ~99% in Tehran/Karaj; the search/area model must work for under-served second-tier cities (Mashhad, Isfahan, Shiraz, Tabriz, Ahvaz, Qom).</li>
</ul>
</div>
<div class="grid2">
<div class="callout mvp" style="margin:0;">
<div class="callout-title"><span class="badge-ico">&check;</span>MVP</div>
<p>Category + city/district geo search; <code>nurse_search_index</code> denormalization; same-gender filter via <code>required_caregiver_gender</code>; rating sort.</p>
</div>
<div class="callout deferred" style="margin:0;">
<div class="callout-title"><span class="badge-ico">&hellip;</span>Deferred</div>
<p>Map-based discovery; availability-window filtering as a hard constraint (slots are soft guidance at launch); algorithmic ranking beyond rating; continuity-of-carer "preferred nurse" suggestions.</p>
</div>
</div>
<div class="datamodel">
<div class="dm-head"><svg class="dm-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg> Data model — Search &amp; Matching</div>
<div class="dm-body">
<div class="entities">
<span class="entity">nurse_service_areas</span><span class="entity">cities</span><span class="entity">districts</span><span class="entity key">nurse_search_index</span><span class="entity">nurse_service_variants</span><span class="entity">nurse_profiles</span><span class="entity">patients</span><span class="entity">booking_requests</span>
</div>
<ul class="dm-rel">
<li><span class="ent">nurse_search_index</span> is a read-only projection: one row per covered area per bookable variant, holding nurse (verified+accepting), variant (category, price, unit), city/district, <code>nurse_gender</code>, rating, and partner center. Maintained on writes to <span class="ent">nurse_profiles</span>, <span class="ent">nurse_service_variants</span>, <span class="ent">nurse_service_areas</span>, and <span class="ent">reviews</span>; <code>is_searchable=1</code> only when its source nurse/variant are bookable.</li>
<li><span class="ent">nurse_service_areas</span> declares where a nurse travels (a NULL <code>district_id</code> = whole city); N:1 to <span class="ent">cities</span> / <span class="ent">districts</span> with <code>UNIQUE(nurse_id, city_id, district_id)</code>.</li>
<li>Same-gender matching pairs <code>users.gender</code> (the nurse, exposed via <span class="ent">nurse_profiles</span>) and <code>patients.gender</code> against <code>booking_requests.required_caregiver_gender</code> &mdash; the requested constraint.</li>
</ul>
</div>
</div>
<a class="back-to-top" href="#intro">&uarr; Back to top</a>
</section>
<hr class="rule" />
<!-- ================= SECTION 5 ================= -->
<section id="s5">
<span class="eyebrow">Business · Step 5</span>
<h2 class="section-title"><span class="step-no">5</span>Booking &amp; Scheduling</h2>
<p class="lead">The lifecycle is split into two tables so each table's invariants stay clean: a <strong>request phase</strong> (no money) and a <strong>booking phase</strong> (always implies captured payment). Single-visit and long multi-session engagements must both be representable.</p>
<h3>Request &rarr; accept &rarr; pay &rarr; confirm</h3>
<ol class="flow">
<li>Customer submits a <strong>booking request</strong> (nurse, patient, variant, address, date/time, requested caregiver gender, customer notes). Status <span class="pill">pending_nurse_response</span>.</li>
<li>The nurse must respond before a <strong>response deadline</strong> (<code>nurse_response_deadline_at</code>, computed from config and frozen on the request). The nurse <strong>accepts</strong> &rarr; <span class="pill">accepted_awaiting_payment</span>, <strong>rejects</strong> &rarr; <span class="pill">rejected_by_nurse</span>, or the deadline passes &rarr; <span class="pill">expired_no_response</span>.</li>
<li>On accept, a <strong>30-minute payment window</strong> opens (<code>payment_deadline_at</code>). The customer pays within it &rarr; a <code>bookings</code> row is created (<span class="pill">confirmed</span>). If the window lapses &rarr; <span class="pill">payment_deadline_expired</span>.</li>
</ol>
<div class="callout decision">
<div class="callout-title"><span class="badge-ico">&loz;</span>Design decision — engagements own N sessions</div>
<p>Home nursing is frequently multi-visit: post-surgery daily visits for ten days, month-long nightly or <span class="fa">شبانه‌روزی</span> (24h live-in) care. A booking therefore carries a <code>session_count</code> and owns <strong>N <code>booking_sessions</code></strong> (one row per scheduled visit), each with its own schedule, its own EVV check-in/out, and its own payout eligibility. <strong>A single EVV per booking cannot represent a multi-day engagement</strong> &mdash; the engagement-to-session split is the core scheduling model. For a single visit, exactly one session is created so the EVV/payout path stays uniform.</p>
</div>
<h4>Booking lifecycle (guarded transitions)</h4>
<div class="lifecycle">
<span class="st">pending_payment</span><span class="arw">&rarr;</span><span class="st">confirmed</span><span class="arw">&rarr;</span><span class="st">in_progress</span><span class="arw">&rarr;</span><span class="st">completed</span><span class="arw">&rarr;</span><span class="st">disputed</span><span class="arw">&rarr;</span><span class="st">closed</span><span class="arw">|</span><span class="st">cancelled</span>
</div>
<p>Allowed transitions are guarded explicitly (an allowed-transition table/CHECK) so the booking and EVV state machines cannot silently contradict. <strong>Snapshots:</strong> <code>variant_snapshot_json</code> and <code>address_snapshot_json</code> freeze the service and address at booking time.</p>
<div class="callout iran">
<div class="callout-title"><span class="badge-ico">!</span>Iran-specific considerations</div>
<ul>
<li>Multi-session and <span class="fa">شبانه‌روزی</span> live-in care is the <strong>dominant</strong> elder-care shape in Iran, not a niche &mdash; modeling only single visits would fail to represent demand.</li>
<li>Heavy platform control over multi-visit scheduling <strong>strengthens a worker-misclassification argument</strong> under labor law; this is flagged for counsel, and the platform deliberately keeps the nurse's accept/reject autonomy per request.</li>
<li>Availability slots/exceptions are <strong>soft guidance only</strong> (informing search), not hard blocks &mdash; the nurse still individually accepts or rejects each request, which also fits the Shamsi week and holiday rhythm.</li>
</ul>
</div>
<div class="grid2">
<div class="callout mvp" style="margin:0;">
<div class="callout-title"><span class="badge-ico">&check;</span>MVP</div>
<p>Request&rarr;accept&rarr;pay&rarr;confirm lifecycle with response deadline + 30-min payment window; single-visit bookings; <code>booking_sessions</code> for multi-session engagements with per-session EVV and payout; explicit status-transition guards; snapshots; soft availability slots/exceptions.</p>
</div>
<div class="callout deferred" style="margin:0;">
<div class="callout-title"><span class="badge-ico">&hellip;</span>Deferred</div>
<p>Open-ended recurring schedules (<code>recurring_booking_schedules</code> modeled, inactive &mdash; launch is all finite engagements); milestone/progress-payment UX beyond per-session accrual; hard availability-based booking blocks.</p>
</div>
</div>
<div class="datamodel">
<div class="dm-head"><svg class="dm-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg> Data model — Booking &amp; Scheduling</div>
<div class="dm-body">
<div class="entities">
<span class="entity key">booking_requests</span><span class="entity key">bookings</span><span class="entity key">booking_sessions</span><span class="entity">booking_care_instructions</span><span class="entity">nurse_availability_slots</span><span class="entity">nurse_availability_exceptions</span><span class="entity">nurse_service_variants</span><span class="entity">patients</span><span class="entity">customer_addresses</span>
</div>
<ul class="dm-rel">
<li><span class="ent">booking_requests</span> is pre-payment intent &mdash; carries <code>required_caregiver_gender</code>, <code>nurse_response_deadline_at</code>, <code>payment_deadline_at</code>, and the request-stage-only <code>customer_notes</code> (the only clinical context the nurse sees before accepting). N:1 to customer/nurse/patient/variant/address; 1:1 &rarr; <span class="ent">bookings</span> on conversion.</li>
<li><span class="ent">bookings</span> exists only when accepted + paid; holds the three-way money split (<code>gross_price_irr</code>, <code>balinyaar_commission_irr</code>, <code>nurse_payout_amount</code> with CHECK <code>gross = commission + payout</code>), <code>session_count</code>, <code>dispute_window_ends_at</code>, both snapshots, and <code>partner_center_id</code>. 1:N &rarr; <span class="ent">booking_sessions</span>.</li>
<li><span class="ent">booking_sessions</span> is one row per <strong>visit</strong> (per-visit schedule, <code>visit_payout_amount</code>, <code>payout_eligible_at</code>, status); 1:1 &rarr; <span class="ent">visit_verifications</span> (EVV per session, §6).</li>
<li><span class="ent">booking_care_instructions</span> (1:1, encrypted) holds clinical/logistical context visible only post-confirmation; <span class="ent">nurse_availability_slots</span> / <span class="ent">nurse_availability_exceptions</span> are soft guidance for search.</li>
<li><strong>Tenancy invariant:</strong> the request's patient &amp; address belong to <code>customer_id</code>; its variant belongs to <code>nurse_id</code>.</li>
</ul>
</div>
</div>
<a class="back-to-top" href="#intro">&uarr; Back to top</a>
</section>
<hr class="rule" />
<!-- ================= SECTION 6 ================= -->
<section id="s6">
<span class="eyebrow">Business · Step 6</span>
<h2 class="section-title"><span class="step-no">6</span>EVV / Service Delivery</h2>
<p class="lead">Electronic Visit Verification is the authoritative record that a visit actually happened, for how long, and where &mdash; and it is the trigger that releases escrow.</p>
<h3>How EVV works</h3>
<ul>
<li>The nurse <strong>clocks in and out via the app per session</strong>, capturing GPS coordinates and timestamps.</li>
<li>An <strong>address-match tolerance</strong> check computes whether the nurse's GPS at check-in falls within an acceptable radius of the booking address (<code>evv_location_tolerance_meters</code>). A mismatch is <strong>advisory</strong> &mdash; it raises a support alert / review flag but does <strong>not</strong> auto-cancel and does not silently block the visit.</li>
<li>If the nurse has not checked in by a configurable threshold after the scheduled start, a <strong>no-show / late support alert</strong> is created and the family is notified.</li>
<li><strong>Payout is gated on EVV completion.</strong> A session/booking becomes payout-eligible only after EVV check-out <strong>and</strong> the dispute window has closed (§10). For a multi-session engagement, payout accrues <strong>per completed session</strong>.</li>
</ul>
<div class="callout iran">
<div class="callout-title"><span class="badge-ico">!</span>Iran-specific considerations</div>
<ul>
<li>EVV is the core operational-trust mitigation for <strong>unobserved in-home care</strong> of vulnerable patients who often cannot reliably report what happened (infants, dementia, post-anesthesia) &mdash; the platform compensates for unobservability with structured proof of service.</li>
<li>Releasing escrow against proof of service is also a financial-correctness requirement under the Iranian "hold then pay weekly" model &mdash; the platform must not pay a nurse for a visit that has no EVV evidence.</li>
</ul>
</div>
<div class="grid2">
<div class="callout mvp" style="margin:0;">
<div class="callout-title"><span class="badge-ico">&check;</span>MVP</div>
<p>Per-session GPS check-in/out, timestamps, address-match tolerance flag, no-show alerting, payout gated on EVV completion + closed dispute window.</p>
</div>
<div class="callout deferred" style="margin:0;">
<div class="callout-title"><span class="badge-ico">&hellip;</span>Deferred</div>
<p>Continuous geofencing during a live-in shift; supervisory tele-check-ins; family-visible live care logs; consented in-home cameras.</p>
</div>
</div>
<div class="datamodel">
<div class="dm-head"><svg class="dm-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 21s-7-5.7-7-11a7 7 0 0 1 14 0c0 5.3-7 11-7 11z"/><circle cx="12" cy="10" r="2.5"/></svg> Data model — EVV / Service Delivery</div>
<div class="dm-body">
<div class="entities">
<span class="entity key">visit_verifications</span><span class="entity">booking_sessions</span><span class="entity">support_alerts</span><span class="entity">platform_configs</span>
</div>
<ul class="dm-rel">
<li><span class="ent">visit_verifications</span> is 1:1 with <span class="ent">booking_sessions</span> (the FK moved to <code>booking_session_id</code> so each visit in a multi-session engagement is verified independently). It stores check-in/out GPS + timestamps, <code>check_in_address_match</code> (advisory), and a status whose mapping to <code>bookings.status</code> is documented so the two state machines cannot diverge.</li>
<li><span class="ent">support_alerts</span> receives no-show and location-mismatch flags for staff triage.</li>
<li><span class="ent">platform_configs</span> supplies <code>evv_location_tolerance_meters</code> (the tolerance radius) and the no-show threshold &mdash; tunable without a deploy.</li>
</ul>
</div>
</div>
<a class="back-to-top" href="#intro">&uarr; Back to top</a>
</section>
<hr class="rule" />
<!-- ================= SECTION 7 ================= -->
<section id="s7">
<span class="eyebrow">Business · Step 7</span>
<h2 class="section-title"><span class="step-no">7</span>Cancellation &amp; Refunds</h2>
<p class="lead">Cancellation/refund rules are tiered and structured, not a single blunt "default 100%". The applicable policy is snapshotted onto the booking, and every refund decomposes across the two fee legs.</p>
<h3>Tiered, snapshotted policy</h3>
<p>The platform defines <code>cancellation_policies</code> tiers by <strong>lead time</strong> and <strong>initiating actor</strong>:</p>
<div class="table-wrap">
<table class="tbl">
<thead><tr><th>Scenario</th><th>Outcome</th></tr></thead>
<tbody>
<tr><td><strong>Free</strong> cancellation &gt; 24h before start</td><td>Full refund, no fee.</td></tr>
<tr><td><strong>Partial</strong> refund under 24h</td><td>e.g. 50% charge.</td></tr>
<tr><td><strong>Customer no-show</strong></td><td>Up to 100% charge.</td></tr>
<tr><td><strong>Nurse no-show</strong></td><td>Full refund to the customer <strong>and</strong> a penalty/forfeiture for the nurse.</td></tr>
</tbody>
</table>
</div>
<ul>
<li>The <strong>applicable policy is snapshotted onto the booking</strong> at booking time (mirroring the per-booking fee-rate snapshot), so later policy edits never rewrite history. The <strong>resolved</strong> cancellation fee / refund percentage is recorded on the refund event.</li>
<li>For multi-session engagements, <strong>cancellation is per remaining session:</strong> cancelling mid-engagement refunds only the un-started sessions, while completed-and-verified sessions remain payout-eligible.</li>
<li><strong>Refunds are admin-only</strong> &mdash; no customer self-service. A refund is initiated by an admin and <strong>must be linked to a support ticket</strong> (<code>tickets</code>) that holds the conversation and dispute evidence.</li>
<li>A refund <strong>decomposes across the two fee legs</strong> &mdash; how much of the platform commission and how much of the nurse payout is being reversed &mdash; because the booking gross is <code>platform commission + nurse payout</code>.</li>
</ul>
<div class="callout iran">
<div class="callout-title"><span class="badge-ico">!</span>Iran-specific considerations</div>
<ul>
<li>A flat percentage is too blunt for <span class="fa">شبانه‌روزی</span> live-in engagements and Iranian holiday-period bookings; tiered, snapshotted policy reduces dispute load.</li>
<li><strong>The refund money path depends on whether the nurse has already been paid</strong> (§8/§10): pre-payout it is a clean reversal; post-payout it becomes a platform-funded refund plus a <strong>nurse clawback</strong>, because an Iranian bank transfer to a nurse's IBAN is effectively irreversible.</li>
<li>For BNPL bookings, the refund <strong>never</strong> goes nurse&rarr;customer or Balinyaar&rarr;customer directly &mdash; it is initiated through the BNPL provider's revert/cancel API (§8/§9).</li>
</ul>
</div>
<div class="grid2">
<div class="callout mvp" style="margin:0;">
<div class="callout-title"><span class="badge-ico">&check;</span>MVP</div>
<p>Tiered <code>cancellation_policies</code>; per-booking policy snapshot; admin-only, ticket-linked refunds; per-session cancellation for engagements; nurse-no-show vs customer-no-show handling; fee-leg decomposition on refunds.</p>
</div>
<div class="callout deferred" style="margin:0;">
<div class="callout-title"><span class="badge-ico">&hellip;</span>Deferred</div>
<p>Automated nurse no-show penalty (manual admin action at launch); self-service partial-refund UI; holiday-specific cancellation overrides.</p>
</div>
</div>
<div class="datamodel">
<div class="dm-head"><svg class="dm-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12a9 9 0 1 0 9-9 9 9 0 0 0-6.4 2.6L3 8"/><path d="M3 4v4h4"/></svg> Data model — Cancellation &amp; Refunds</div>
<div class="dm-body">
<div class="entities">
<span class="entity key">cancellation_policies</span><span class="entity">bookings</span><span class="entity key">refunds</span><span class="entity">tickets</span><span class="entity">nurse_clawbacks</span><span class="entity">ledger_entries</span>
</div>
<ul class="dm-rel">
<li><span class="ent">cancellation_policies</span> is config-driven tiers (<code>applies_to</code> customer/nurse/admin, <code>hours_before_start_min/max</code>, <code>refund_percentage</code>, <code>fee_amount_or_rate</code>); the resolved tier is snapshotted onto the booking and the refund.</li>
<li><span class="ent">refunds</span> are admin-only, N:1 to a <span class="ent">payment_transactions</span> row (1:N &mdash; partials allowed) and always linked to a <span class="ent">tickets</span> row. They carry the fee-leg decomposition (<code>platform_fee_refunded_irr</code>, <code>nurse_payout_refunded_irr</code>), <code>refund_channel</code>, and the policy snapshot.</li>
<li><span class="ent">bookings</span> holds the policy snapshot + <code>dispute_window_ends_at</code>; when the nurse was already paid, the refund spawns a <span class="ent">nurse_clawbacks</span> receivable; every leg posts balanced <span class="ent">ledger_entries</span>.</li>
</ul>
</div>
</div>
<a class="back-to-top" href="#intro">&uarr; Back to top</a>
</section>
<hr class="rule" />
<!-- ================= SECTION 8 ================= -->
<section id="s8">
<span class="eyebrow">Business · Step 8</span>
<h2 class="section-title"><span class="step-no">8</span>Payments &amp; Escrow</h2>
<p class="lead">The family pays the gross booking price <strong>through the platform</strong>; the platform models the money as an <strong>internal double-entry ledger state</strong>, never as cash it holds. This is the most-changed domain &mdash; the ledger is the financial source of truth.</p>
<h3>The money flow</h3>
<ul>
<li>The family pays the <strong>gross</strong> booking price by card via a licensed PSP's IPG. The platform is the <strong>merchant-of-record</strong>; the payment lands net of provider/Shaparak fees.</li>
<li><strong>Escrow is an internal ledger state, not platform-held cash.</strong> A minimal <strong>double-entry <code>ledger_entries</code></strong> ledger models money state: each money event posts <strong>balanced</strong> legs grouped by a <code>transaction_group_id</code> (&Sigma; debit = &Sigma; credit). The ledger is the single source of truth for "how much is held," "how much do we owe nurses now," and "what is our commission income" &mdash; replacing fragile inference from scattered status booleans.</li>
</ul>
<h4>Account types in the ledger</h4>
<div class="table-wrap">
<table class="tbl">
<thead><tr><th>account_type</th><th>Meaning</th></tr></thead>
<tbody>
<tr><td class="col-field">escrow_held</td><td>Funds received and held (over provider custody), not yet released or refunded.</td></tr>
<tr><td class="col-field">platform_revenue</td><td>Balinyaar's own commission income.</td></tr>
<tr><td class="col-field">nurse_payable</td><td>What the platform owes the nurse (accrued, awaiting weekly payout).</td></tr>
<tr><td class="col-field">refund_payable</td><td>Amount owed back to the customer / in-flight reversal.</td></tr>
<tr><td class="col-field">bnpl_fee_expense</td><td>The BNPL provider's merchant commission &mdash; a platform expense.</td></tr>
<tr><td class="col-field">psp_fee_expense</td><td>Gateway/PSP cost on the card leg.</td></tr>
<tr><td class="col-field">nurse_clawback_receivable</td><td>Money a nurse owes back after a refund-after-payout.</td></tr>
<tr><td class="col-field">bad_debt</td><td>Written-off uncollectable clawback.</td></tr>
</tbody>
</table>
</div>
<h4>The canonical postings</h4>
<p>Amounts are always positive; <code>direction</code> (debit/credit) carries the sign. Posted once, idempotently, keyed on the settling transaction.</p>
<div class="posting">
<div class="pt">(a) Card payment capture (inbound)</div>
<pre>DEBIT escrow_held gross_price_irr
CREDIT platform_revenue balinyaar_commission_irr
CREDIT nurse_payable nurse_payout_amount (= gross balinyaar_commission)</pre>
</div>
<div class="posting">
<div class="pt">(b) BNPL settle (inbound) — identical to a card capture, plus the provider-fee leg</div>
<pre>DEBIT escrow_held gross_price_irr
CREDIT platform_revenue balinyaar_commission_irr
CREDIT nurse_payable nurse_payout_amount
DEBIT bnpl_fee_expense bnpl_commission_irr
CREDIT escrow_held bnpl_commission_irr (escrow reflects NET cash actually received)</pre>
</div>
<div class="posting">
<div class="pt">(c) Refund — BEFORE the nurse is paid out (clean reversal)</div>
<pre>DEBIT platform_revenue platform_fee_refunded_irr
DEBIT nurse_payable nurse_payout_refunded_irr
CREDIT refund_payable (sum)</pre>
</div>
<div class="posting">
<div class="pt">(d) Clawback — refund AFTER the nurse was already paid</div>
<pre>DEBIT nurse_clawback_receivable amount_irr (nurse_id set; nurse now owes the platform)
CREDIT refund_payable amount_irr</pre>
</div>
<p class="muted" style="font-size:0.88rem;">Clawback recovered: <code>DEBIT nurse_payable</code> (next batch) / <code>CREDIT nurse_clawback_receivable</code>. There are <strong>no installment-level postings</strong> &mdash; the customer's repayment schedule is the BNPL provider's ledger, not Balinyaar's.</p>
<h4>The three amounts, never conflated</h4>
<div class="table-wrap">
<table class="tbl">
<thead><tr><th>Amount</th><th>Meaning</th><th>Drives</th></tr></thead>
<tbody>
<tr><td class="col-field">gross_price_irr</td><td>What the customer is charged (the booking price)</td><td>The invoice; the inbound <code>escrow_held</code> debit; the refund base</td></tr>
<tr><td class="col-field">balinyaar_commission_irr</td><td>Platform's own cut (was <code>platform_fee_amount</code>)</td><td><code>platform_revenue</code>; <strong>the nurse payout</strong></td></tr>
<tr><td class="col-field">bnpl_commission_irr</td><td>The BNPL provider's merchant discount (on <code>bnpl_transactions</code>)</td><td><code>bnpl_fee_expense</code> &mdash; a platform <strong>expense</strong>, never the nurse's</td></tr>
</tbody>
</table>
</div>
<p><code>nurse_payout_amount = gross_price_irr &minus; balinyaar_commission_irr</code>, enforced by CHECK.</p>
<div class="callout decision">
<div class="callout-title"><span class="badge-ico">&loz;</span>Design decisions baked into payments</div>
<ul>
<li><strong>Settlement-sharing (<span class="fa">تسهیم</span>).</strong> The compliant marketplace primitive splits one incoming card payment across multiple registered IBANs (nurse share + platform commission) at settlement, performed by Shaparak/the provider &mdash; the platform never touches the actual split. The internal ledger mirrors this split; the per-booking fee snapshot freezes it.</li>
<li><strong>Webhook idempotency is mandatory before money moves.</strong> Every PSP/BNPL callback is stored raw and deduplicated by a unique external event id in <code>payment_webhook_events</code> before any money state mutates &mdash; preventing double-confirmed bookings and double-settlements from at-least-once, retried callbacks.</li>
<li><strong>Payment uniqueness:</strong> at most one <span class="pill">succeeded</span> payment transaction per booking, and the Shaparak reference is unique &mdash; so a retried success webhook cannot double-confirm.</li>
<li><strong>Multi-provider failover.</strong> The payment layer abstracts the provider behind configuration so a blocked provider can be swapped, and the reconciliation ledger survives a provider being cut off (the Toman/Jibit Nov-2024 suspensions cut businesses off mid-cycle).</li>
</ul>
</div>
<div class="callout iran">
<div class="callout-title"><span class="badge-ico">!</span>Iran-specific considerations — the load-bearing constraint</div>
<ul>
<li>A <span class="fa">پرداخت‌یار</span> may <strong>not</strong> hold customer deposits, run wallets, or move money between merchants; the Shaparak ban on inter-merchant/inter-facilitator transfers means the "delay the <span class="fa">تسهیم</span> and redistribute later from a platform pool" pattern is regulatory grey-to-prohibited. The compliant posture: collect via the provider, model escrow as an <strong>internal ledger over funds custodied at the licensed provider/partner bank</strong>, and pay out by provider-side settlement to <strong>verified, registered nurse IBANs</strong>. A bank-grade escrow product (e.g. Vandar <span class="fa">میندو</span> / <span class="fa">معاملات امن</span>) is the only true hold/release/refund mechanism, and its EVV-triggered hold is unverified.</li>
<li><strong>PSP received &ne; cash in bank.</strong> Iranian PAYA settlement is cyclic (T+0/T+1, holiday-deferred), so the ledger separates a clearing/receivable state from settled cash, making bank reconciliation possible.</li>
<li>Toman/PSP units differ from internal Rials; convert only at the API boundary. Amounts are <code>BIGINT</code> IRR internally to avoid float/rounding bugs.</li>
</ul>
</div>
<div class="grid2">
<div class="callout mvp" style="margin:0;">
<div class="callout-title"><span class="badge-ico">&check;</span>MVP</div>
<p>Card payment via one licensed PSP; internal double-entry <code>ledger_entries</code> escrow; per-booking three-way amount split; <span class="fa">تسهیم</span>-style commission/nurse-share modeling; <code>payment_webhook_events</code> idempotency; single-succeeded-transaction-per-booking guard; provider abstraction for failover.</p>
</div>
<div class="callout deferred" style="margin:0;">
<div class="callout-title"><span class="badge-ico">&hellip;</span>Deferred</div>
<p>A nurse-facing wallet with on-demand withdrawal (facilitator wallet-prohibition risk); multiple simultaneous live PSPs at launch (abstraction is built, second provider added later); bank-grade EVV-triggered escrow product integration.</p>
</div>
</div>
<div class="datamodel">
<div class="dm-head"><svg class="dm-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="5" width="20" height="14" rx="2"/><path d="M2 10h20"/></svg> Data model — Payments &amp; Escrow</div>
<div class="dm-body">
<div class="entities">
<span class="entity">payment_gateways</span><span class="entity">payment_transactions</span><span class="entity key">payment_webhook_events</span><span class="entity key">ledger_entries</span><span class="entity">bookings</span><span class="entity">refunds</span><span class="entity">nurse_bank_accounts</span>
</div>
<ul class="dm-rel">
<li><span class="ent">ledger_entries</span> is the append-only double-entry <strong>source of truth</strong>: balanced legs per <code>transaction_group_id</code>, <code>account_type</code>, <code>direction</code>, <code>amount_irr</code>, optional <code>nurse_id</code>/<code>booking_id</code>, and <code>source_ref_type</code>/<code>source_ref_id</code>. Per-nurse payable balance derives by filter, never a drifting cached column.</li>
<li><span class="ent">payment_transactions</span> records every attempt against a booking; the <span class="pill">succeeded</span> row triggers confirmation. Hardened with filtered <code>UNIQUE(gateway_reference_code)</code> and filtered <code>UNIQUE(booking_id) WHERE status='succeeded'</code>. N:1 to <span class="ent">payment_gateways</span>; 1:N &rarr; <span class="ent">refunds</span> / <span class="ent">ledger_entries</span>; 1:1 &rarr; <span class="ent">bnpl_transactions</span> if BNPL.</li>
<li><span class="ent">payment_webhook_events</span> stores every callback raw, deduplicated on <code>UNIQUE(provider_code, external_event_id)</code>, upserted <strong>first</strong> inside the money-mutating transaction.</li>
<li><span class="ent">payment_gateways</span> abstracts each PSP/BNPL provider (type <code>standard</code>/<code>bnpl</code>, encrypted <code>config_json</code>) for failover; <span class="ent">bookings</span> carries the three amounts + <code>platform_fee_rate</code> + <code>psp_fee_amount</code>; payouts target verified <span class="ent">nurse_bank_accounts</span> IBANs.</li>
</ul>
</div>
</div>
<a class="back-to-top" href="#intro">&uarr; Back to top</a>
</section>
<hr class="rule" />
<!-- ================= SECTION 9 ================= -->
<section id="s9">
<span class="eyebrow">Business · Step 9</span>
<h2 class="section-title"><span class="step-no">9</span>Installments / BNPL</h2>
<p class="lead">BNPL is an alternative checkout. The decisive, verified finding is <strong>full-upfront settlement</strong>: the provider pays Balinyaar the full booking amount in one lump (net of its commission) and bears 100% of customer-default risk. So a BNPL order is, in Balinyaar's books, identical to a card payment landing net-of-fee.</p>
<h3>How BNPL is modeled</h3>
<ul>
<li>On approval the BNPL provider pays Balinyaar the <strong>full booking amount in one lump, net of the provider's merchant commission</strong>, and <strong>bears 100% of customer-default risk</strong>. The customer's interest-free installment repayment (typically a 4-installment plan) is <strong>owned entirely by the provider</strong> and is <strong>decoupled</strong> from Balinyaar's escrow/EVV/payout cycle.</li>
<li><strong>Therefore Balinyaar does NOT track customer installments, per-installment webhooks, or default propagation</strong> &mdash; that fragile subsystem is intentionally not built. A BNPL order is recorded once as a single inbound settlement in <code>bnpl_transactions</code> (1:1 with a payment transaction).</li>
<li><strong>The nurse's payout is unchanged by BNPL:</strong> computed from <code>gross_price_irr &minus; balinyaar_commission_irr</code>, paid weekly after EVV + dispute window. The provider's commission is a <strong>platform cost of accepting BNPL</strong> and is <strong>never</strong> passed through to the nurse.</li>
</ul>
<h4>What <code>bnpl_transactions</code> captures</h4>
<p>Provider, merchant-of-record (Balinyaar/partner center), external payment token / transaction id, <code>order_amount_irr</code>, <code>settled_amount_irr</code> (net of provider commission), <code>bnpl_commission_irr</code>, currency (converted at the boundary), an idempotent status state-machine (<span class="pill">eligible</span>/<span class="pill">token_issued</span>/<span class="pill">verified</span>/<span class="pill">settled</span>/<span class="pill">reverted</span>/<span class="pill">cancelled</span>/<span class="pill">failed</span>), <code>installment_count</code> (informational, default 4), <code>settled_at</code>, and the revert fields.</p>
<div class="callout note">
<div class="callout-title"><span class="badge-ico">i</span>BNPL refunds flow only customer &harr; provider &harr; Balinyaar</div>
<p>Never nurse&rarr;customer or Balinyaar&rarr;customer directly. Balinyaar initiates the reversal via the provider's <strong>revert</strong> (full) / <strong>cancel/update</strong> (partial, new amount strictly lower) API using the stored token; the provider cancels the customer's unpaid installments, restores their credit, and refunds any already-paid installment to the customer's bank in ~7&ndash;10 business days (asynchronous, owned by the provider). The refund still decomposes across the platform-fee and nurse-payout legs in Balinyaar's ledger. <strong>See the <a href="#bnpl-deep">BNPL deep-dive</a> for the exact Q1 cancellation flow and the Q2 three-amount split.</strong></p>
</div>
<div class="callout iran">
<div class="callout-title"><span class="badge-ico">!</span>Iran-specific considerations</div>
<ul>
<li>Provider-financed Iranian BNPLs (SnappPay, Digipay, Tara, Torob Pay) are uniformly <strong>full-upfront, provider-bears-risk, interest-free-to-customer</strong>; only bank-financed POS loans (Lendo) charge the customer interest and are a poor fit for short, cancellable nursing visits.</li>
<li><strong>Settlement timing is contract-defined and may be gated on the customer's first installment</strong> (daily / T+1-3 / weekly / 15-day) &mdash; "full amount" does not mean "instant cash." Timing is config + a per-transaction <code>settled_at</code>; weekly nurse payout may key off settlement actually received, never an assumption.</li>
<li><strong>Commission rate is per-contract and not public</strong> (anecdotal 7&ndash;15% for SnappPay; Torob Pay's published 6.6%) &mdash; always a config field read from the actual settlement, never hardcoded.</li>
<li>Onboarding requires <span class="fa">جواز کسب</span> <strong>and</strong> <span class="fa">اینماد</span> (eNamad) for the Balinyaar/partner entity; whether a multi-vendor re-disbursing marketplace qualifies as a single BNPL merchant is publicly undocumented &mdash; an ops/contracting task, not a schema dependency.</li>
</ul>
</div>
<div class="grid2">
<div class="callout mvp" style="margin:0;">
<div class="callout-title"><span class="badge-ico">&check;</span>MVP</div>
<p>Full-upfront BNPL via one provider modeled as a single inbound settlement (<code>bnpl_transactions</code>); provider-mediated revert/cancel refunds; nurse payout decoupled from BNPL; commission + settlement timing as config.</p>
</div>
<div class="callout deferred" style="margin:0;">
<div class="callout-title"><span class="badge-ico">&hellip;</span>Deferred</div>
<p>Customer installment tracking (<code>installment_entries</code> &mdash; <strong>cut</strong>, owned by the provider); tranched settlement (<code>bnpl_settlement_entries</code> modeled-only, added if a future provider tranches); multiple BNPL providers.</p>
</div>
</div>
<div class="datamodel">
<div class="dm-head"><svg class="dm-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16v16H4z"/><path d="M4 9h16M9 4v16"/></svg> Data model — Installments / BNPL</div>
<div class="dm-body">
<div class="entities">
<span class="entity key">bnpl_transactions</span><span class="entity">payment_transactions</span><span class="entity">payment_webhook_events</span><span class="entity">refunds</span><span class="entity">ledger_entries</span>
</div>
<ul class="dm-rel">
<li><span class="ent">bnpl_transactions</span> (replaces the old <code>installment_plans</code>; <code>installment_entries</code> is cut) is 1:1 with a <span class="ent">payment_transactions</span> row &mdash; the single inbound settlement to reconcile, plus the revert path. State-machine guard on <code>status</code> for idempotency.</li>
<li><span class="ent">refunds</span> on a BNPL order carry <code>refund_channel = 'bnpl_revert'</code>, <code>external_revert_reference</code>, and <code>expected_customer_refund_eta</code> (the ~7&ndash;10 business-day window surfaced in UI/reconciliation).</li>
<li>The settlement posts the same balanced <span class="ent">ledger_entries</span> as a card capture, plus the <code>bnpl_fee_expense</code> leg; callbacks are deduplicated via <span class="ent">payment_webhook_events</span>.</li>
</ul>
</div>
</div>
<a class="back-to-top" href="#intro">&uarr; Back to top</a>
</section>
<hr class="rule" />
<!-- ================= SECTION 10 ================= -->
<section id="s10">
<span class="eyebrow">Business · Step 10</span>
<h2 class="section-title"><span class="step-no">10</span>Payouts to Nurses</h2>
<p class="lead">Nurses are paid in <strong>weekly batches</strong>, gated on EVV completion <em>and</em> a closed dispute window &mdash; because an Iranian bank transfer, once sent, is effectively irreversible. Clawbacks handle the refund-after-payout case.</p>
<h3>How payouts work</h3>
<ul>
<li>A batch aggregates the amounts owed for completed, payout-eligible bookings/sessions and produces <strong>one payout per nurse</strong> with earnings in that window.</li>
<li><strong>Payout eligibility is gated on EVV completion AND a closed dispute window.</strong> A booking/session enters a batch only when <code>status = 'completed'</code> AND <code>dispute_window_ends_at &lt; now()</code> (config-driven, default 72h post-completion). This prevents paying a nurse before a dispute can surface, shrinking clawback frequency.</li>
<li>The nurse payout amount derives from <code>gross_price_irr &minus; balinyaar_commission_irr</code> &mdash; never from a BNPL provider's net settlement.</li>
<li><strong>Each booking is paid at most once</strong> (the payout&harr;booking link is unique), preventing double-pay across batches.</li>
<li>Payouts go to the nurse's <strong>verified, registered primary IBAN</strong>, with the IBAN snapshotted and a transfer reference stored for reconciliation. Each payout item carries a unique track id + (for batches) a batch id.</li>
</ul>
<div class="callout decision">
<div class="callout-title"><span class="badge-ico">&loz;</span>Design decision — clawbacks &amp; holiday-aware scheduling</div>
<ul>
<li><strong>Clawbacks</strong> (<code>nurse_clawbacks</code>) handle the refund-after-payout case: a clawback receivable is recorded (negative ledger entry against the nurse) and recovered by <strong>netting against the nurse's next weekly batch</strong>, or written off if uncollectable. The nurse's payable balance is <strong>derived from the ledger</strong> (it may go negative); a batch nets prior clawbacks (<code>gross_earnings</code>, <code>clawback_applied</code>, <code>net_amount</code>).</li>
<li><strong>Bank-holiday-aware scheduling.</strong> Payout period-end and processing dates are shifted off bank-closed days using a shared <code>iranian_holidays</code> calendar &mdash; a weekly payout landing on a multi-day Nowruz closure would otherwise fail, since PAYA/SATNA transfers do not settle on closed days.</li>
</ul>
</div>
<div class="callout iran">
<div class="callout-title"><span class="badge-ico">!</span>Iran-specific considerations</div>
<ul>
<li>Payouts are <strong>real bank transfers to registered IBANs</strong> (PAYA/SATNA cycles, next-business-day on holidays) &mdash; there is no chargeback-style reversal, which is <em>why</em> the dispute window must close before payout and why clawback is a netting/receivable mechanism rather than an automatic reversal.</li>
<li>Provider settlement cut-offs (Toman/Jibit) mean payout must tolerate a provider being unavailable mid-cycle; the batch + reconciliation references survive a swap.</li>
<li>Each nurse must have a Shahkar/KYC-verified, IBAN-ownership-checked account registered as a beneficiary before any payout targets it.</li>
</ul>
</div>
<div class="grid2">
<div class="callout mvp" style="margin:0;">
<div class="callout-title"><span class="badge-ico">&check;</span>MVP</div>
<p>Weekly batches; EVV + dispute-window gating; per-session accrual for engagements; <code>nurse_clawbacks</code> with next-batch netting and write-off; unique booking&harr;payout link; <code>iranian_holidays</code>-aware scheduling; verified-IBAN payouts with reconciliation references.</p>
</div>
<div class="callout deferred" style="margin:0;">
<div class="callout-title"><span class="badge-ico">&hellip;</span>Deferred</div>
<p>On-demand / instant nurse withdrawal; per-nurse configurable payout frequency; automated clawback recovery beyond netting.</p>
</div>
</div>
<div class="datamodel">
<div class="dm-head"><svg class="dm-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 21h18M5 21V10M9 21V10M15 21V10M19 21V10M3 10l9-6 9 6z"/></svg> Data model — Payouts to Nurses</div>
<div class="dm-body">
<div class="entities">
<span class="entity">nurse_payout_batches</span><span class="entity">nurse_payouts</span><span class="entity">nurse_payout_booking_links</span><span class="entity key">nurse_clawbacks</span><span class="entity">ledger_entries</span><span class="entity key">iranian_holidays</span><span class="entity">bookings</span><span class="entity">nurse_bank_accounts</span>
</div>
<ul class="dm-rel">
<li><span class="ent">nurse_payout_batches</span> is the weekly aggregation (holiday-aware <code>period_end</code>, CHECK <code>total_amount = &Sigma; payouts</code>) 1:N &rarr; <span class="ent">nurse_payouts</span> (one row per nurse per batch, with <code>gross_earnings_irr</code>, <code>clawback_applied_irr</code>, <code>net_amount_irr</code>, <code>iban_snapshot</code>, <code>transfer_reference</code>).</li>
<li><span class="ent">nurse_payouts</span> 1:N &rarr; <span class="ent">nurse_payout_booking_links</span> with <code>booking_id</code> <strong>UNIQUE</strong> &mdash; the structural anti-double-pay guard (exactly one payout per booking).</li>
<li><span class="ent">nurse_clawbacks</span> (1:1 to a <span class="ent">refunds</span> row; N:1 to nurse/booking; links to the original &amp; recovering payout) carries <code>status</code> <span class="pill">pending</span>/<span class="pill">recovered</span>/<span class="pill">written_off</span>.</li>
<li><span class="ent">iranian_holidays</span> (with <code>is_bank_closed</code>) drives date-shifting; every transfer posts balanced <span class="ent">ledger_entries</span>; eligibility reads <code>bookings.dispute_window_ends_at</code>; the target is a verified <span class="ent">nurse_bank_accounts</span> IBAN.</li>
</ul>
</div>
</div>
<a class="back-to-top" href="#intro">&uarr; Back to top</a>
</section>
<hr class="rule" />
<!-- ================= SECTION 11 ================= -->
<section id="s11">
<span class="eyebrow">Business · Step 11</span>
<h2 class="section-title"><span class="step-no">11</span>Reviews, Trust &amp; Safety</h2>
<p class="lead">One review per completed booking, moderated before it goes public, with aggregates recomputed on <em>every</em> transition &mdash; because the buyers are vulnerable people cared for unobserved, and a single incident can destroy a fragile, trust-first brand.</p>
<h3>How reviews &amp; safety work</h3>
<ul>
<li>A customer can leave <strong>one review per completed booking</strong> (rating 1&ndash;5 + free text), tied to a verified, completed, on-platform booking.</li>
<li><strong>Moderation:</strong> reviews enter <span class="pill">pending_moderation</span> and are not public until approved by an admin (or an AI moderator). Aggregate nurse rating/counts are recomputed on <strong>every</strong> review status transition &mdash; publish, <strong>hide</strong>, reject, unpublish &mdash; so hiding a 1-star review never leaves a stale, inflated average.</li>
<li><strong>Low-rating alerting:</strong> a rating at or below a configurable threshold (default &le; 2) with negative content automatically raises a <code>support_alerts</code> row for the support team to investigate.</li>
<li><strong>Incident handling:</strong> rapid-response protocols with immediate suspension on credible complaints; structured family check-ins and easy in-app concern flagging (the patient is not the sole information source); high-acuity cases routed only to appropriately verified nurses.</li>
</ul>
<div class="callout iran">
<div class="callout-title"><span class="badge-ico">!</span>Iran-specific considerations</div>
<ul>
<li>The buyers are <strong>vulnerable people</strong> cared for <strong>unobserved at home</strong>; a single incident can destroy a fragile, trust-first brand &mdash; so moderation, low-rating alerting, and immediate suspension are core, not optional.</li>
<li>Verified-trust is the brand; reviews must be bound to real completed bookings to resist fake-review fraud (gig-marketplace fraud is ~2&times; elsewhere, mostly impersonation).</li>
</ul>
</div>
<div class="grid2">
<div class="callout mvp" style="margin:0;">
<div class="callout-title"><span class="badge-ico">&check;</span>MVP</div>
<p>One-per-completed-booking customer reviews; moderation with full recompute-on-every-transition; low-rating <code>support_alerts</code>; manual incident suspension.</p>
</div>
<div class="callout deferred" style="margin:0;">
<div class="callout-title"><span class="badge-ico">&hellip;</span>Deferred</div>
<p>Two-way (nurse-reviews-customer) double-blind reviews with timed reveal; structured review-tag aggregation (<code>review_tags_master</code> / <code>review_tag_links</code> modeled but phase-2); a dedicated <code>incidents</code> entity; ML fraud scoring.</p>
</div>
</div>
<div class="datamodel">
<div class="dm-head"><svg class="dm-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2l3 6.3 6.9 1-5 4.9 1.2 6.8L12 17.8 5.9 21l1.2-6.8-5-4.9 6.9-1z"/></svg> Data model — Reviews, Trust &amp; Safety</div>
<div class="dm-body">
<div class="entities">
<span class="entity key">reviews</span><span class="entity">review_tags_master</span><span class="entity">review_tag_links</span><span class="entity">support_alerts</span><span class="entity">nurse_profiles</span><span class="entity">audit_logs</span>
</div>
<ul class="dm-rel">
<li><span class="ent">reviews</span> is 1:1 with a <span class="ent">bookings</span> row (creation allowed only for completed/closed bookings); rating CHECK 1&ndash;5; <strong>every</strong> status transition recomputes the denormalized aggregates on <span class="ent">nurse_profiles</span> (<code>average_rating</code>, <code>total_reviews</code>), fixing the inflated-rating-after-hide drift.</li>
<li><span class="ent">reviews</span> N:N <span class="ent">review_tags_master</span> via <span class="ent">review_tag_links</span> for quantitative tag aggregation (phase-2 nicety).</li>
<li>A low rating raises a <span class="ent">support_alerts</span> row; sensitive transitions write <span class="ent">audit_logs</span>.</li>
</ul>
</div>
</div>
<a class="back-to-top" href="#intro">&uarr; Back to top</a>
</section>
<hr class="rule" />
<!-- ================= SECTION 12 ================= -->
<section id="s12">
<span class="eyebrow">Business · Step 12</span>
<h2 class="section-title"><span class="step-no">12</span>Messaging &amp; On-Site Emergencies</h2>
<p class="lead">There is <strong>no live chat and no direct nurse&harr;customer messaging</strong>. All post-booking communication runs through a structured ticket system that admin can read in full &mdash; a deliberate anti-disintermediation and patient-safety design.</p>
<h3>How communication works</h3>
<ul>
<li>All post-booking communication runs through <strong>tickets</strong> admin can read in full &mdash; it protects vulnerable patients, creates a dispute paper trail, and prevents families and nurses pairing off-platform.</li>
<li>A <strong>booking-scoped coordination ticket</strong> is auto-created so the nurse and customer can coordinate logistics (arrival time, room location) under admin visibility. Internal admin-only notes are supported and never shown to users.</li>
<li>Tickets also carry refund conversations and any support request, and are the mandatory anchor for admin refunds (§7).</li>
</ul>
<div class="callout decision">
<div class="callout-title"><span class="badge-ico">&loz;</span>On-site emergency playbook</div>
<p>The ticket system is async and has no real-time channel, so the operational playbook is explicit: <strong>in an emergency (no answer at the door, a medical emergency), the nurse calls the emergency-contact number surfaced in the app, then opens a ticket.</strong> The emergency contact number is surfaced prominently in the booking UI (drawn from encrypted care instructions), so a nurse never needs to find the family's number by other means (which would break the platform's communication control). No schema change &mdash; an operational rule.</p>
</div>
<div class="callout iran">
<div class="callout-title"><span class="badge-ico">!</span>Iran-specific considerations</div>
<ul>
<li>Disintermediation is the predictable failure mode of recurring, relationship-based care; the ticket-only model retains value (escrow, dispute protection, backup coverage, insurance that only applies on-platform) instead of relying on punitive lock-in.</li>
<li>For unobserved in-home care of patients who cannot self-report, the controlled-but-auditable communication channel plus a clear emergency escalation path is a safety requirement.</li>
</ul>
</div>
<div class="grid2">
<div class="callout mvp" style="margin:0;">
<div class="callout-title"><span class="badge-ico">&check;</span>MVP</div>
<p>Ticket-only messaging (admin-readable); auto-created booking-coordination ticket; internal notes; prominent in-app emergency contact + documented playbook.</p>
</div>
<div class="callout deferred" style="margin:0;">
<div class="callout-title"><span class="badge-ico">&hellip;</span>Deferred</div>
<p>Real-time chat; a first-class <code>incidents</code>/emergency-event entity with SLA; push/real-time alerting.</p>
</div>
</div>
<div class="datamodel">
<div class="dm-head"><svg class="dm-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg> Data model — Messaging &amp; Emergencies</div>
<div class="dm-body">
<div class="entities">
<span class="entity key">tickets</span><span class="entity">ticket_participants</span><span class="entity">ticket_messages</span><span class="entity">booking_care_instructions</span><span class="entity">support_alerts</span>
</div>
<ul class="dm-rel">
<li><span class="ent">tickets</span> (human-facing <code>reference_code</code>, optionally linked to a <span class="ent">bookings</span> or <span class="ent">refunds</span> row) 1:N &rarr; <span class="ent">ticket_participants</span> (<code>UNIQUE(ticket_id, user_id)</code>) and <span class="ent">ticket_messages</span> (<code>is_internal</code> keeps admin-only notes out of user view).</li>
<li>The emergency contact number is read from the encrypted <span class="ent">booking_care_instructions</span> and surfaced in the booking UI.</li>
<li>Escalations and anomalies become <span class="ent">support_alerts</span> for staff.</li>
</ul>
</div>
</div>
<a class="back-to-top" href="#intro">&uarr; Back to top</a>
</section>
<hr class="rule" />
<!-- ================= SECTION 13 ================= -->
<section id="s13">
<span class="eyebrow">Business · Step 13</span>
<h2 class="section-title"><span class="step-no">13</span>Tax, Invoicing &amp; Legal</h2>
<p class="lead">The nurse is the taxable seller of the nursing service; Balinyaar is the taxable seller only of its commission. A partner licensed center is the launch legal vehicle that makes the whole money flow legal.</p>
<h3>The tax/invoice split</h3>
<ul>
<li><strong>The nurse is the taxable seller of the nursing service; Balinyaar is the taxable seller only of its commission.</strong> This mirrors the Snapp/Tapsi sharing-economy precedent: the nurse's fee is the nurse's income (the nurse files their own income tax &mdash; out of Balinyaar's scope), and Balinyaar's commission is the company's VAT-relevant revenue.</li>
<li><strong>VAT is 10%</strong> (configurable), applied to Balinyaar's commission line. The home-nursing <strong>service's</strong> own VAT treatment is <strong>unconfirmed</strong> (medical services are commonly exempt) &mdash; so the VAT field is config-driven and can be 0/exempt, keeping the model correct whichever way the ruling lands. Confirm with an Iranian tax advisor before launch.</li>
<li><strong><span class="fa">سامانه مودیان</span> (taxpayer system) readiness, minimal footprint.</strong> The platform produces a minimal <code>invoices</code> record per booking capturing the gross, the platform commission, any BNPL commission, VAT, and a place for the <span class="fa">مودیان</span> reference fields (22-digit fiscal number, memory tax id, status) and PDF. <strong>The seller issues the invoice</strong> (the buyer cannot), so Balinyaar issues only its own <strong>commission</strong> invoice; it does not issue the nurse's service invoice.</li>
<li><strong>e-namad (<span class="fa">نماد اعتماد الکترونیکی</span>)</strong> is de-facto mandatory: a monetized Iranian site needs e-namad to obtain an online payment gateway from PSP/Shaparak. Held by the legal launch entity.</li>
</ul>
<div class="callout decision">
<div class="callout-title"><span class="badge-ico">&loz;</span>Design decision — partner licensed-center as the launch legal vehicle</div>
<p>Home nursing is a <strong>licensed healthcare activity</strong> (MoH establishment permit <span class="fa">پروانه تأسیس</span> + technical-director license <span class="fa">پروانه مسئول فنی</span> via the Article-20 commission), in the <strong>home-nursing-services-center</strong> track (a nurse with BSc + &ge;5 yrs experience can found/direct it). The fast, legal go-to-market is to <strong>partner with already-licensed centers</strong> (the Asanism model) while Balinyaar's own permit is pending. A <code>partner_centers</code> entity represents the licensed center that holds the <span class="fa">جواز کسب</span> + <span class="fa">اینماد</span> + MoH license, sponsors nurses, and <strong>may be the merchant-of-record / invoice issuer</strong> for payments &mdash; making BNPL and online payment legally feasible without each nurse holding a license. (See the <a href="#legal">legal landscape deep-dive</a>.)</p>
</div>
<div class="callout iran">
<div class="callout-title"><span class="badge-ico">!</span>Iran-specific considerations</div>
<ul>
<li>Operating <strong>without</strong> a permit is the real legal risk (penalty ladder up to permanent revocation + judicial referral). The partner-center vehicle is the launch-critical mechanism that makes the whole money flow legal.</li>
<li><span class="fa">مودیان</span> obligation phases in by revenue thresholds; most individual nurses fall below mandatory thresholds early, but the <strong>platform's commission line is VAT/e-invoice-relevant</strong> &mdash; so per-nurse <span class="fa">مودیان</span> obligation is a configurable flag and the platform's own commission invoicing is the in-scope obligation.</li>
<li>The licensed center (not Balinyaar-the-tech-company, initially) is plausibly the IPG merchant-of-record and the invoice issuer &mdash; the data model represents this explicitly.</li>
</ul>
</div>
<div class="grid2">
<div class="callout mvp" style="margin:0;">
<div class="callout-title"><span class="badge-ico">&check;</span>MVP</div>
<p><code>partner_centers</code> as the launch legal vehicle with merchant-of-record flag and nurse sponsorship; minimal per-booking <code>invoices</code> with 10% configurable VAT on commission and <span class="fa">مودیان</span> reference fields; e-namad held by the launch entity; nurse-as-taxable-seller / platform-as-commission-seller split.</p>
</div>
<div class="callout deferred" style="margin:0;">
<div class="callout-title"><span class="badge-ico">&hellip;</span>Deferred</div>
<p>Full <span class="fa">مودیان</span> e-invoice automation / digital-signature pipeline; nurse-side service-invoice issuance on the nurse's behalf; insurer/B2B-payor invoicing; the future employer-style <code>organizations</code> model.</p>
</div>
</div>
<div class="datamodel">
<div class="dm-head"><svg class="dm-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2h9l5 5v15H6z"/><path d="M14 2v6h6M9 13h6M9 17h6"/></svg> Data model — Tax, Invoicing &amp; Legal</div>
<div class="dm-body">
<div class="entities">
<span class="entity key">invoices</span><span class="entity key">partner_centers</span><span class="entity">nurse_profiles</span><span class="entity">payment_transactions</span><span class="entity">platform_configs</span>
</div>
<ul class="dm-rel">
<li><span class="ent">invoices</span> is 1:1 with a <span class="ent">bookings</span> row: <code>invoice_number</code>, <code>issuing_entity_type</code> (<code>platform</code>/<code>partner_center</code>), <code>gross_irr</code>, <code>platform_commission_irr</code> (the VAT-relevant line), <code>bnpl_commission_irr</code>, config-driven <code>vat_rate</code>/<code>vat_irr</code>, and <span class="fa">مودیان</span> fields (<code>moadian_reference_number</code>, <code>moadian_status</code>) + <code>pdf_storage_key</code>.</li>
<li><span class="ent">partner_centers</span> holds the MoH license (<code>moh_establishment_permit_no</code>), <code>enamad_code</code>, <code>settlement_iban</code>, and <code>is_merchant_of_record</code>; 1:N &rarr; <span class="ent">nurse_profiles</span> (sponsors, via <code>nurse_profiles.partner_center_id</code>), <span class="ent">bookings</span> (legally covered by), and <span class="ent">invoices</span> (issuer).</li>
<li><span class="ent">payment_transactions</span> supplies the Shaparak reference for reconciliation; <span class="ent">platform_configs</span> holds the VAT rate and merchant-of-record settings.</li>
</ul>
</div>
</div>
<a class="back-to-top" href="#intro">&uarr; Back to top</a>
</section>
<hr class="rule" />
<!-- ================= SECTION 14 ================= -->
<section id="s14">
<span class="eyebrow">Business · Step 14</span>
<h2 class="section-title"><span class="step-no">14</span>Notifications &amp; Admin / Backoffice</h2>
<p class="lead">In-app notifications keep users informed; a back-office tooling spine lets admins verify nurses, refund, pay out, triage alerts, and audit everything &mdash; all scoped by RBAC.</p>
<h3>Notifications</h3>
<ul>
<li><strong>In-app notifications</strong> to all user types for booking, payment, payout, review, verification, and alert events. Carried as typed in-app records the front-end fetches on load and uses to deep-link to the relevant entity. <strong>No push notifications at launch.</strong></li>
<li>A retention job hard-deletes read notifications older than <strong>90 days</strong> to keep the table bounded.</li>
</ul>
<h3>Admin / backoffice tooling — the operational spine</h3>
<div class="grid2">
<div class="card"><h6 style="margin-bottom:4px;">Verification queue</h6><p style="font-size:0.9rem;margin:0;">Review uploaded MoH/INO/criminal-record documents, record structured credential numbers/expiries, pass/fail steps, and flip <code>is_verified</code> transactionally.</p></div>
<div class="card"><h6 style="margin-bottom:4px;">Refund tooling</h6><p style="font-size:0.9rem;margin:0;">Initiate admin-only, ticket-linked refunds with tiered policy application and fee-leg decomposition; for BNPL, trigger the provider revert/cancel.</p></div>
<div class="card"><h6 style="margin-bottom:4px;">Payout tooling</h6><p style="font-size:0.9rem;margin:0;">Initiate/inspect weekly batches, see eligibility (EVV + closed dispute window), apply clawback netting, schedule around bank holidays, and reconcile transfer references.</p></div>
<div class="card"><h6 style="margin-bottom:4px;">Support-alert console</h6><p style="font-size:0.9rem;margin:0;">Triage low-rating, no-show, location-mismatch, expiry, and fraud-signal alerts.</p></div>
<div class="card"><h6 style="margin-bottom:4px;">RBAC</h6><p style="font-size:0.9rem;margin:0;">Admin roles (super_admin / admin / support / finance / moderator) scope who can verify, refund, pay out, and moderate.</p></div>
<div class="card"><h6 style="margin-bottom:4px;">Append-only audit trail</h6><p style="font-size:0.9rem;margin:0;">Every state-changing operation on sensitive entities (bookings, payments, refunds, verifications, reviews, users) and config changes (e.g. the platform fee rate) are auditable.</p></div>
</div>
<div class="callout iran">
<div class="callout-title"><span class="badge-ico">!</span>Iran-specific considerations</div>
<ul>
<li>No push at launch reflects a pragmatic MVP and the in-app polling norm; SMS-OTP already covers the critical auth path.</li>
<li>Back-office must reason over the Shamsi calendar and <code>iranian_holidays</code> for payout scheduling and deadline computation, and over the verification realities (manual MoH/INO checks, expiry-driven re-verification).</li>
<li>High-volume logs (<code>audit_logs</code>, <code>system_events</code>, <code>notifications</code>) need partitioning/retention planned before launch to avoid unbounded growth.</li>
</ul>
</div>
<div class="grid2">
<div class="callout mvp" style="margin:0;">
<div class="callout-title"><span class="badge-ico">&check;</span>MVP</div>
<p>In-app notifications with 90-day retention; admin verification/refund/payout/alert tooling; RBAC; append-only <code>audit_logs</code>; config-change auditing.</p>
</div>
<div class="callout deferred" style="margin:0;">
<div class="callout-title"><span class="badge-ico">&hellip;</span>Deferred</div>
<p>Push notifications; SMS/email notification channels beyond OTP; a full analytics warehouse (<code>system_events</code> piped out rather than queried in the transactional DB); ML fraud console.</p>
</div>
</div>
<div class="datamodel">
<div class="dm-head"><svg class="dm-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg> Data model — Notifications &amp; Admin</div>
<div class="dm-body">
<div class="entities">
<span class="entity">notifications</span><span class="entity">support_alerts</span><span class="entity">roles</span><span class="entity">user_roles</span><span class="entity">audit_logs</span><span class="entity">system_events</span><span class="entity">platform_configs</span>
</div>
<ul class="dm-rel">
<li><span class="ent">notifications</span> (N:1 to <span class="ent">users</span>) carries a typed <code>data_json</code> payload for front-end deep-linking; a retention job deletes read rows &gt; 90 days.</li>
<li><span class="ent">support_alerts</span> are staff worklist items (never shown to users); <span class="ent">roles</span>/<span class="ent">user_roles</span> scope admin permissions.</li>
<li><span class="ent">audit_logs</span> is append-only over every sensitive transition <strong>including</strong> <span class="ent">platform_configs</span> changes; <span class="ent">system_events</span> is high-volume analytics (piped to a warehouse at scale); <span class="ent">platform_configs</span> holds runtime business parameters (fee rate, deadlines, dispute window, VAT, tolerance).</li>
<li>The tooling acts on the operational entities of every prior section: <span class="ent">nurse_verifications</span>/<span class="ent">verification_steps</span>/<span class="ent">nurse_credentials</span>, <span class="ent">refunds</span>, <span class="ent">nurse_payout_batches</span>/<span class="ent">nurse_payouts</span>/<span class="ent">nurse_clawbacks</span>, <span class="ent">bookings</span>.</li>
</ul>
</div>
</div>
<a class="back-to-top" href="#intro">&uarr; Back to top</a>
</section>
<hr class="rule" />
<!-- ================= DEEP DIVE: LEGAL ================= -->
<section id="legal">
<span class="eyebrow">Deep dive</span>
<h2 class="section-title">Legal landscape (Iran)</h2>
<p class="lead">You can legally build this in Iran &mdash; but it is a <strong>licensed healthcare activity</strong>, not a free-to-launch marketplace. Operating without a permit is what is illegal, and penalties escalate to permanent revocation and judicial referral.</p>
<h3>The governing framework</h3>
<p>Licensing flows through the MoH <strong>Treatment Deputy</strong> (<span class="fa">معاونت درمان</span>), after approval by the <strong>Article-20 medical-affairs commission</strong> (<span class="fa">کمیسیون قانونی تشخیص امور پزشکی موضوع ماده ۲۰</span>), under the Medical Affairs Law of 1334 (amended 1367) and the home-care bylaw approved 1378/7/17 (9 Oct 1999). Each center receives one establishment permit (<span class="fa">پروانه تأسیس</span>) and one technical-director license (<span class="fa">پروانه مسئول فنی</span>).</p>
<h3>Two regulatory tracks — pick the nursing track</h3>
<div class="table-wrap">
<table class="tbl">
<thead><tr><th></th><th>Home <strong>Nursing</strong> Services Center <span class="muted">(the vehicle)</span></th><th>Home <strong>Clinical</strong> Care Center</th></tr></thead>
<tbody>
<tr><td>Persian name</td><td class="fa">مرکز مشاوره و ارائه مراقبت‌های پرستاری در منزل</td><td class="fa">مرکز خدمات و مراقبت‌های بالینی در منزل</td></tr>
<tr><td>Governed via</td><td>Iranian Nursing Organization (<span class="fa">نظام پرستاری</span>)</td><td>MoH directly</td></tr>
<tr><td>Who can found / direct</td><td><strong>A nurse</strong> — BSc nursing + &ge;5 years clinical experience (can be both founder &amp; technical director)</td><td><strong>Both founder &amp; technical director must be physicians</strong></td></tr>
<tr><td>Fit</td><td>&check; Elderly / post-surgery / infant / chronic home nursing</td><td>Only with a physician partner</td></tr>
</tbody>
</table>
</div>
<h4>How the model must operate</h4>
<ul>
<li><strong>Care must be delivered in the patient's home;</strong> performing services at the center's HQ is prohibited. The licensed center is therefore a <strong>dispatch/coordination entity, not a walk-in clinic</strong> &mdash; which structurally fits a matchmaking/dispatch platform.</li>
<li>After <strong>principal approval</strong> (<span class="fa">موافقت اصولی</span>), the founder has up to one year to ready the center for final inspection before operating.</li>
<li><strong>e-namad</strong> (<span class="fa">نماد اعتماد الکترونیکی</span>) is required for an Iranian site providing online services/sales and is de-facto mandatory for a monetized site, because PSP/Shaparak rules require e-namad to obtain an online payment gateway (IPG).</li>
</ul>
<div class="callout decision">
<div class="callout-title"><span class="badge-ico">&loz;</span>Go-to-market: the partner-center vehicle</div>
<p>The #1 strategic recommendation: <strong>register a Home Nursing Services Center</strong>, then <strong>go to market fast via the Asanism model &mdash; partner with already-licensed centers</strong> while the platform's own permit is in process. This launches the tech/brand/marketplace layer legally and quickly, then brings supply in-house over time. The <code>partner_centers</code> entity makes this representable: it is the legal vehicle, plausibly the IPG merchant-of-record, and the BNPL onboarding gate (which needs a <span class="fa">جواز کسب</span> + eNamad the center holds, not each nurse).</p>
</div>
<div class="callout open">
<div class="callout-title"><span class="badge-ico">?</span>Open / to verify before launch</div>
<ul>
<li>Current (1404&ndash;1405) registered-company count and the present status of the <span class="fa">سخت و زیان‌آور</span> (arduous-work) labor-law gap &mdash; home-care nurses fall outside the regime that benefits hospital nurses; confirm whether any legislation has closed it.</li>
<li>Whether a tech-first marketplace can operate by subcontracting <em>only</em> to already-licensed partner centers without holding its own permit initially.</li>
<li>Full capital, facility, staffing, and insurance requirements for the nursing-services-center track.</li>
<li><strong>Worker classification</strong> (neutral marketplace vs employer/agency) with labor counsel &mdash; the highest-stakes structural decision; the "control-for-quality + contractor-for-cost" middle is exactly what triggers misclassification liability. Tax/VAT/company-structure specifics with a local accountant.</li>
</ul>
</div>
<a class="back-to-top" href="#intro">&uarr; Back to top</a>
</section>
<hr class="rule" />
<!-- ================= DEEP DIVE: VERIFICATION PIPELINE ================= -->
<section id="verif-pipeline">
<span class="eyebrow">Deep dive</span>
<h2 class="section-title">Verification pipeline (the operative detail)</h2>
<p class="lead">"Is this nurse really who they say, and really licensed?" splits into two checks that are separate pipeline stages: a <strong>license check</strong> (are they a registered nurse?) and an <strong>identity + background check</strong> (are they who they claim, with no disqualifying record?). The cautionary tale is the "imposter nurse" who defeated agencies with stolen identities and forged documents &mdash; so verify at source, bind to national ID + liveness, and never trust an uploaded PDF alone.</p>
<div class="table-wrap">
<table class="tbl">
<thead><tr><th>Stage</th><th>Goal</th><th>Iran tool / how</th><th>Programmatic?</th></tr></thead>
<tbody>
<tr><td><strong>0. Consent</strong></td><td>Lawful basis to verify + store data</td><td>Explicit in-app consent at onboarding</td><td>n/a</td></tr>
<tr><td><strong>1. Identity</strong></td><td>Match person &harr; <span class="fa">کد ملی</span> &harr; phone &harr; face</td><td><strong>Shahkar</strong> + national-ID validity + video/photo <strong>liveness</strong> vs national card, via one KYC vendor (Finnotech / U-ID / Jibbit / Farashensa / Verify / Kavoshak)</td><td><strong>Yes — off-the-shelf API</strong></td></tr>
<tr><td><strong>2. License</strong></td><td>Verify nursing credential at source</td><td>MoH <span class="fa">پروانه صلاحیت حرفه‌ای</span> (Rn.behdasht.gov.ir) as primary <strong>+</strong> INO <span class="fa">نظام پرستاری</span> number (ino.ir) as cross-check</td><td><strong>Manual</strong> — require upload + verify (no public API)</td></tr>
<tr><td><strong>3. Criminal record</strong></td><td>No disqualifying record</td><td><span class="fa">عدم سوء پیشینه</span> — nurse self-requests via adliran.ir / <span class="fa">ثنا</span> and uploads; <em>partly covered</em> by the MoH license</td><td><strong>No company API</strong> — consent-gated, nurse-uploaded</td></tr>
<tr><td><strong>4. Ongoing monitoring</strong></td><td>Catch revocations/expiry</td><td>Periodic re-verification of license validity + re-request of <span class="fa">عدم سوء پیشینه</span>; re-run Shahkar on phone change</td><td>Semi-manual; emulate Nursys e-Notify</td></tr>
</tbody>
</table>
</div>
<div class="callout decision">
<div class="callout-title"><span class="badge-ico">&loz;</span>Practical rules</div>
<ol style="margin:0;">
<li><strong>Buy identity verification</strong> through one KYC provider &mdash; it shifts the regulator-gated Shahkar / <span class="fa">ثبت احوال</span> access burden onto a vendor that already holds the agreements.</li>
<li><strong>Anchor the license check on the MoH <span class="fa">پروانه صلاحیت حرفه‌ای</span></strong> &mdash; it is State-mandated for in-home nursing and bundles a criminal screen.</li>
<li><strong>Treat the criminal certificate as nurse-supplied + consent-gated.</strong></li>
<li><strong>Build continuous monitoring</strong>, not one-and-done.</li>
<li><strong>Route through a licensed KYC intermediary</strong> to keep data-protection exposure compliant.</li>
</ol>
</div>
<p>These five stages map onto the data-driven <code>verification_step_types</code> rows of <a href="#s2">§2</a>, with the structured numbers captured in <code>nurse_credentials</code>, the raw vendor responses in <code>verification_steps.external_response_json</code>, and the IBAN-ownership inquiry result on <code>nurse_bank_accounts</code>.</p>
<a class="back-to-top" href="#intro">&uarr; Back to top</a>
</section>
<hr class="rule" />
<!-- ================= DEEP DIVE: ESCROW AS LEDGER ================= -->
<section id="escrow-ledger">
<span class="eyebrow">Deep dive</span>
<h2 class="section-title">Escrow as a ledger, not held cash</h2>
<p class="lead">Because Balinyaar cannot custody buyer funds, "escrow" must be a software construct: a double-entry ledger <em>state</em> over money that legally sits at a licensed provider/bank. The original model had no ledger &mdash; escrow was only inferable by joining status flags, with no single answer to "how much do we owe nurses right now?" Three critiques rated this a critical gap. The fix is one append-only table.</p>
<h3>The rails &amp; the custody prohibition</h3>
<ul>
<li>Every card payment is acquired by a licensed <strong>PSP</strong> and cleared through <strong>Shaparak</strong>, which settles to bank-registered IBANs (<span class="fa">شِبا</span>). There is <strong>no native marketplace-escrow construct</strong> that holds buyer cash in trust.</li>
<li>A <span class="fa">پرداخت‌یار</span> (payment facilitator) is <strong>explicitly forbidden</strong> from holding customer deposits, operating wallets, paying interest, granting credit/guarantees, or temporarily using merchant balances. Settlement goes <strong>only</strong> to merchant-registered accounts, and <strong>only Shaparak</strong> can withdraw from the special facilitator settlement account (<span class="fa">حساب ویژه پرداخت‌یاری</span>). <strong>This is the single load-bearing constraint of the whole design.</strong></li>
<li><strong><span class="fa">تسهیم</span> (settlement-sharing)</strong> is the compliant primitive: one incoming card payment is split across multiple registered IBANs and credited directly by Shaparak/the provider &mdash; the platform never touches the split.</li>
<li><strong>The banned move:</strong> "collect into a platform pool, hold until EVV, then redistribute" &mdash; Shaparak banned inter-facilitator/inter-merchant transfers and wallet-style holding. A bank-grade escrow product (Vandar <span class="fa">میندو</span> / <span class="fa">معاملات امن</span>) is the only true hold/release/refund mechanism, and even its EVV-trigger is unverified.</li>
</ul>
<div class="callout decision">
<div class="callout-title"><span class="badge-ico">&loz;</span>Why the ledger, not more columns</div>
<p>A marketplace that holds escrow, pays out weekly minus commission, and handles refunds + clawbacks has exactly the shape double-entry was invented for. The MVP cost is <strong>one table + posting discipline</strong>. The alternative (more money columns + status booleans) cannot answer "how much is held but unreleased" without fragile joins and makes bank/Shaparak reconciliation nearly impossible. Keep the per-booking fee snapshot as the <em>pricing</em> record; <code>ledger_entries</code> is the <em>financial-truth / reconciliation</em> layer posted alongside. The canonical postings (card capture, BNPL settle, refund pre-payout, clawback post-payout) are in <a href="#s8">§8</a>.</p>
</div>
<div class="callout iran">
<div class="callout-title"><span class="badge-ico">!</span>Provider continuity risk</div>
<p>In <strong>Nov 2024 the CBI abruptly cut Toman's and Jibit's settlement/withdrawal services</strong> with no stated cause, stranding businesses (including millions of Snapp drivers). Wallet/balance facilitator models have been blocked and re-permitted before (Vandar's gateway). Design for <strong>multi-provider failover</strong> and a reconciliation ledger that survives a provider being cut off mid-cycle &mdash; which is exactly what the provider-abstracted <code>payment_gateways</code> + the append-only <code>ledger_entries</code> deliver.</p>
</div>
<a class="back-to-top" href="#intro">&uarr; Back to top</a>
</section>
<hr class="rule" />
<!-- ================= DEEP DIVE: BNPL Q1/Q2 ================= -->
<section id="bnpl-deep">
<span class="eyebrow">Deep dive</span>
<h2 class="section-title">BNPL mechanics &mdash; the two hard questions</h2>
<p class="lead">All mainstream Iranian provider-financed BNPLs (SnappPay, Digipay, Tara, Torob Pay, ZarinPlus) use <strong>full-upfront settlement</strong>: the provider pays the merchant the whole amount minus commission in one lump and bears default risk; the customer's installments are owned by the provider and decoupled from Balinyaar's escrow/payout. Lendo is the outlier (bank-financed, customer pays interest) &mdash; avoid for the MVP.</p>
<h3>Provider comparison (the structurally important facts)</h3>
<div class="table-wrap">
<table class="tbl">
<thead><tr><th>Provider</th><th>Settlement</th><th>Who bears cost</th><th>Customer plan</th><th>Merchant fee</th></tr></thead>
<tbody>
<tr><td><strong>SnappPay</strong></td><td>Full-upfront, single lump minus commission; provider bears default risk</td><td>Merchant (commission)</td><td>4 installments / 4 months, interest-free</td><td>Undocumented (anecdotal ~7&ndash;15%); per-contract config</td></tr>
<tr><td><strong>Digipay</strong></td><td>Full-upfront to contracted merchant; provider bears default risk</td><td>Customer markup + merchant acquiring commission</td><td>1-month + 4-installment + 3/6/9/12-mo loan</td><td><span class="fa">توافقی</span> (negotiable); sells early-settlement as an add-on</td></tr>
<tr><td><strong>Tara</strong></td><td>Provider-financed, full amount to seller</td><td>Merchant (interest-free to customer)</td><td>2 interest-free installments, starting 1 month after</td><td>Per-contract</td></tr>
<tr><td><strong>Torob Pay</strong></td><td>Full-upfront, cash to seller</td><td>Merchant</td><td>4 equal installments, 25% down, interest-free</td><td><strong>Concrete: 6% + VAT = 6.6%</strong></td></tr>
<tr><td><strong>Lendo</strong> <span class="tag cut">avoid</span></td><td>Bank-financed (Bank Ayandeh)</td><td><strong>Customer</strong> (~18&ndash;23% interest + ~5% upfront fee)</td><td>6 / 9 / 12 months — a POS loan</td><td></td></tr>
</tbody>
</table>
</div>
<h3 id="q1">Q1 — Cancellation / refund of a BNPL booking mid-plan</h3>
<p><strong>Money always flows <code>customer &harr; provider &harr; Balinyaar</code>.</strong> Never refund the customer directly; never route a nurse&rarr;customer refund. Balinyaar initiates the reversal through the provider's API using the stored token:</p>
<ul>
<li><strong>Full cancel/refund &rarr; <code>revert</code></strong> (full amount).</li>
<li><strong>Partial / shortened-visit &rarr; <code>update</code></strong> (new amount strictly lower) or <code>cancel</code> per the provider's partial semantics.</li>
</ul>
<p>The provider then, on its own ledger and asynchronously: (1) <strong>cancels the customer's remaining unpaid installments</strong> and credits the equivalent back to their credit wallet (reusable BNPL credit), and (2) <strong>refunds any already-paid installment to the customer's bank in ~7&ndash;10 business days.</strong> The merchant's only role is to authorize/cancel; the provider owns the unwind.</p>
<h5>Balinyaar's internal bookkeeping</h5>
<ol class="flow">
<li>Record a <code>refunds</code> row with <code>refund_channel = 'bnpl_revert'</code>, <code>external_revert_reference</code>, <code>expected_customer_refund_eta</code>; <code>refund_status</code> stays <span class="pill">processing</span> until a reconciliation job confirms. <strong>Surface the 7&ndash;10-day window</strong> in UI/reconciliation.</li>
<li><strong>Decompose</strong> across the two fee legs: <code>platform_fee_refunded_irr</code> and <code>nurse_payout_refunded_irr</code>.</li>
<li><strong>Post balanced ledger entries</strong> and record the revert reference on the <code>bnpl_transactions</code> row (<code>reverted_amount_irr</code>, <code>reverted_at</code>).</li>
<li><strong>If the nurse has NOT been paid:</strong> reverse the <code>nurse_payable</code> accrual &mdash; clean, nothing leaves Balinyaar (the common case if payout is gated on the dispute window).</li>
<li><strong>If the nurse HAS been paid:</strong> the <strong>clawback</strong> path &mdash; a <code>nurse_clawbacks</code> row + a <code>nurse_clawback_receivable</code> leg, recovered from the next batch or written off.</li>
</ol>
<div class="callout open">
<div class="callout-title"><span class="badge-ico">?</span>Open — confirm at contracting</div>
<p>Whether the provider returns <em>its</em> merchant commission on a full vs partial refund (full / pro-rata / not at all) is undocumented and directly affects platform P&amp;L on cancellations. Model <code>provider_commission_reversed_amount</code> as nullable and reconcile from the provider's refund response &mdash; never hardcode.</p>
</div>
<h3 id="q2">Q2 — Under BNPL, who pays the nurse, and when?</h3>
<p><strong>Balinyaar pays the nurse, on its own normal weekly schedule, after EVV check-out and after the dispute window closes &mdash; exactly the same path as a card-funded booking.</strong> The provider never pays the nurse and is indifferent to the internal split. The nurse's payout is computed from <code>gross_price_irr &minus; balinyaar_commission_irr</code>, <strong>NOT</strong> from the BNPL-net amount.</p>
<div class="callout note">
<div class="callout-title"><span class="badge-ico">i</span>Worked example (illustrative; rates are config)</div>
<p>Gross <code>5,000,000</code> IRR, Balinyaar commission 15% = <code>750,000</code>, nurse payout = <code>4,250,000</code>. If paid via SnappPay at a 10% merchant commission, <code>bnpl_commission_irr = 500,000</code> is a Balinyaar expense; SnappPay settles <code>4,500,000</code> net to Balinyaar; the <strong>nurse still receives <code>4,250,000</code></strong>, and Balinyaar's net margin is <code>750,000 &minus; 500,000 = 250,000</code> (before PSP/VAT). <strong>The nurse payout is invariant to the payment method.</strong> The only difference a BNPL order makes is the extra <code>bnpl_fee_expense</code> leg that reduces <em>Balinyaar's</em> margin.</p>
</div>
<h3>Integration notes</h3>
<ul>
<li><strong>SnappPay (primary)</strong> — API + IPG redirect; verified endpoint flow: OAuth token &rarr; eligible &rarr; payment token (redirect) &rarr; verify &rarr; settle &rarr; revert/cancel/update/status.</li>
<li><strong>Digipay (secondary)</strong> — unified UPG gateway; persist the gateway type per transaction (IPG=0, Wallet=11, Credit=5, BNPL=13, Credit-Card=24); deliver/refund calls must carry the matching code; <strong>gate <code>deliver</code> on the nurse's EVV check-out</strong>; each purchase supports either refund OR manual reverse, not both.</li>
<li><strong>Cross-cutting:</strong> webhook idempotency via <code>payment_webhook_events</code> keyed on <code>external_event_id</code>, written first; never trust the callback alone &mdash; always <code>verify</code> server-side and re-check amount + reference; amounts in IRR <code>BIGINT</code>, converting from Toman only at the boundary; a state-machine guard on BNPL status transitions.</li>
<li><strong>Recommendation:</strong> integrate <strong>SnappPay first, Digipay second, avoid Lendo</strong>.</li>
</ul>
<a class="back-to-top" href="#intro">&uarr; Back to top</a>
</section>
<hr class="rule" />
<!-- ================= DEEP DIVE: MARKET ================= -->
<section id="market">
<span class="eyebrow">Deep dive</span>
<h2 class="section-title">Market &amp; competitors</h2>
<p class="lead">The market is real and already competitive &mdash; but incumbents are heavily concentrated in Tehran/Karaj and run mostly as direct-dispatch staffing, not trust-first marketplaces. That is the gap. The hardest problem is trust and safety, not technology.</p>
<h3>Iranian players</h3>
<div class="table-wrap">
<table class="tbl">
<thead><tr><th>Player</th><th>Model</th><th>Notable facts</th></tr></thead>
<tbody>
<tr><td><strong>Asanism</strong> (<span class="fa">آسانیسم</span>)</td><td>Matching/marketplace supplying caregivers <strong>through licensed partner centers</strong> (intermediary)</td><td>Markets identity-vetting, a reported ~40M-toman security note, 24&ndash;48hr trial periods. ~99% concentrated in Tehran/Karaj. <strong>The model the launch vehicle imitates.</strong></td></tr>
<tr><td><strong>Snapp Doctor</strong></td><td>Health vertical of Snapp; managed dispatch</td><td>Operates in several cities. Holds a general online-medical-intermediary license &mdash; <strong>not</strong> a specific home-nursing MoH authorization.</td></tr>
<tr><td><strong>Salamat Aval</strong></td><td><strong>Direct dispatch of its own nurses</strong> (not an open marketplace)</td><td>3,000+ active personnel (self-reported), 24/7 call center, holds official MoH license no. 388180-3 (displays it prominently). Pricing <span class="fa">توافقی</span>.</td></tr>
<tr><td><strong>Hirad</strong></td><td>App-based managed staffing/dispatch</td><td>Shows both sides; advertises no placement fee; states it operates under MoH authorization. Modest adoption.</td></tr>
</tbody>
</table>
</div>
<p><strong>What this tells you:</strong> the dominant model is direct/managed dispatch, <em>not</em> a true trust-first two-sided marketplace; geographic concentration is extreme (Tehran/Karaj dominate; second-tier cities thinly served &mdash; the clearest white space); pricing is opaque/negotiable (transparency is a differentiator); and "licensed" is a real, displayed trust signal. The closest international fit is the <strong>Homage</strong> model (curated marketplace + human matching), and India shows that where licensing infrastructure is weak, <strong>vetting and quality control are the product</strong>.</p>
<h3>The risks that shape the build</h3>
<div class="grid2">
<div class="callout note" style="margin:0;">
<div class="callout-title"><span class="badge-ico">i</span>Trust &amp; safety</div>
<p>Connecting strangers to vulnerable people without rigorous platform-owned vetting enables theft, abuse, and fraud &mdash; and the public blames the platform (Care.com's records-laden listings; the "imposter nurse" with 20+ aliases / 7 SSNs / forged docs). <strong>Own the vetting; verify at source; bind to national ID + liveness; re-verify periodically.</strong></p>
</div>
<div class="callout note" style="margin:0;">
<div class="callout-title"><span class="badge-ico">i</span>Liability &amp; classification</div>
<p>Worker misclassification ($10M TLC judgment), vicarious liability / negligent hiring, and insurance gaps stack. <strong>The dangerous middle &mdash; heavy control for "quality" but contractor classification for cost &mdash; is what triggers misclassification judgments.</strong> Keeping the nurse's per-request accept/reject autonomy is a deliberate hedge.</p>
</div>
<div class="callout note" style="margin:0;">
<div class="callout-title"><span class="badge-ico">i</span>Operational &amp; disintermediation</div>
<p>Extreme caregiver churn, no-shows that strand a patient, and <strong>disintermediation</strong> (families + nurses pairing off-platform). Beat leakage with <strong>retained value, not lock-in</strong>: EVV, a backup-coverage guarantee, in-platform escrow/dispute protection, and insurance that only applies on-platform &mdash; precisely the EVV, ticket-only messaging, escrow-ledger, and review mechanisms modeled above.</p>
</div>
<div class="callout note" style="margin:0;">
<div class="callout-title"><span class="badge-ico">i</span>Payment &amp; fraud</div>
<p>Gig-marketplace fraud runs ~2&times; elsewhere (&gt;90% impersonation); financial elder abuse is real (1 in 9 known-perpetrator cases is a non-family caregiver). <strong>Tie reviews to verified, completed, on-platform bookings; strong identity verification at onboarding; in-platform escrow with dispute resolution.</strong></p>
</div>
</div>
<a class="back-to-top" href="#intro">&uarr; Back to top</a>
</section>
<hr class="rule" />
<!-- ================= FULL DATA MODEL ================= -->
<section id="dm-intro">
<span class="eyebrow">Reference</span>
<h2 class="section-title">The whole data model</h2>
<p class="lead">The complete schema (Revision 2): ~53 tables across 13 domains. Each domain below lists its tables with key fields/columns and relationships (foreign keys). Net change vs the original 45: &minus;2 cut (<code>installment_plans</code> replaced, <code>installment_entries</code> removed), +10 added, 1 replaced &mdash; the financial core is now a single ledger, BNPL is one settlement row, and the clawback / dispute-window / idempotency / license / multi-session gaps are closed.</p>
<h4>Scope &amp; change legend</h4>
<div class="legend" style="margin-bottom:6px;">
<span><span class="tag core">CORE</span> launch-critical</span>
<span><span class="tag mvp">MVP</span> in first release</span>
<span><span class="tag def">DEFERRED</span> modeled now, inactive at launch</span>
<span><span class="tag new">NEW</span> added in Rev 2</span>
<span><span class="tag chg">CHG</span> changed/renamed</span>
<span><span class="tag cut">CUT</span> removed/replaced</span>
</div>
<p class="muted" style="font-size:0.86rem;">Conventions: PII columns are <em>(encrypted)</em>. Money is <code>BIGINT</code> IRR. Timestamps are <code>DATETIME2(7)</code> UTC. Most tables also carry <code>created_at</code>/<code>updated_at</code> (and <code>deleted_at</code> where soft-deleted); these are omitted from the key-field lists for brevity.</p>
<div class="card" style="background:var(--fill-primary);">
<h6 style="margin-bottom:8px;">The 13 domains at a glance</h6>
<div class="grid3" style="gap:8px;">
<span class="muted" style="font-size:0.86rem;"><a href="#dm-d1">D1</a> Identity &amp; Access (9)</span>
<span class="muted" style="font-size:0.86rem;"><a href="#dm-d2">D2</a> Geographic (4)</span>
<span class="muted" style="font-size:0.86rem;"><a href="#dm-d3">D3</a> Services &amp; Pricing (8)</span>
<span class="muted" style="font-size:0.86rem;"><a href="#dm-d4">D4</a> Verification (5)</span>
<span class="muted" style="font-size:0.86rem;"><a href="#dm-d5">D5</a> Booking &amp; Scheduling (6)</span>
<span class="muted" style="font-size:0.86rem;"><a href="#dm-d6">D6</a> Payments / Ledger (9)</span>
<span class="muted" style="font-size:0.86rem;"><a href="#dm-d7">D7</a> Payouts (3)</span>
<span class="muted" style="font-size:0.86rem;"><a href="#dm-d8">D8</a> BNPL (2)</span>
<span class="muted" style="font-size:0.86rem;"><a href="#dm-d9">D9</a> Messaging (3)</span>
<span class="muted" style="font-size:0.86rem;"><a href="#dm-d10">D10</a> Reviews &amp; Records (4)</span>
<span class="muted" style="font-size:0.86rem;"><a href="#dm-d11">D11</a> Notifications (2)</span>
<span class="muted" style="font-size:0.86rem;"><a href="#dm-d12">D12</a> Audit &amp; Config (4)</span>
<span class="muted" style="font-size:0.86rem;"><a href="#dm-d13">D13</a> Partner &amp; Future (5)</span>
</div>
</div>
</section>
<!-- ===== DOMAIN 1 ===== -->
<section id="dm-d1">
<h3>Domain 1 &mdash; Identity &amp; Access</h3>
<p>One identity table for every human actor avoids three near-duplicate user tables; <code>role</code> decides which profile sub-table is populated. Phone-as-primary matches Iranian OTP norms and is what Shahkar matches against.</p>
<div class="table-wrap">
<table class="tbl schema">
<thead><tr><th>Table</th><th>Key fields</th><th>Relationships (FK)</th></tr></thead>
<tbody>
<tr>
<td><code>users</code> <span class="tag core">CORE</span></td>
<td><span class="col-field">id</span> PK · <span class="col-field">email</span> (enc, nullable) · <span class="col-field">phone</span> (enc, UNIQUE) · <span class="col-field">national_id</span> (enc, nullable) · <span class="col-field">national_id_verified_at</span> · <span class="col-field">first_name</span>/<span class="col-field">last_name</span> · <span class="col-field">gender</span> <span class="tag new">NEW</span> · <span class="col-field">role</span> (nurse/customer/admin) · <span class="col-field">shahkar_verified_at</span> <span class="tag new">NEW</span> · <span class="col-field">is_active</span> · <span class="col-field">last_login_at</span>/<span class="col-field">_ip</span> · <span class="col-field">deleted_at</span></td>
<td>1:1 &rarr; <span class="ent">nurse_profiles</span> / <span class="ent">customer_profiles</span> (by role); 1:N &rarr; <span class="ent">user_sessions</span>, <span class="ent">user_roles</span>, <span class="ent">notifications</span>, <span class="ent">ticket_participants</span>. Referenced as <code>*_by_admin_id</code> across the schema.</td>
</tr>
<tr>
<td><code>user_sessions</code> <span class="tag core">CORE</span></td>
<td><span class="col-field">id</span> · <span class="col-field">user_id</span> · <span class="col-field">refresh_token_hash</span> · <span class="col-field">device_info</span> · <span class="col-field">ip_address</span> · <span class="col-field">is_revoked</span> · <span class="col-field">revoked_at</span> · <span class="col-field">expires_at</span></td>
<td>N:1 &rarr; <span class="ent">users</span>. Enables logout-everywhere &amp; stolen-token revocation.</td>
</tr>
<tr>
<td><code>roles</code> <span class="tag core">CORE</span></td>
<td><span class="col-field">id</span> · <span class="col-field">name</span> (super_admin/admin/support/finance/moderator) · <span class="col-field">description</span></td>
<td>N:N <span class="ent">users</span> via <span class="ent">user_roles</span>. Admin staff only.</td>
</tr>
<tr>
<td><code>user_roles</code> <span class="tag core">CORE</span></td>
<td><span class="col-field">user_id</span> · <span class="col-field">role_id</span> · <span class="col-field">granted_by</span> · <span class="col-field">granted_at</span> · <span class="col-field">revoked_at</span></td>
<td>Join table; keeps a grant/revoke audit trail.</td>
</tr>
<tr>
<td><code>nurse_profiles</code> <span class="tag core">CORE</span></td>
<td><span class="col-field">id</span> · <span class="col-field">user_id</span> (UNIQUE) · <span class="col-field">partner_center_id</span> <span class="tag new">NEW</span> · <span class="col-field">bio</span> · <span class="col-field">years_of_experience</span> · <span class="col-field">education_level</span>/<span class="col-field">_field</span> · <span class="col-field">specializations_json</span> · <span class="col-field">is_verified</span> (guarded) · <span class="col-field">is_accepting_bookings</span> · <span class="col-field">average_rating</span>/<span class="col-field">total_reviews</span>/<span class="col-field">total_completed_bookings</span> (denorm). <span class="tag cut">CUT</span> <code>verification_status</code>, <code>response_rate</code>, <code>profile_completion_score</code></td>
<td>1:1 &rarr; <span class="ent">users</span>, <span class="ent">nurse_verifications</span>; 1:N &rarr; <span class="ent">nurse_service_variants</span>, <span class="ent">nurse_service_areas</span>, <span class="ent">nurse_bank_accounts</span>, <span class="ent">nurse_credentials</span>, <span class="ent">bookings</span>, <span class="ent">nurse_payouts</span>, <span class="ent">nurse_clawbacks</span>; N:1 &rarr; <span class="ent">partner_centers</span>.</td>
</tr>
<tr>
<td><code>customer_profiles</code> <span class="tag core">CORE</span></td>
<td><span class="col-field">id</span> · <span class="col-field">user_id</span> (UNIQUE) · <span class="col-field">default_emergency_contact_name</span>/<span class="col-field">_phone</span> (enc). <span class="tag cut">CUT</span> <code>national_id_verified_at</code> (deferred customer KYC)</td>
<td>1:1 &rarr; <span class="ent">users</span>; 1:N &rarr; <span class="ent">patients</span>, <span class="ent">customer_addresses</span>, <span class="ent">booking_requests</span>, <span class="ent">bookings</span>.</td>
</tr>
<tr>
<td><code>patients</code> <span class="tag core">CORE</span></td>
<td><span class="col-field">id</span> · <span class="col-field">customer_id</span> · <span class="col-field">display_name</span> · <span class="col-field">first_name</span>/<span class="col-field">last_name</span> · <span class="col-field">birth_date</span> · <span class="col-field">gender</span> · <span class="col-field">blood_type</span> · <span class="col-field">initial_medical_notes</span> (enc) · <span class="col-field">is_active</span></td>
<td>N:1 &rarr; <span class="ent">customer_profiles</span>; 1:N &rarr; <span class="ent">booking_requests</span>, <span class="ent">patient_care_records</span>. <strong>Tenancy:</strong> a request's patient must belong to the same customer.</td>
</tr>
<tr>
<td><code>customer_addresses</code> <span class="tag core">CORE</span></td>
<td><span class="col-field">id</span> · <span class="col-field">customer_id</span> · <span class="col-field">city_id</span> · <span class="col-field">district_id</span> · address lines (enc) · <span class="col-field">latitude</span>/<span class="col-field">longitude</span> (EVV) · <span class="col-field">is_primary</span> — filtered <code>UNIQUE(customer_id) WHERE is_primary=1</code></td>
<td>N:1 &rarr; <span class="ent">customer_profiles</span>, <span class="ent">cities</span>, <span class="ent">districts</span>; referenced by <span class="ent">booking_requests</span>/<span class="ent">bookings</span>.</td>
</tr>
<tr>
<td><code>nurse_bank_accounts</code> <span class="tag core">CORE</span></td>
<td><span class="col-field">id</span> · <span class="col-field">nurse_id</span> · <span class="col-field">bank_name</span> · <span class="col-field">account_holder_name</span> (enc) · <span class="col-field">iban</span> (enc) · <span class="col-field">iban_hash</span> <span class="tag new">NEW</span> (UNIQUE) · <span class="col-field">matched_national_id</span> <span class="tag new">NEW</span> · <span class="col-field">account_holder_from_bank</span> <span class="tag new">NEW</span> · <span class="col-field">ownership_vendor_ref</span> <span class="tag new">NEW</span> · <span class="col-field">is_primary</span> · <span class="col-field">is_verified</span> — filtered <code>UNIQUE(nurse_id) WHERE is_primary=1</code></td>
<td>N:1 &rarr; <span class="ent">nurse_profiles</span>; 1:N &rarr; <span class="ent">nurse_payouts</span>. The single place real money leaves the platform.</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- ===== DOMAIN 2 ===== -->
<section id="dm-d2">
<h3>Domain 2 &mdash; Geographic Data</h3>
<p>A table (not a static list) so new cities/districts launch without a deploy; <code>sort_order</code>/<code>is_active</code> drive ordered, toggleable dropdowns. Districts are optional (a nurse can cover a whole city).</p>
<div class="table-wrap">
<table class="tbl schema">
<thead><tr><th>Table</th><th>Key fields</th><th>Relationships (FK)</th></tr></thead>
<tbody>
<tr><td><code>provinces</code> <span class="tag core">CORE</span></td><td><span class="col-field">id</span> · <span class="col-field">name_fa</span>/<span class="col-field">name_en</span> · <span class="col-field">sort_order</span> · <span class="col-field">is_active</span></td><td>1:N &rarr; <span class="ent">cities</span>.</td></tr>
<tr><td><code>cities</code> <span class="tag core">CORE</span></td><td><span class="col-field">id</span> · <span class="col-field">province_id</span> · <span class="col-field">name_fa</span>/<span class="col-field">name_en</span> · <span class="col-field">sort_order</span> · <span class="col-field">is_active</span></td><td>N:1 &rarr; <span class="ent">provinces</span>; 1:N &rarr; <span class="ent">districts</span>; referenced by <span class="ent">customer_addresses</span>, <span class="ent">nurse_service_areas</span>.</td></tr>
<tr><td><code>districts</code> <span class="tag mvp">MVP</span></td><td><span class="col-field">id</span> · <span class="col-field">city_id</span> · <span class="col-field">name_fa</span>/<span class="col-field">name_en</span> — Tehran's 22 <span class="fa">مناطق</span> or major neighborhoods</td><td>N:1 &rarr; <span class="ent">cities</span>; referenced by <span class="ent">customer_addresses</span>, <span class="ent">nurse_service_areas</span>.</td></tr>
<tr><td><code>nurse_service_areas</code> <span class="tag core">CORE</span></td><td><span class="col-field">id</span> · <span class="col-field">nurse_id</span> · <span class="col-field">city_id</span> · <span class="col-field">district_id</span> (NULL = whole city) — <code>UNIQUE(nurse_id, city_id, district_id)</code></td><td>N:1 &rarr; <span class="ent">nurse_profiles</span>, <span class="ent">cities</span>, <span class="ent">districts</span>. Drives the geo filter in search cheaply.</td></tr>
</tbody>
</table>
</div>
</section>
<!-- ===== DOMAIN 3 ===== -->
<section id="dm-d3">
<h3>Domain 3 &mdash; Services &amp; Pricing</h3>
<p>Three admin layers (category &rarr; option group &rarr; option value) + two nurse layers (variant &rarr; variant option). This EAV-style configurability lets admins add a new pricing dimension without a migration; the only addition is a denormalized read model for search.</p>
<div class="table-wrap">
<table class="tbl schema">
<thead><tr><th>Table</th><th>Key fields</th><th>Relationships (FK)</th></tr></thead>
<tbody>
<tr><td><code>service_categories</code> <span class="tag core">CORE</span></td><td><span class="col-field">id</span> · <span class="col-field">name_fa</span>/<span class="col-field">name_en</span> · <span class="col-field">icon</span> · <span class="col-field">sort_order</span> · <span class="col-field">is_active</span></td><td>1:N &rarr; <span class="ent">service_option_groups</span>, <span class="ent">nurse_service_variants</span>. The primary search dimension.</td></tr>
<tr><td><code>service_option_groups</code> <span class="tag core">CORE</span></td><td><span class="col-field">id</span> · <span class="col-field">service_category_id</span> (NULL = cross-category) · <span class="col-field">name_fa</span>/<span class="col-field">name_en</span> · <span class="col-field">is_required</span></td><td>N:1 &rarr; <span class="ent">service_categories</span>; 1:N &rarr; <span class="ent">service_option_values</span>.</td></tr>
<tr><td><code>service_option_values</code> <span class="tag core">CORE</span></td><td><span class="col-field">id</span> · <span class="col-field">option_group_id</span> · <span class="col-field">name_fa</span>/<span class="col-field">name_en</span> · <span class="col-field">sort_order</span></td><td>N:1 &rarr; <span class="ent">service_option_groups</span>.</td></tr>
<tr><td><code>nurse_service_variants</code> <span class="tag core">CORE</span></td><td><span class="col-field">id</span> · <span class="col-field">nurse_id</span> · <span class="col-field">service_category_id</span> · <span class="col-field">display_name</span> · <span class="col-field">price</span> · <span class="col-field">price_unit</span> (per_hour/per_session/per_half_day/per_day/per_24h) · <span class="col-field">estimated_duration</span> · <span class="col-field">is_active</span></td><td>N:1 &rarr; <span class="ent">nurse_profiles</span>, <span class="ent">service_categories</span>; 1:N &rarr; <span class="ent">nurse_service_variant_options</span>, <span class="ent">booking_requests</span>. The <strong>atomic bookable unit</strong>.</td></tr>
<tr><td><code>nurse_service_variant_options</code> <span class="tag core">CORE</span></td><td><span class="col-field">id</span> · <span class="col-field">variant_id</span> · <span class="col-field">option_group_id</span> · <span class="col-field">option_value_id</span><code>UNIQUE(variant_id, option_group_id)</code></td><td>N:1 &rarr; <span class="ent">nurse_service_variants</span>, <span class="ent">service_option_groups</span>, <span class="ent">service_option_values</span>.</td></tr>
<tr><td><code>nurse_search_index</code> <span class="tag core">CORE</span> <span class="tag new">NEW</span></td><td><span class="col-field">id</span> · <span class="col-field">variant_id</span> · <span class="col-field">nurse_id</span> · <span class="col-field">service_category_id</span> · <span class="col-field">price</span>/<span class="col-field">price_unit</span> · <span class="col-field">city_id</span>/<span class="col-field">district_id</span> (fan-out) · <span class="col-field">nurse_gender</span> · <span class="col-field">average_rating</span>/<span class="col-field">total_reviews</span> · <span class="col-field">is_searchable</span></td><td>Read-only projection maintained on writes to <span class="ent">nurse_profiles</span>, <span class="ent">nurse_service_variants</span>, <span class="ent">nurse_service_areas</span>, <span class="ent">reviews</span>. <code>is_searchable=1</code> only when source is bookable.</td></tr>
<tr><td><code>nurse_availability_slots</code> <span class="tag mvp">MVP</span></td><td><span class="col-field">id</span> · <span class="col-field">nurse_id</span> · <span class="col-field">day_of_week</span> (0=Sat…6=Fri) · <span class="col-field">start_time</span>/<span class="col-field">end_time</span> — CHECK <code>end_time &gt; start_time</code></td><td>N:1 &rarr; <span class="ent">nurse_profiles</span>. Soft guidance only.</td></tr>
<tr><td><code>nurse_availability_exceptions</code> <span class="tag mvp">MVP</span></td><td><span class="col-field">id</span> · <span class="col-field">nurse_id</span> · <span class="col-field">exception_date</span> · <span class="col-field">is_available</span> · <span class="col-field">reason</span></td><td>N:1 &rarr; <span class="ent">nurse_profiles</span>. Date overrides; informs search, never blocks.</td></tr>
</tbody>
</table>
</div>
</section>
<!-- ===== DOMAIN 4 ===== -->
<section id="dm-d4">
<h3>Domain 4 &mdash; Verification &amp; Credentials</h3>
<p>Data-driven (step types are rows) plus a structured credential registry, because the brand <em>is</em> "verified trust" and renewal tracking needs queryable license numbers, not opaque PDFs.</p>
<div class="table-wrap">
<table class="tbl schema">
<thead><tr><th>Table</th><th>Key fields</th><th>Relationships (FK)</th></tr></thead>
<tbody>
<tr><td><code>nurse_verifications</code> <span class="tag core">CORE</span></td><td><span class="col-field">id</span> · <span class="col-field">nurse_id</span> (UNIQUE) · <span class="col-field">status</span> (not_started/pending/in_review/approved/rejected/suspended) · <span class="col-field">submitted_at</span>/<span class="col-field">approved_at</span>/<span class="col-field">rejected_at</span>/<span class="col-field">suspended_at</span> · <span class="col-field">rejection_reason</span> · <span class="col-field">reviewed_by_admin_id</span> · <span class="col-field">internal_notes</span></td><td>1:1 &rarr; <span class="ent">nurse_profiles</span>; 1:N &rarr; <span class="ent">verification_steps</span>. The single source of truth for verification state.</td></tr>
<tr><td><code>verification_step_types</code> <span class="tag core">CORE</span></td><td><span class="col-field">id</span> · <span class="col-field">code</span> (identity_kyc/shahkar_match/moh_competency_license/ino_membership/criminal_record/bank_account_verification) · <span class="col-field">name_fa</span>/<span class="col-field">name_en</span> · <span class="col-field">is_required</span> · <span class="col-field">is_automated</span> · <span class="col-field">automation_provider</span> · <span class="col-field">sort_order</span></td><td>1:N &rarr; <span class="ent">verification_steps</span>. Admin catalog &mdash; a new requirement is one INSERT.</td></tr>
<tr><td><code>verification_steps</code> <span class="tag core">CORE</span></td><td><span class="col-field">id</span> · <span class="col-field">nurse_verification_id</span> · <span class="col-field">step_type_id</span> · <span class="col-field">status</span> · <span class="col-field">is_automated</span> (snapshot) · <span class="col-field">external_response_json</span> (KYC vendor audit) · <span class="col-field">expires_at</span> · <span class="col-field">reviewed_by_admin_id</span><code>UNIQUE(nurse_verification_id, step_type_id)</code></td><td>N:1 &rarr; <span class="ent">nurse_verifications</span>, <span class="ent">verification_step_types</span>; 1:N &rarr; <span class="ent">verification_documents</span>. On expiry of a time-limited step it reverts to pending + raises a <span class="ent">support_alerts</span>.</td></tr>
<tr><td><code>verification_documents</code> <span class="tag core">CORE</span></td><td><span class="col-field">id</span> · <span class="col-field">verification_step_id</span> · <span class="col-field">storage_key</span> · <span class="col-field">integrity_hash</span> · <span class="col-field">file_type</span> · <span class="col-field">uploaded_at</span></td><td>N:1 &rarr; <span class="ent">verification_steps</span>. Metadata only; bytes live in S3-compatible storage behind signed URLs.</td></tr>
<tr><td><code>nurse_credentials</code> <span class="tag mvp">MVP</span> <span class="tag new">NEW</span></td><td><span class="col-field">id</span> · <span class="col-field">nurse_id</span> · <span class="col-field">credential_type</span> (moh_competency_license / ino_membership / criminal_record) · <span class="col-field">credential_number</span> (enc) · <span class="col-field">holder_name_snapshot</span> · <span class="col-field">issuing_authority</span> · <span class="col-field">issued_at</span>/<span class="col-field">expires_at</span> · <span class="col-field">verification_source</span> · <span class="col-field">verification_method</span> (manual/portal/api) · <span class="col-field">verified_by_admin_id</span></td><td>N:1 &rarr; <span class="ent">nurse_profiles</span>. Cross-referenced by the relevant <span class="ent">verification_steps</span>. Powers renewal alerts &amp; the trust badge.</td></tr>
</tbody>
</table>
</div>
</section>
<!-- ===== DOMAIN 5 ===== -->
<section id="dm-d5">
<h3>Domain 5 &mdash; Booking &amp; Scheduling</h3>
<p>Two distinct phases &mdash; the request phase (pre-payment intent) and the booking phase (post-payment commitment). The previous model's biggest gap &mdash; single-visit-only bookings &mdash; is fixed with <code>booking_sessions</code>.</p>
<div class="table-wrap">
<table class="tbl schema">
<thead><tr><th>Table</th><th>Key fields</th><th>Relationships (FK)</th></tr></thead>
<tbody>
<tr><td><code>booking_requests</code> <span class="tag core">CORE</span></td><td><span class="col-field">id</span> · <span class="col-field">customer_id</span>/<span class="col-field">nurse_id</span>/<span class="col-field">patient_id</span>/<span class="col-field">variant_id</span>/<span class="col-field">customer_address_id</span> · <span class="col-field">required_caregiver_gender</span> <span class="tag new">NEW</span> · <span class="col-field">requested_date</span>/<span class="col-field">_time_start</span>/<span class="col-field">_time_end</span> · <span class="col-field">customer_notes</span> (unenc, request-stage) · <span class="col-field">status</span> · <span class="col-field">nurse_response_deadline_at</span> · <span class="col-field">payment_deadline_at</span> · <span class="col-field">nurse_rejection_reason</span></td><td>N:1 &rarr; <span class="ent">customer_profiles</span>, <span class="ent">nurse_profiles</span>, <span class="ent">patients</span>, <span class="ent">nurse_service_variants</span>, <span class="ent">customer_addresses</span>; 1:1 &rarr; <span class="ent">bookings</span> on conversion. <strong>Tenancy:</strong> patient/address belong to customer; variant to nurse.</td></tr>
<tr><td><code>bookings</code> <span class="tag core">CORE</span></td><td><span class="col-field">id</span> · <span class="col-field">booking_request_id</span> (UNIQUE) · <span class="col-field">customer_id</span>/<span class="col-field">nurse_id</span>/<span class="col-field">patient_id</span>/<span class="col-field">variant_id</span>/<span class="col-field">customer_address_id</span> · <span class="col-field">variant_snapshot_json</span> · <span class="col-field">address_snapshot_json</span> (enc) · <span class="col-field">partner_center_id</span> <span class="tag new">NEW</span> · <span class="col-field">gross_price_irr</span> <span class="tag chg">CHG</span> · <span class="col-field">balinyaar_commission_irr</span> <span class="tag chg">CHG</span> · <span class="col-field">platform_fee_rate</span> · <span class="col-field">nurse_payout_amount</span> · <span class="col-field">psp_fee_amount</span> <span class="tag new">NEW</span> · <span class="col-field">session_count</span> <span class="tag new">NEW</span> · <span class="col-field">status</span> (guarded) · <span class="col-field">dispute_window_ends_at</span> <span class="tag new">NEW</span> · <span class="col-field">completed_at</span>. <span class="tag cut">CUT</span> <code>payout_released</code> — CHECK <code>gross = commission + payout</code></td><td>1:1 &larr; <span class="ent">booking_requests</span>; 1:N &rarr; <span class="ent">booking_sessions</span>, <span class="ent">payment_transactions</span>, <span class="ent">ledger_entries</span>; 1:1 &rarr; <span class="ent">booking_care_instructions</span>, <span class="ent">reviews</span>, <span class="ent">invoices</span>; referenced by <span class="ent">nurse_payout_booking_links</span>, <span class="ent">refunds</span>, <span class="ent">nurse_clawbacks</span>.</td></tr>
<tr><td><code>booking_sessions</code> <span class="tag mvp">MVP</span> <span class="tag new">NEW</span></td><td><span class="col-field">id</span> · <span class="col-field">booking_id</span> · <span class="col-field">session_index</span> (1-based) · <span class="col-field">scheduled_date</span>/<span class="col-field">_time_start</span>/<span class="col-field">_time_end</span> · <span class="col-field">visit_payout_amount</span> · <span class="col-field">status</span> (scheduled/in_progress/completed/missed/cancelled) · <span class="col-field">payout_eligible_at</span> · <span class="col-field">cancellation_event_id</span></td><td>N:1 &rarr; <span class="ent">bookings</span>; 1:1 &rarr; <span class="ent">visit_verifications</span>. One row per visit; a single-visit booking still gets exactly one session.</td></tr>
<tr><td><code>booking_care_instructions</code> <span class="tag core">CORE</span></td><td><span class="col-field">id</span> · <span class="col-field">booking_id</span> · current conditions · medications · allergies · special instructions · emergency contact — <strong>all (enc)</strong></td><td>1:1 &rarr; <span class="ent">bookings</span>. Visible only post-confirmation (Principle 6).</td></tr>
<tr><td><code>visit_verifications</code> <span class="tag core">CORE</span></td><td><span class="col-field">id</span> · <span class="col-field">booking_session_id</span> <span class="tag chg">CHG</span> · <span class="col-field">check_in_at</span>/<span class="col-field">check_out_at</span> · <span class="col-field">check_in_lat</span>/<span class="col-field">_lng</span> · <span class="col-field">check_out_lat</span>/<span class="col-field">_lng</span> · <span class="col-field">check_in_address_match</span> (advisory) · <span class="col-field">status</span></td><td>1:1 &rarr; <span class="ent">booking_sessions</span>. EVV per session; status maps to <code>bookings.status</code> (documented).</td></tr>
<tr><td><code>cancellation_policies</code> <span class="tag mvp">MVP</span> <span class="tag new">NEW</span></td><td><span class="col-field">id</span> · <span class="col-field">code</span> (UNIQUE, e.g. standard_24h) · <span class="col-field">applies_to</span> (customer/nurse/admin) · <span class="col-field">hours_before_start_min</span>/<span class="col-field">_max</span> · <span class="col-field">refund_percentage</span> · <span class="col-field">fee_amount_or_rate</span> · <span class="col-field">is_active</span></td><td>Referenced (snapshot) by <span class="ent">refunds</span> and the cancellation event on a session/booking.</td></tr>
</tbody>
</table>
</div>
</section>
<!-- ===== DOMAIN 6 ===== -->
<section id="dm-d6">
<h3>Domain 6 &mdash; Payments, Ledger &amp; Refunds</h3>
<p>The most-changed domain. A <strong>double-entry ledger</strong> is the source of truth (replacing inference from scattered status flags), with the <strong>idempotency</strong> and <strong>clawback</strong> primitives any real-money platform needs before launch.</p>
<div class="table-wrap">
<table class="tbl schema">
<thead><tr><th>Table</th><th>Key fields</th><th>Relationships (FK)</th></tr></thead>
<tbody>
<tr><td><code>payment_gateways</code> <span class="tag core">CORE</span></td><td><span class="col-field">id</span> · <span class="col-field">name</span> · <span class="col-field">type</span> (standard/bnpl) · <span class="col-field">config_json</span> (enc secrets: client_id/secret, merchant no, base_url, sandbox flag) · <span class="col-field">is_active</span></td><td>1:N &rarr; <span class="ent">payment_transactions</span>, <span class="ent">payment_webhook_events</span>. Abstracted for failover (Toman/Jibit cut-off precedent).</td></tr>
<tr><td><code>payment_transactions</code> <span class="tag core">CORE</span></td><td><span class="col-field">id</span> · <span class="col-field">booking_id</span> · <span class="col-field">customer_id</span> · <span class="col-field">gateway_id</span> · <span class="col-field">amount</span> · <span class="col-field">currency</span> · <span class="col-field">status</span> · <span class="col-field">gateway_transaction_id</span> · <span class="col-field">gateway_reference_code</span> (Shaparak) · <span class="col-field">gateway_response_json</span> · <span class="col-field">is_installment</span> — filtered <code>UNIQUE(gateway_reference_code)</code> + filtered <code>UNIQUE(booking_id) WHERE status='succeeded'</code></td><td>N:1 &rarr; <span class="ent">bookings</span>, <span class="ent">payment_gateways</span>; 1:1 &rarr; <span class="ent">bnpl_transactions</span> (if BNPL); 1:N &rarr; <span class="ent">refunds</span>, <span class="ent">ledger_entries</span>.</td></tr>
<tr><td><code>payment_webhook_events</code> <span class="tag core">CORE</span> <span class="tag new">NEW</span></td><td><span class="col-field">id</span> · <span class="col-field">provider_code</span> · <span class="col-field">external_event_id</span><code>UNIQUE(provider_code, external_event_id)</code> · <span class="col-field">event_type</span> · <span class="col-field">signature_valid</span> · <span class="col-field">payload_json</span> · <span class="col-field">processing_status</span> (received/processed/failed/ignored) · <span class="col-field">related_payment_transaction_id</span> · <span class="col-field">received_at</span>/<span class="col-field">processed_at</span></td><td>N:1 &rarr; <span class="ent">payment_gateways</span>; optional &rarr; <span class="ent">payment_transactions</span>. Upserted first; no-ops on duplicate.</td></tr>
<tr><td><code>refunds</code> <span class="tag core">CORE</span></td><td><span class="col-field">id</span> · <span class="col-field">payment_transaction_id</span> · <span class="col-field">booking_id</span> · <span class="col-field">requested_by_customer_id</span> · <span class="col-field">ticket_id</span> · <span class="col-field">amount</span> · <span class="col-field">refund_percentage</span> · <span class="col-field">status</span> · <span class="col-field">gateway_refund_reference</span> · <span class="col-field">platform_fee_refunded_irr</span> <span class="tag new">NEW</span> · <span class="col-field">nurse_payout_refunded_irr</span> <span class="tag new">NEW</span> · <span class="col-field">refund_channel</span> <span class="tag new">NEW</span> (psp_card/bnpl_revert/manual_bank) · <span class="col-field">external_revert_reference</span> <span class="tag new">NEW</span> · <span class="col-field">expected_customer_refund_eta</span> <span class="tag new">NEW</span> · <span class="col-field">cancellation_policy_code</span> <span class="tag new">NEW</span></td><td>N:1 (<strong>1:N</strong> per txn <span class="tag chg">CHG</span>) &rarr; <span class="ent">payment_transactions</span>, <span class="ent">bookings</span>, <span class="ent">customer_profiles</span>, <span class="ent">tickets</span>; 1:1 &rarr; <span class="ent">nurse_clawbacks</span> (only when nurse already paid). Admin-only.</td></tr>
<tr><td><code>ledger_entries</code> <span class="tag core">CORE</span> <span class="tag new">NEW</span></td><td><span class="col-field">id</span> · <span class="col-field">transaction_group_id</span> (UUID) · <span class="col-field">account_type</span> (escrow_held/platform_revenue/nurse_payable/refund_payable/bnpl_fee_expense/psp_fee_expense/nurse_clawback_receivable/bad_debt) · <span class="col-field">nurse_id</span> · <span class="col-field">direction</span> (debit/credit) · <span class="col-field">amount_irr</span> · <span class="col-field">booking_id</span> · <span class="col-field">source_ref_type</span>/<span class="col-field">source_ref_id</span> · <span class="col-field">memo</span> — append-only, balanced per group</td><td>N:1 &rarr; <span class="ent">bookings</span>; logical links to <span class="ent">payment_transactions</span>/<span class="ent">refunds</span>/<span class="ent">nurse_payouts</span>/<span class="ent">bnpl_transactions</span> via <code>source_ref_*</code>. The financial source of truth.</td></tr>
<tr><td><code>nurse_clawbacks</code> <span class="tag core">CORE</span> <span class="tag new">NEW</span></td><td><span class="col-field">id</span> · <span class="col-field">nurse_id</span> · <span class="col-field">booking_id</span> · <span class="col-field">refund_id</span> · <span class="col-field">original_payout_id</span> · <span class="col-field">amount_irr</span> · <span class="col-field">status</span> (pending/recovered/written_off) · <span class="col-field">recovered_in_payout_id</span> · <span class="col-field">resolved_at</span></td><td>N:1 &rarr; <span class="ent">nurse_profiles</span>, <span class="ent">bookings</span>; 1:1 &rarr; <span class="ent">refunds</span>; &rarr; <span class="ent">nurse_payouts</span> (original &amp; recovering).</td></tr>
<tr><td><code>invoices</code> <span class="tag mvp">MVP</span> <span class="tag new">NEW</span></td><td><span class="col-field">id</span> · <span class="col-field">booking_id</span> · <span class="col-field">invoice_number</span> (UNIQUE) · <span class="col-field">issuing_entity_type</span> (platform/partner_center) · <span class="col-field">gross_irr</span> · <span class="col-field">platform_commission_irr</span> (VAT-relevant) · <span class="col-field">bnpl_commission_irr</span> · <span class="col-field">vat_rate</span> (0.10) · <span class="col-field">vat_irr</span> · <span class="col-field">moadian_reference_number</span> · <span class="col-field">moadian_status</span> · <span class="col-field">pdf_storage_key</span></td><td>1:1 &rarr; <span class="ent">bookings</span>; N:1 &rarr; <span class="ent">partner_centers</span> (when issuer).</td></tr>
</tbody>
</table>
</div>
<div class="legend">
<span><span class="sw" style="background:var(--primary)"></span>9 tables — the financial core</span>
<span class="muted">Canonical ledger postings are in <a href="#s8">§8</a>; the lifecycle diagram is <a href="#dgm-life">Diagram 4</a>.</span>
</div>
</section>
<!-- ===== DOMAIN 7 ===== -->
<section id="dm-d7">
<h3>Domain 7 &mdash; Payouts to Nurses</h3>
<p>Weekly aggregation matching the PAYA settlement cycle, holiday-aware, with a structural anti-double-pay guard.</p>
<div class="table-wrap">
<table class="tbl schema">
<thead><tr><th>Table</th><th>Key fields</th><th>Relationships (FK)</th></tr></thead>
<tbody>
<tr><td><code>nurse_payout_batches</code> <span class="tag core">CORE</span></td><td><span class="col-field">id</span> · <span class="col-field">period_start</span>/<span class="col-field">period_end</span> (holiday-shifted) · <span class="col-field">total_amount</span> · <span class="col-field">payout_count</span> · <span class="col-field">status</span> · <span class="col-field">initiated_by_admin_id</span> · <span class="col-field">processed_at</span> · <span class="col-field">failure_notes</span> — CHECK <code>total_amount = &Sigma; payouts</code></td><td>1:N &rarr; <span class="ent">nurse_payouts</span>.</td></tr>
<tr><td><code>nurse_payouts</code> <span class="tag core">CORE</span></td><td><span class="col-field">id</span> · <span class="col-field">batch_id</span> · <span class="col-field">nurse_id</span> · <span class="col-field">bank_account_id</span> · <span class="col-field">iban_snapshot</span> (enc) · <span class="col-field">amount</span> · <span class="col-field">booking_count</span> · <span class="col-field">status</span> · <span class="col-field">transfer_reference</span> · <span class="col-field">gross_earnings_irr</span> <span class="tag new">NEW</span> · <span class="col-field">clawback_applied_irr</span> <span class="tag new">NEW</span> · <span class="col-field">net_amount_irr</span> <span class="tag new">NEW</span></td><td>N:1 &rarr; <span class="ent">nurse_payout_batches</span>, <span class="ent">nurse_profiles</span>, <span class="ent">nurse_bank_accounts</span>; 1:N &rarr; <span class="ent">nurse_payout_booking_links</span>; referenced by <span class="ent">nurse_clawbacks</span>.</td></tr>
<tr><td><code>nurse_payout_booking_links</code> <span class="tag core">CORE</span></td><td><span class="col-field">id</span> · <span class="col-field">payout_id</span> · <span class="col-field">booking_id</span> (<strong>UNIQUE</strong>) · <span class="col-field">amount_irr</span></td><td>N:1 &rarr; <span class="ent">nurse_payouts</span>; 1:1 &rarr; <span class="ent">bookings</span>. Guarantees a booking is paid in exactly one batch.</td></tr>
</tbody>
</table>
</div>
</section>
<!-- ===== DOMAIN 8 ===== -->
<section id="dm-d8">
<h3>Domain 8 &mdash; BNPL / Installments</h3>
<p>Because verified research shows Iranian provider-financed BNPL settles the full amount to the merchant in one lump, a BNPL order is &mdash; in these books &mdash; a card payment that lands net-of-fee. The old <code>installment_plans</code> + <code>installment_entries</code> subsystem (which tried to track the customer's repayment and default) is <strong>deleted</strong>.</p>
<div class="table-wrap">
<table class="tbl schema">
<thead><tr><th>Table</th><th>Key fields</th><th>Relationships (FK)</th></tr></thead>
<tbody>
<tr><td><code>bnpl_transactions</code> <span class="tag mvp">MVP</span> <span class="tag new">NEW</span></td><td><span class="col-field">id</span> · <span class="col-field">payment_transaction_id</span> (UNIQUE) · <span class="col-field">provider_code</span> (snapppay/digipay/tara/torobpay) · <span class="col-field">merchant_of_record</span> · <span class="col-field">external_payment_token</span> · <span class="col-field">external_transaction_id</span> · <span class="col-field">eligibility_status</span> · <span class="col-field">order_amount_irr</span> · <span class="col-field">settled_amount_irr</span> · <span class="col-field">bnpl_commission_irr</span> · <span class="col-field">currency</span> · <span class="col-field">installment_count</span> (info, default 4) · <span class="col-field">status</span> (eligible/token_issued/verified/settled/reverted/cancelled/failed) · <span class="col-field">settled_at</span> · <span class="col-field">revert_transaction_id</span>/<span class="col-field">reverted_amount_irr</span>/<span class="col-field">reverted_at</span> · <span class="col-field">refund_channel</span> · <span class="col-field">callback_payload_json</span></td><td>1:1 &rarr; <span class="ent">payment_transactions</span>. State-machine guard on <code>status</code> for idempotency. Replaces <span class="tag cut">installment_plans</span>; <span class="tag cut">installment_entries</span> removed.</td></tr>
<tr><td><code>bnpl_settlement_entries</code> <span class="tag def">DEFERRED</span></td><td>Modeled-but-inactive: only needed if a future provider uses <strong>tranched</strong> settlement (pays the platform over time). No mainstream Iranian provider does today.</td><td>Would be N:1 &rarr; <span class="ent">bnpl_transactions</span>. Adding it later is a purely additive migration.</td></tr>
</tbody>
</table>
</div>
</section>
<!-- ===== DOMAIN 9 ===== -->
<section id="dm-d9">
<h3>Domain 9 &mdash; Messaging (Ticket System)</h3>
<p>All post-booking communication, admin-readable, with no direct nurse&harr;customer channel &mdash; it protects vulnerable patients, creates dispute evidence, and prevents disintermediation.</p>
<div class="table-wrap">
<table class="tbl schema">
<thead><tr><th>Table</th><th>Key fields</th><th>Relationships (FK)</th></tr></thead>
<tbody>
<tr><td><code>tickets</code> <span class="tag core">CORE</span></td><td><span class="col-field">id</span> · <span class="col-field">reference_code</span> (human-facing) · <span class="col-field">booking_id</span> (opt) · <span class="col-field">subject</span> · <span class="col-field">category</span> (coordination/refund/support) · <span class="col-field">status</span> · <span class="col-field">priority</span></td><td>1:N &rarr; <span class="ent">ticket_participants</span>, <span class="ent">ticket_messages</span>; optionally &harr; <span class="ent">bookings</span>, <span class="ent">refunds</span>.</td></tr>
<tr><td><code>ticket_participants</code> <span class="tag core">CORE</span></td><td><span class="col-field">id</span> · <span class="col-field">ticket_id</span> · <span class="col-field">user_id</span> · <span class="col-field">role_in_ticket</span><code>UNIQUE(ticket_id, user_id)</code></td><td>N:1 &rarr; <span class="ent">tickets</span>, <span class="ent">users</span>.</td></tr>
<tr><td><code>ticket_messages</code> <span class="tag core">CORE</span></td><td><span class="col-field">id</span> · <span class="col-field">ticket_id</span> · <span class="col-field">sender_user_id</span> · <span class="col-field">body</span> · <span class="col-field">is_internal</span> (admin-only) · <span class="col-field">attachments_json</span></td><td>N:1 &rarr; <span class="ent">tickets</span>. <code>is_internal</code> keeps admin notes out of user view.</td></tr>
</tbody>
</table>
</div>
</section>
<!-- ===== DOMAIN 10 ===== -->
<section id="dm-d10">
<h3>Domain 10 &mdash; Reviews &amp; Patient Records</h3>
<p>One review per completed booking with recompute-on-every-transition, plus a patient-scoped longitudinal clinical history that enables continuity of care.</p>
<div class="table-wrap">
<table class="tbl schema">
<thead><tr><th>Table</th><th>Key fields</th><th>Relationships (FK)</th></tr></thead>
<tbody>
<tr><td><code>reviews</code> <span class="tag core">CORE</span></td><td><span class="col-field">id</span> · <span class="col-field">booking_id</span> (1:1) · <span class="col-field">customer_id</span> · <span class="col-field">nurse_id</span> · <span class="col-field">rating</span> (CHECK 1&ndash;5) · <span class="col-field">body</span> · <span class="col-field">status</span> (pending_moderation/published/hidden/rejected) · moderation fields</td><td>1:1 &rarr; <span class="ent">bookings</span>; N:1 &rarr; <span class="ent">customer_profiles</span>, <span class="ent">nurse_profiles</span>; 1:N &rarr; <span class="ent">review_tag_links</span>. Every transition recomputes <span class="ent">nurse_profiles</span> aggregates.</td></tr>
<tr><td><code>review_tags_master</code> <span class="tag mvp">MVP</span></td><td><span class="col-field">id</span> · <span class="col-field">name_fa</span>/<span class="col-field">name_en</span> · <span class="col-field">sentiment</span></td><td>N:N <span class="ent">reviews</span> via <span class="ent">review_tag_links</span>. Phase-2 analytics nicety.</td></tr>
<tr><td><code>review_tag_links</code> <span class="tag mvp">MVP</span></td><td><span class="col-field">id</span> · <span class="col-field">review_id</span> · <span class="col-field">tag_id</span></td><td>N:1 &rarr; <span class="ent">reviews</span>, <span class="ent">review_tags_master</span>.</td></tr>
<tr><td><code>patient_care_records</code> <span class="tag mvp">MVP</span></td><td><span class="col-field">id</span> · <span class="col-field">patient_id</span> · <span class="col-field">booking_id</span> · <span class="col-field">nurse_id</span> · clinical notes (enc) · <span class="col-field">recorded_at</span></td><td>N:1 &rarr; <span class="ent">patients</span>, <span class="ent">bookings</span>, <span class="ent">nurse_profiles</span>. <strong>Patient-scoped</strong> (not booking-scoped) longitudinal history; strict access.</td></tr>
</tbody>
</table>
</div>
</section>
<!-- ===== DOMAIN 11 ===== -->
<section id="dm-d11">
<h3>Domain 11 &mdash; Notifications</h3>
<div class="table-wrap">
<table class="tbl schema">
<thead><tr><th>Table</th><th>Key fields</th><th>Relationships (FK)</th></tr></thead>
<tbody>
<tr><td><code>notifications</code> <span class="tag core">CORE</span></td><td><span class="col-field">id</span> · <span class="col-field">user_id</span> · <span class="col-field">type</span> · <span class="col-field">title</span>/<span class="col-field">body</span> · <span class="col-field">data_json</span> (deep-link payload) · <span class="col-field">is_read</span> · <span class="col-field">read_at</span></td><td>N:1 &rarr; <span class="ent">users</span>. In-app only (no push at launch); read rows &gt; 90 days are purged.</td></tr>
<tr><td><code>support_alerts</code> <span class="tag core">CORE</span></td><td><span class="col-field">id</span> · <span class="col-field">alert_type</span> (low_rating/no_show/location_mismatch/credential_expiry/fraud_signal/payment_anomaly) · <span class="col-field">entity_type</span>/<span class="col-field">entity_id</span> · <span class="col-field">owner_admin_id</span> · <span class="col-field">status</span> · <span class="col-field">resolution_notes</span></td><td>Polymorphic (validated at app layer; consider nullable typed FKs <code>booking_id</code>/<code>review_id</code>). Staff worklist items, never shown to users.</td></tr>
</tbody>
</table>
</div>
</section>
<!-- ===== DOMAIN 12 ===== -->
<section id="dm-d12">
<h3>Domain 12 &mdash; Audit, Config &amp; Reference</h3>
<div class="table-wrap">
<table class="tbl schema">
<thead><tr><th>Table</th><th>Key fields</th><th>Relationships (FK)</th></tr></thead>
<tbody>
<tr><td><code>audit_logs</code> <span class="tag core">CORE</span></td><td><span class="col-field">id</span> · <span class="col-field">entity_type</span>/<span class="col-field">entity_id</span> · <span class="col-field">action</span> · <span class="col-field">actor_user_id</span> · <span class="col-field">changed_fields_json</span> · <span class="col-field">created_at</span></td><td>Polymorphic, append-only. Covers every sensitive transition <strong>including</strong> <span class="ent">platform_configs</span> changes. Plan month-partitioning + 2&ndash;3yr archival.</td></tr>
<tr><td><code>system_events</code> <span class="tag mvp">MVP</span></td><td><span class="col-field">id</span> · <span class="col-field">event_name</span> · <span class="col-field">user_id</span> · <span class="col-field">properties_json</span> · <span class="col-field">created_at</span></td><td>High-volume behavioral analytics; pipe to a warehouse at scale rather than the transactional DB.</td></tr>
<tr><td><code>platform_configs</code> <span class="tag core">CORE</span></td><td><span class="col-field">key</span> · <span class="col-field">value</span> · <span class="col-field">data_type</span> — keys: <code>platform_fee_rate</code>, <code>booking_payment_deadline_minutes</code>, <code>nurse_response_deadline_hours</code>, <code>nurse_payout_interval_days</code>, <code>evv_location_tolerance_meters</code>, <code>min_rating_for_support_alert</code>, <code>dispute_window_hours</code> <span class="tag new">NEW</span>, <code>vat_rate</code> <span class="tag new">NEW</span>, <code>bnpl_merchant_of_record</code> <span class="tag new">NEW</span>, <code>bnpl_provider_commission_rate</code> <span class="tag new">NEW</span>, <code>bnpl_settlement_timing</code> <span class="tag new">NEW</span>, cancellation-tier defaults</td><td>Referenced everywhere; changes audited.</td></tr>
<tr><td><code>iranian_holidays</code> <span class="tag mvp">MVP</span> <span class="tag new">NEW</span></td><td><span class="col-field">id</span> · <span class="col-field">holiday_date</span> · <span class="col-field">name_fa</span> · <span class="col-field">type</span> (official/religious/national) · <span class="col-field">is_bank_closed</span></td><td>Referenced by payout scheduling (date-shifting) and optionally pricing. PAYA/SATNA fail on closed days.</td></tr>
</tbody>
</table>
</div>
</section>
<!-- ===== DOMAIN 13 ===== -->
<section id="dm-d13">
<h3>Domain 13 &mdash; Partner Centers (launch) &amp; Future</h3>
<p><code>partner_centers</code> is the single most launch-critical addition: the legal vehicle, plausibly the merchant-of-record, and the BNPL onboarding gate. The remaining tables are modeled-but-inactive so adding them later is a pure additive migration.</p>
<div class="table-wrap">
<table class="tbl schema">
<thead><tr><th>Table</th><th>Key fields</th><th>Relationships (FK)</th></tr></thead>
<tbody>
<tr><td><code>partner_centers</code> <span class="tag mvp">MVP</span> <span class="tag new">NEW</span></td><td><span class="col-field">id</span> · <span class="col-field">name</span> · <span class="col-field">legal_entity_type</span> · <span class="col-field">moh_establishment_permit_no</span> (<span class="fa">پروانه تأسیس</span>) · <span class="col-field">technical_director_nurse_user_id</span> (<span class="fa">مسئول فنی</span>) · <span class="col-field">technical_director_license_no</span> · <span class="col-field">enamad_code</span> · <span class="col-field">settlement_iban</span> (enc) · <span class="col-field">is_merchant_of_record</span> · <span class="col-field">commission_rate</span> · <span class="col-field">admin_user_id</span> · <span class="col-field">is_active</span>/<span class="col-field">verified_at</span></td><td>1:N &rarr; <span class="ent">nurse_profiles</span> (sponsors), <span class="ent">bookings</span> (legally covered by), <span class="ent">invoices</span> (issuer). N:1 &rarr; <span class="ent">users</span> (technical director, admin).</td></tr>
<tr><td><code>organizations</code> / <code>organization_nurses</code> <span class="tag def">DEFERRED</span></td><td>The future <strong>employer</strong> model (nursing companies adding employed nurses). Kept distinct from <code>partner_centers</code> (launch licensing) to avoid conflating "sponsor for legality" with "employer."</td><td>Modeled-but-inactive; no launch table references them.</td></tr>
<tr><td><code>fraud_flags</code> <span class="tag def">DEFERRED</span></td><td>Output of a future ML fraud service. <code>support_alerts</code> (<code>fraud_signal</code> type) covers rule-based signals manually for now.</td><td>Inactive stub.</td></tr>
<tr><td><code>recurring_booking_schedules</code> <span class="tag def">DEFERRED</span></td><td>RFC-5545 recurrence for repeating care patterns. The concrete multi-day need is met by <code>booking_sessions</code>; this remains for true open-ended recurrence.</td><td>Inactive stub.</td></tr>
</tbody>
</table>
</div>
</section>
<!-- ===== RELATIONSHIP SUMMARY ===== -->
<section id="dm-rel">
<h3>Relationship summary</h3>
<p>The load-bearing relationships across the whole schema, at a glance.</p>
<div class="table-wrap">
<table class="tbl">
<thead><tr><th>Relationship</th><th>Type</th><th>Notes</th></tr></thead>
<tbody>
<tr><td><span class="ent">users</span> &rarr; <span class="ent">nurse_profiles</span> / <span class="ent">customer_profiles</span></td><td>1:1</td><td>by <code>role</code></td></tr>
<tr><td><span class="ent">partner_centers</span> &rarr; <span class="ent">nurse_profiles</span></td><td>1:N</td><td>launch sponsor <span class="tag new">NEW</span></td></tr>
<tr><td><span class="ent">customer_profiles</span> &rarr; <span class="ent">patients</span> / <span class="ent">customer_addresses</span></td><td>1:N</td><td></td></tr>
<tr><td><span class="ent">nurse_profiles</span> &rarr; <span class="ent">nurse_service_variants</span> / <span class="ent">nurse_service_areas</span> / <span class="ent">nurse_bank_accounts</span> / <span class="ent">nurse_credentials</span></td><td>1:N</td><td></td></tr>
<tr><td><span class="ent">nurse_service_variants</span> &rarr; <span class="ent">nurse_service_variant_options</span></td><td>1:N</td><td>option combination</td></tr>
<tr><td><span class="ent">nurse_profiles</span> &rarr; <span class="ent">nurse_verifications</span></td><td>1:1</td><td></td></tr>
<tr><td><span class="ent">nurse_verifications</span> &rarr; <span class="ent">verification_steps</span> &rarr; <span class="ent">verification_documents</span></td><td>1:N &rarr; 1:N</td><td></td></tr>
<tr><td><span class="ent">booking_requests</span> &rarr; <span class="ent">bookings</span></td><td>1:1</td><td>on nurse-accept + payment</td></tr>
<tr><td><span class="ent">bookings</span> &rarr; <span class="ent">booking_sessions</span></td><td>1:N</td><td><span class="tag new">NEW</span> — multi-visit engagements</td></tr>
<tr><td><span class="ent">booking_sessions</span> &rarr; <span class="ent">visit_verifications</span></td><td>1:1</td><td><span class="tag chg">CHG</span> — EVV per session</td></tr>
<tr><td><span class="ent">bookings</span> &rarr; <span class="ent">booking_care_instructions</span> / <span class="ent">reviews</span> / <span class="ent">invoices</span></td><td>1:1</td><td></td></tr>
<tr><td><span class="ent">bookings</span> &rarr; <span class="ent">payment_transactions</span></td><td>1:N</td><td>attempts</td></tr>
<tr><td><span class="ent">payment_transactions</span> &rarr; <span class="ent">bnpl_transactions</span></td><td>1:1</td><td>if BNPL (replaces installment_plans)</td></tr>
<tr><td><span class="ent">payment_transactions</span> &rarr; <span class="ent">refunds</span></td><td><strong>1:N</strong></td><td><span class="tag chg">CHG</span> — partials allowed</td></tr>
<tr><td><span class="ent">payment_gateways</span> &rarr; <span class="ent">payment_webhook_events</span></td><td>1:N</td><td><span class="tag new">NEW</span> — idempotency</td></tr>
<tr><td><span class="ent">bookings</span> / nurses &rarr; <span class="ent">ledger_entries</span></td><td>1:N</td><td><span class="tag new">NEW</span> — money source of truth</td></tr>
<tr><td><span class="ent">refunds</span> &rarr; <span class="ent">nurse_clawbacks</span></td><td>1:1 (opt)</td><td><span class="tag new">NEW</span> — refund-after-payout</td></tr>
<tr><td><span class="ent">nurse_payout_batches</span> &rarr; <span class="ent">nurse_payouts</span> &rarr; <span class="ent">nurse_payout_booking_links</span></td><td>1:N &rarr; 1:N</td><td><code>booking_id</code> UNIQUE</td></tr>
<tr><td><span class="ent">nurse_payout_booking_links</span> &rarr; <span class="ent">bookings</span></td><td>1:1</td><td>exactly one payout per booking</td></tr>
<tr><td><span class="ent">patients</span> &rarr; <span class="ent">patient_care_records</span></td><td>1:N</td><td>longitudinal history</td></tr>
<tr><td><span class="ent">tickets</span> &rarr; <span class="ent">ticket_participants</span> / <span class="ent">ticket_messages</span></td><td>1:N</td><td></td></tr>
<tr><td>Sensitive entities &rarr; <span class="ent">audit_logs</span></td><td>*:N</td><td>append-only</td></tr>
</tbody>
</table>
</div>
</section>
<!-- ===== KEY DESIGN DECISIONS ===== -->
<section id="dm-decisions">
<h3>Key design decisions (the reasoning, in one place)</h3>
<ol class="flow">
<li><strong>Escrow as a ledger state, not platform cash</strong> &mdash; because an Iranian <span class="fa">پرداخت‌یار</span> 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 provider-side settlement to <strong>verified, ownership-checked</strong> IBANs.</li>
<li><strong>A BNPL order is a net-of-fee inbound payment, full stop</strong> &mdash; the verified full-upfront settlement model means there is no customer receivable, no default risk, and no installment schedule to track. Deleting <code>installment_entries</code> removed an entire fragile subsystem and replaced it with one reconciliation row.</li>
<li><strong>Three separate money amounts</strong> 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.</li>
<li><strong>Double-entry over status flags</strong> &mdash; 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 <code>nurse_clawbacks</code> receivable fixes both and makes bank/Shaparak reconciliation possible.</li>
<li><strong>Dispute window gates payout</strong> &mdash; preferring a <em>holding period</em> over a <em>clawback</em>, because clawback against an already-paid nurse IBAN is largely unenforceable. The clawback path exists for the cases that slip through.</li>
<li><strong>Idempotency before money</strong> &mdash; <code>payment_webhook_events</code> 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).</li>
<li><strong>Multi-session engagements are the norm, not an edge case</strong> &mdash; <code>booking_sessions</code> 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.</li>
<li><strong>Partner center is launch-critical</strong> &mdash; the legal vehicle and likely merchant-of-record; without it the recommended go-to-market and the money flow are not representable.</li>
<li><strong>Verified-trust must be queryable</strong> &mdash; <code>nurse_credentials</code> turns the brand promise into renewal alerts, a real badge, and audit defensibility, surviving the future arrival of an INO/MoH API.</li>
<li><strong>Keep the configurable service EAV; cut the analytics scaffolding</strong> &mdash; the category/option model earns its complexity (admin-extensible pricing dimensions without migrations); <code>response_rate</code>/<code>profile_completion_score</code>/<code>system_events</code>-in-SQL do not, at launch.</li>
</ol>
</section>
<!-- ===== OPEN ITEMS ===== -->
<section id="dm-open">
<h3>Open items to confirm before building (not schema blockers)</h3>
<div class="callout open">
<div class="callout-title"><span class="badge-ico">?</span>Confirm at contracting / with counsel</div>
<ul>
<li><strong>BNPL provider contract:</strong> does SnappPay/Digipay permit a multi-vendor marketplace re-disbursing to many nurses as a single merchant? (Publicly undocumented.) The schema assumes one lump to Balinyaar/the center, internal allocation to nurses &mdash; an ops confirmation, not a schema dependency.</li>
<li><strong>Commission %</strong> and <strong>settlement SLA</strong> per provider (and whether the provider returns its commission on a refund &mdash; full or pro-rata).</li>
<li><strong>PSP / <span class="fa">تسهیم</span> provider</strong> for MVP (ZarinPal Multiplexing vs Vandar vs Jibit) and whether it permits hold-then-weekly-payout timing, or whether a bank-grade escrow (Vandar <span class="fa">میندو</span>) is needed.</li>
<li><strong>VAT exemption</strong> ruling on the nursing service itself (the commission line is taxable regardless) &mdash; <code>vat_rate</code> is config-driven so either ruling is a value change.</li>
<li><strong><span class="fa">مودیان</span></strong> enrollment thresholds for the platform and high-earning nurses.</li>
</ul>
<p style="margin-top:8px;">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.</p>
</div>
<a class="back-to-top" href="#intro">&uarr; Back to top</a>
</section>
<hr class="rule" />
<!-- ================= DIAGRAMS ================= -->
<section id="dgm-domain">
<span class="eyebrow">Diagrams</span>
<h2 class="section-title">Diagrams</h2>
<p class="lead">Five views of the same system, rendered with Mermaid (brand-themed). The first four are reproduced from the data-model document; the fifth is an entity-relationship overview of the core spine.</p>
<h3>1 &mdash; Domain map: how the clusters relate</h3>
<div class="diagram">
<pre class="mermaid">
flowchart LR
PARTNER["Partner Centers (launch)<br/>partner_centers"]
IDENTITY["Identity &amp; 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 &amp; Pricing<br/>service_categories · option_groups · option_values<br/>variants · variant_options · search_index · availability"]
BOOKING["Booking &amp; Scheduling<br/>booking_requests · bookings · booking_sessions<br/>care_instructions · visit_verifications · cancellation_policies"]
PAY["Payments &amp; 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 &amp; Records<br/>reviews · review_tags · patient_care_records"]
MSG["Messaging<br/>tickets · participants · messages"]
NOTIFY["Notifications<br/>notifications · support_alerts"]
AUDITCFG["Audit &amp; 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
</pre>
<div class="dgm-cap">Diagram 1 — the 13 domains and how data flows between them.</div>
</div>
</section>
<section id="dgm-spine">
<h3>2 &mdash; Core booking spine (who books whom)</h3>
<div class="diagram">
<pre class="mermaid">
erDiagram
users ||--o| nurse_profiles : "role=nurse"
users ||--o| customer_profiles : "role=customer"
partner_centers ||--o{ nurse_profiles : "sponsors"
customer_profiles ||--o{ patients : "registers"
customer_profiles ||--o{ customer_addresses : "saves"
nurse_profiles ||--o{ nurse_service_variants : "offers"
customer_profiles ||--o{ booking_requests : "submits"
nurse_profiles ||--o{ booking_requests : "receives"
patients ||--o{ booking_requests : "for patient"
nurse_service_variants ||--o{ booking_requests : "selects variant"
booking_requests ||--o| bookings : "converts on payment"
bookings ||--o{ booking_sessions : "has visits"
booking_sessions ||--o| visit_verifications : "EVV per visit"
bookings ||--o| booking_care_instructions : "clinical (encrypted)"
bookings ||--o| reviews : "one review"
booking_requests {
bigint id PK
string status
string required_caregiver_gender
datetime nurse_response_deadline_at
datetime payment_deadline_at
}
bookings {
bigint id PK
bigint gross_price_irr
bigint balinyaar_commission_irr
bigint nurse_payout_amount
smallint session_count
datetime dispute_window_ends_at
string status
}
booking_sessions {
bigint id PK
int session_index
date scheduled_date
string status
datetime payout_eligible_at
}
</pre>
<div class="dgm-cap">Diagram 2 — the request &rarr; booking &rarr; session &rarr; EVV spine, with the money split on <code>bookings</code>.</div>
</div>
</section>
<section id="dgm-pay">
<h3>3 &mdash; Payments, ledger &amp; payouts</h3>
<div class="diagram">
<pre class="mermaid">
erDiagram
bookings ||--o{ payment_transactions : "paid by (attempts)"
payment_gateways ||--o{ payment_transactions : "via"
payment_gateways ||--o{ payment_webhook_events : "emits"
payment_transactions ||--o| bnpl_transactions : "if BNPL"
payment_transactions ||--o{ refunds : "may be refunded"
refunds ||--o| nurse_clawbacks : "if after payout"
nurse_profiles ||--o{ nurse_clawbacks : "owes"
bookings ||--o{ ledger_entries : "money postings"
bookings ||--o| invoices : "billed"
nurse_payout_batches ||--o{ nurse_payouts : "groups"
nurse_profiles ||--o{ nurse_payouts : "receives"
nurse_bank_accounts ||--o{ nurse_payouts : "to IBAN"
nurse_payouts ||--o{ nurse_payout_booking_links : "covers"
bookings ||--o| nurse_payout_booking_links : "settled in one"
ledger_entries {
bigint id PK
uuid transaction_group_id
string account_type
string direction
bigint amount_irr
}
refunds {
bigint id PK
bigint platform_fee_refunded_irr
bigint nurse_payout_refunded_irr
string refund_channel
}
bnpl_transactions {
bigint id PK
string provider_code
bigint settled_amount_irr
bigint bnpl_commission_irr
string status
}
</pre>
<div class="dgm-cap">Diagram 3 — the payment attempt, the BNPL settlement, the double-entry ledger, refunds/clawbacks, and the weekly payout batch.</div>
</div>
</section>
<section id="dgm-life">
<h3>4 &mdash; Financial lifecycle: escrow &rarr; payout &rarr; clawback</h3>
<div class="diagram">
<pre class="mermaid">
flowchart TD
A["Family submits booking_request"] --> B{"Nurse responds in time?"}
B -->|"reject / expire"| X["request closed — no money moved"]
B -->|"accept"| C["30-min payment window"]
C --> D{"Payment method"}
D -->|"Card (IPG)"| E["payment_transactions = succeeded"]
D -->|"BNPL (SnappPay)"| F["bnpl_transactions = settled<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 &amp; 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"]
</pre>
<div class="dgm-cap">Diagram 4 — the money's full journey: request, capture (card or BNPL), escrow-as-ledger, EVV gating, dispute window, weekly payout, and the pre/post-payout refund branches.</div>
</div>
</section>
<section id="dgm-er">
<h3>5 &mdash; Entity-relationship overview (cross-domain)</h3>
<p>A wider ER view tying the identity, verification, services, booking, payment, payout, BNPL, messaging, review, and partner clusters together &mdash; the relationships a reader needs to navigate the whole model.</p>
<div class="diagram">
<pre class="mermaid">
erDiagram
users ||--o| nurse_profiles : "1:1"
users ||--o| customer_profiles : "1:1"
users ||--o{ user_sessions : "sessions"
users }o--o{ roles : "via user_roles"
partner_centers ||--o{ nurse_profiles : "sponsors"
partner_centers ||--o{ bookings : "covers"
partner_centers ||--o{ invoices : "issues"
nurse_profiles ||--|| nurse_verifications : "header"
nurse_verifications ||--o{ verification_steps : "steps"
verification_steps ||--o{ verification_documents : "evidence"
nurse_profiles ||--o{ nurse_credentials : "licenses"
nurse_profiles ||--o{ nurse_bank_accounts : "IBANs"
nurse_profiles ||--o{ nurse_service_variants : "offers"
nurse_profiles ||--o{ nurse_service_areas : "covers"
nurse_service_variants ||--o{ nurse_service_variant_options : "options"
nurse_service_variants ||--o{ nurse_search_index : "projected"
customer_profiles ||--o{ patients : "registers"
customer_profiles ||--o{ customer_addresses : "saves"
patients ||--o{ patient_care_records : "history"
customer_profiles ||--o{ booking_requests : "submits"
nurse_service_variants ||--o{ booking_requests : "variant"
booking_requests ||--o| bookings : "converts"
bookings ||--o{ booking_sessions : "visits"
booking_sessions ||--o| visit_verifications : "EVV"
bookings ||--o| booking_care_instructions : "clinical"
bookings ||--o| reviews : "one review"
reviews ||--o{ review_tag_links : "tags"
bookings ||--o| invoices : "billed"
bookings ||--o{ payment_transactions : "attempts"
payment_gateways ||--o{ payment_transactions : "via"
payment_gateways ||--o{ payment_webhook_events : "idempotency"
payment_transactions ||--o| bnpl_transactions : "if BNPL"
payment_transactions ||--o{ refunds : "partials"
refunds ||--o| nurse_clawbacks : "after payout"
bookings ||--o{ ledger_entries : "postings"
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 : "one payout"
bookings ||--o{ tickets : "coordination"
tickets ||--o{ ticket_participants : "who"
tickets ||--o{ ticket_messages : "thread"
refunds ||--o{ tickets : "anchored"
</pre>
<div class="dgm-cap">Diagram 5 — a comprehensive ER overview spanning identity, verification, services, booking, payments/ledger, payouts, BNPL, reviews, messaging, and partner centers.</div>
</div>
<a class="back-to-top" href="#intro">&uarr; Back to top</a>
</section>
<hr class="rule" />
<footer style="padding:8px 0 40px;color:var(--text-secondary);font-size:0.86rem;">
<p><strong style="color:var(--primary);font-family:'Space Grotesk',sans-serif;">Balinyaar</strong> &mdash; Business &amp; Data Model Handbook. Synthesized from <code>business-requirements.md</code>, <code>database-model.md</code> (Revision 2), <code>payments-and-installments.md</code>, and the market/legal/verification research report. All monetary values in IRR. This document is the product's source-of-truth narrative; business rules are decisions, not guesses &mdash; confirm decades-old regulations, provider fee/settlement specifics, and tax thresholds against current primary sources before building.</p>
</footer>
</main>
</div>
<!-- ================= MERMAID + INTERACTIVITY ================= -->
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
<script>
(function () {
"use strict";
var THEME_KEY = "balinyaar-doc-theme";
var root = document.documentElement;
// ---- Brand-themed Mermaid variables (recomputed per theme) ----
function mermaidThemeVars() {
var dark = root.getAttribute("data-theme") === "dark";
if (dark) {
return {
background: "#16302a",
primaryColor: "#16302a",
primaryTextColor: "#f3efe9",
primaryBorderColor:"#6fc0ac",
secondaryColor: "#234a41",
tertiaryColor: "#1d3b34",
lineColor: "#e6a98a",
textColor: "#f3efe9",
mainBkg: "#1d3b34",
clusterBkg: "#11221e",
clusterBorder: "#3f8a78",
nodeBorder: "#6fc0ac",
edgeLabelBackground:"#11221e",
fontFamily: '"Space Grotesk", system-ui, sans-serif'
};
}
return {
background: "#ffffff",
primaryColor: "#f3efe9",
primaryTextColor: "#15302a",
primaryBorderColor:"#1d4a40",
secondaryColor: "#fbeee6",
tertiaryColor: "#f0ece3",
lineColor: "#bf6f4d",
textColor: "#1b2521",
mainBkg: "#f6f3ed",
clusterBkg: "#f3efe9",
clusterBorder: "#1d4a40",
nodeBorder: "#1d4a40",
edgeLabelBackground:"#ffffff",
fontFamily: '"Space Grotesk", system-ui, sans-serif'
};
}
// Keep the original mermaid source so we can re-render on theme switch.
var blocks = Array.prototype.slice.call(document.querySelectorAll(".mermaid"));
blocks.forEach(function (b) { b.setAttribute("data-src", b.textContent); });
function renderMermaid() {
if (typeof window.mermaid === "undefined") return;
// restore source + clear any prior render
blocks.forEach(function (b, i) {
b.removeAttribute("data-processed");
b.innerHTML = b.getAttribute("data-src");
b.id = "mmd-" + i;
});
window.mermaid.initialize({
startOnLoad: false,
securityLevel: "loose",
theme: "base",
themeVariables: mermaidThemeVars(),
flowchart: { curve: "basis", useMaxWidth: true, htmlLabels: true },
er: { useMaxWidth: true }
});
try {
window.mermaid.run({ nodes: blocks });
} catch (e) {
// mermaid <11 fallback
if (window.mermaid.init) { window.mermaid.init(undefined, blocks); }
}
}
// ---- Theme toggle (default light) ----
function applyTheme(theme) {
root.setAttribute("data-theme", theme === "dark" ? "dark" : "light");
try { localStorage.setItem(THEME_KEY, theme); } catch (e) {}
renderMermaid();
}
(function initTheme() {
var saved = null;
try { saved = localStorage.getItem(THEME_KEY); } catch (e) {}
root.setAttribute("data-theme", saved === "dark" ? "dark" : "light");
})();
var toggle = document.getElementById("themeToggle");
if (toggle) {
toggle.addEventListener("click", function () {
var next = root.getAttribute("data-theme") === "dark" ? "light" : "dark";
applyTheme(next);
});
}
// ---- Scrollspy: highlight the active TOC link ----
var tocLinks = Array.prototype.slice.call(document.querySelectorAll(".toc a[href^='#']"));
var byId = {};
tocLinks.forEach(function (a) {
var id = a.getAttribute("href").slice(1);
byId[id] = a;
});
var observed = Object.keys(byId)
.map(function (id) { return document.getElementById(id); })
.filter(Boolean);
var current = null;
function spy() {
var best = null, bestTop = -Infinity;
var probe = 120; // px from top
observed.forEach(function (sec) {
var top = sec.getBoundingClientRect().top;
if (top <= probe && top > bestTop) { bestTop = top; best = sec; }
});
if (best && best.id !== current) {
if (current && byId[current]) byId[current].classList.remove("active");
current = best.id;
if (byId[current]) {
byId[current].classList.add("active");
// keep the active item in view within the sidebar
var el = byId[current];
var sb = document.getElementById("sidebar");
if (sb && el) {
var r = el.getBoundingClientRect(), sr = sb.getBoundingClientRect();
if (r.top < sr.top || r.bottom > sr.bottom) {
el.scrollIntoView({ block: "nearest" });
}
}
}
}
}
var ticking = false;
window.addEventListener("scroll", function () {
if (!ticking) {
window.requestAnimationFrame(function () { spy(); ticking = false; });
ticking = true;
}
}, { passive: true });
// ---- Initial render ----
function boot() { renderMermaid(); spy(); }
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot);
} else {
boot();
}
// Mermaid script may load after DOMContentLoaded; re-render on full load.
window.addEventListener("load", function () { renderMermaid(); });
})();
</script>
</body>
</html>