2697 lines
231 KiB
HTML
2697 lines
231 KiB
HTML
<!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"> · Business & 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 & 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 & Onboarding</a></li>
|
||
<li><a href="#s2"><span class="num">2</span>Nurse Verification & Credentials</a></li>
|
||
<li><a href="#s3"><span class="num">3</span>Service Catalog & Pricing</a></li>
|
||
<li><a href="#s4"><span class="num">4</span>Search & Matching</a></li>
|
||
<li><a href="#s5"><span class="num">5</span>Booking & 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 & Refunds</a></li>
|
||
<li><a href="#s8"><span class="num">8</span>Payments & 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 & Safety</a></li>
|
||
<li><a href="#s12"><span class="num">12</span>Messaging & Emergencies</a></li>
|
||
<li><a href="#s13"><span class="num">13</span>Tax, Invoicing & Legal</a></li>
|
||
<li><a href="#s14"><span class="num">14</span>Notifications & 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 & Q1/Q2</a></li>
|
||
<li><a href="#market">Market & competitors</a></li>
|
||
</ul>
|
||
|
||
<h2>The whole data model</h2>
|
||
<ul class="toc">
|
||
<li><a href="#dm-intro">Catalog overview & legend</a></li>
|
||
<li class="sub"><a href="#dm-d1">D1 · Identity & Access</a></li>
|
||
<li class="sub"><a href="#dm-d2">D2 · Geographic</a></li>
|
||
<li class="sub"><a href="#dm-d3">D3 · Services & Pricing</a></li>
|
||
<li class="sub"><a href="#dm-d4">D4 · Verification</a></li>
|
||
<li class="sub"><a href="#dm-d5">D5 · Booking & 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 & Records</a></li>
|
||
<li class="sub"><a href="#dm-d11">D11 · Notifications</a></li>
|
||
<li class="sub"><a href="#dm-d12">D12 · Audit & Config</a></li>
|
||
<li class="sub"><a href="#dm-d13">D13 · Partner Centers & 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 & 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 & 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 — from a family searching for a nurse to the weekly bank transfer that pays that nurse — 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–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 & 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 & 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 — filtered by city/district <strong>and same-gender caregiver preference</strong> — 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 — there is no direct nurse↔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>) — the Asanism-style model — 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 — 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 → licensed PSP → Shaparak settlement → bank-registered IBANs</strong>. "Escrow" is therefore an <strong>internal ledger state</strong> over funds custodied at a licensed provider/partner bank — 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 — 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 — <em>regardless of how the family paid</em>. The nurse's payout is always <code>gross_price_irr − 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> — national ID, IBAN, phone, addresses, clinical data — 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 — 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 ≥ 0, <code>end_time > 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–14 expand each stage.</p>
|
||
<ol class="flow">
|
||
<li><strong>Onboard</strong> — 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> — 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 & price services</strong> — admin defines the catalog skeleton; each nurse creates priced variants. <span class="muted">(§3)</span></li>
|
||
<li><strong>Search & match</strong> — 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 → accept → pay → confirm</strong> — request (no money) becomes a booking (payment captured), which owns N sessions. <span class="muted">(§5)</span></li>
|
||
<li><strong>Deliver with EVV</strong> — 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> — tiered, snapshotted policy; admin-only refunds decomposed across fee legs. <span class="muted">(§7)</span></li>
|
||
<li><strong>Move the money</strong> — card or BNPL capture posts a balanced ledger entry; escrow is a ledger state. <span class="muted">(§8–9)</span></li>
|
||
<li><strong>Pay the nurse</strong> — 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> — one moderated review per booking; ticket-only messaging; a minimal commission invoice with 10% VAT. <span class="muted">(§11–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 & 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 & 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 — 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↔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">✓</span>MVP</div>
|
||
<p>Phone-OTP login; customer/nurse/admin roles; customer→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">…</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 & 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 & 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 → <span class="ent">nurse_profiles</span> / <span class="ent">customer_profiles</span> by role; 1:N → <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 → <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">↑ 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 & 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 — 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) — <span style="color:var(--success)">automated</span></h5>
|
||
<p style="margin:0;">Match person ↔ <span class="fa">کد ملی</span> (national ID) ↔ phone ↔ 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↔national-id binding — <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> — 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 — 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> — 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 — 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">◊</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 — 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> — 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 — 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">✓</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">…</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 & 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 → <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 → <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> — drives renewal alerts & 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">↑ 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 & 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 — hourly / daily / 24-hour (<span class="fa">شبانهروزی</span>) shifts and multi-day packages — 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">✓</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">…</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 & 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 → <span class="ent">service_option_groups</span> (the configurable dimensions; a NULL <code>service_category_id</code> = cross-category) 1:N → <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> — 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">↑ 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 & Matching</h2>
|
||
<p class="lead">Families search by category, geography, price, and availability, sortable by rating — 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">◊</span>Design decision — a denormalized search index instead of Elasticsearch</div>
|
||
<p>The naive query joins nurse profile (verified + accepting) → variants (category/price) → variant options → service areas → 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 — 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 — 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">✓</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">…</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 & 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> — the requested constraint.</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
<a class="back-to-top" href="#intro">↑ 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 & 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 → accept → pay → 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> → <span class="pill">accepted_awaiting_payment</span>, <strong>rejects</strong> → <span class="pill">rejected_by_nurse</span>, or the deadline passes → <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 → a <code>bookings</code> row is created (<span class="pill">confirmed</span>). If the window lapses → <span class="pill">payment_deadline_expired</span>.</li>
|
||
</ol>
|
||
|
||
<div class="callout decision">
|
||
<div class="callout-title"><span class="badge-ico">◊</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> — 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">→</span><span class="st">confirmed</span><span class="arw">→</span><span class="st">in_progress</span><span class="arw">→</span><span class="st">completed</span><span class="arw">→</span><span class="st">disputed</span><span class="arw">→</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 — 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 — 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">✓</span>MVP</div>
|
||
<p>Request→accept→pay→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">…</span>Deferred</div>
|
||
<p>Open-ended recurring schedules (<code>recurring_booking_schedules</code> modeled, inactive — 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 & 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 — 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 → <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 → <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 → <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 & 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">↑ 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 — 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> — 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) — 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 — 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">✓</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">…</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 — tunable without a deploy.</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
<a class="back-to-top" href="#intro">↑ 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 & 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 > 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> — 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> — how much of the platform commission and how much of the nurse payout is being reversed — 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→customer or Balinyaar→customer directly — 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">✓</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">…</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 & 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 — 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">↑ 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 & 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 — 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> (Σ debit = Σ 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" — 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 — 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> — 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> — a platform <strong>expense</strong>, never the nurse's</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<p><code>nurse_payout_amount = gross_price_irr − balinyaar_commission_irr</code>, enforced by CHECK.</p>
|
||
|
||
<div class="callout decision">
|
||
<div class="callout-title"><span class="badge-ico">◊</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 — 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 — 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 — 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 ≠ 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">✓</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">…</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 & 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 → <span class="ent">refunds</span> / <span class="ent">ledger_entries</span>; 1:1 → <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">↑ 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> — 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 − 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 ↔ provider ↔ Balinyaar</div>
|
||
<p>Never nurse→customer or Balinyaar→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–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) — "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–15% for SnappPay; Torob Pay's published 6.6%) — 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 — 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">✓</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">…</span>Deferred</div>
|
||
<p>Customer installment tracking (<code>installment_entries</code> — <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 — 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–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">↑ 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 — 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 < 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 − balinyaar_commission_irr</code> — never from a BNPL provider's net settlement.</li>
|
||
<li><strong>Each booking is paid at most once</strong> (the payout↔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">◊</span>Design decision — clawbacks & 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 — 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) — 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">✓</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↔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">…</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 = Σ payouts</code>) 1:N → <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 → <span class="ent">nurse_payout_booking_links</span> with <code>booking_id</code> <strong>UNIQUE</strong> — 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 & 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">↑ 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 & Safety</h2>
|
||
<p class="lead">One review per completed booking, moderated before it goes public, with aggregates recomputed on <em>every</em> transition — because the buyers are vulnerable people cared for unobserved, and a single incident can destroy a fragile, trust-first brand.</p>
|
||
|
||
<h3>How reviews & safety work</h3>
|
||
<ul>
|
||
<li>A customer can leave <strong>one review per completed booking</strong> (rating 1–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 — publish, <strong>hide</strong>, reject, unpublish — 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 ≤ 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 — 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× elsewhere, mostly impersonation).</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="grid2">
|
||
<div class="callout mvp" style="margin:0;">
|
||
<div class="callout-title"><span class="badge-ico">✓</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">…</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 & 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–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">↑ 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 & On-Site Emergencies</h2>
|
||
<p class="lead">There is <strong>no live chat and no direct nurse↔customer messaging</strong>. All post-booking communication runs through a structured ticket system that admin can read in full — 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 — 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">◊</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 — 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">✓</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">…</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 & 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 → <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">↑ 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 & 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 — 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) — 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">◊</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 + ≥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 — 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> — 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 — 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">✓</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">…</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 & 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 → <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">↑ 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 & 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 — 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">✓</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">…</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 & 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 > 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">↑ 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 — 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 + ≥5 years clinical experience (can be both founder & technical director)</td><td><strong>Both founder & technical director must be physicians</strong></td></tr>
|
||
<tr><td>Fit</td><td>✓ 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> — 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">◊</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 — 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–1405) registered-company count and the present status of the <span class="fa">سخت و زیانآور</span> (arduous-work) labor-law gap — 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 — 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">↑ 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 — 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 ↔ <span class="fa">کد ملی</span> ↔ phone ↔ 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">◊</span>Practical rules</div>
|
||
<ol style="margin:0;">
|
||
<li><strong>Buy identity verification</strong> through one KYC provider — 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> — 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">↑ 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 — 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 & 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 — the platform never touches the split.</li>
|
||
<li><strong>The banned move:</strong> "collect into a platform pool, hold until EVV, then redistribute" — 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">◊</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 — 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">↑ 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 — 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) — 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–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–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 ↔ provider ↔ Balinyaar</code>.</strong> Never refund the customer directly; never route a nurse→customer refund. Balinyaar initiates the reversal through the provider's API using the stored token:</p>
|
||
<ul>
|
||
<li><strong>Full cancel/refund → <code>revert</code></strong> (full amount).</li>
|
||
<li><strong>Partial / shortened-visit → <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–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–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 — 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 — 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&L on cancellations. Model <code>provider_commission_reversed_amount</code> as nullable and reconcile from the provider's refund response — 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 — 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 − 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 − 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 → eligible → payment token (redirect) → verify → settle → 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 — 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">↑ 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 & competitors</h2>
|
||
<p class="lead">The market is real and already competitive — 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–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 — <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 — 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 & safety</div>
|
||
<p>Connecting strangers to vulnerable people without rigorous platform-owned vetting enables theft, abuse, and fraud — 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 & classification</div>
|
||
<p>Worker misclassification ($10M TLC judgment), vicarious liability / negligent hiring, and insurance gaps stack. <strong>The dangerous middle — heavy control for "quality" but contractor classification for cost — 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 & 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 — 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 & fraud</div>
|
||
<p>Gig-marketplace fraud runs ~2× elsewhere (>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">↑ 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: −2 cut (<code>installment_plans</code> replaced, <code>installment_entries</code> removed), +10 added, 1 replaced — 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 & 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 & 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 & 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 & 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 & 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 & Config (4)</span>
|
||
<span class="muted" style="font-size:0.86rem;"><a href="#dm-d13">D13</a> Partner & Future (5)</span>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ===== DOMAIN 1 ===== -->
|
||
<section id="dm-d1">
|
||
<h3>Domain 1 — Identity & 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 → <span class="ent">nurse_profiles</span> / <span class="ent">customer_profiles</span> (by role); 1:N → <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 → <span class="ent">users</span>. Enables logout-everywhere & 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 → <span class="ent">users</span>, <span class="ent">nurse_verifications</span>; 1:N → <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 → <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 → <span class="ent">users</span>; 1:N → <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 → <span class="ent">customer_profiles</span>; 1:N → <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 → <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 → <span class="ent">nurse_profiles</span>; 1:N → <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 — 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 → <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 → <span class="ent">provinces</span>; 1:N → <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 → <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 → <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 — Services & Pricing</h3>
|
||
<p>Three admin layers (category → option group → option value) + two nurse layers (variant → 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 → <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 → <span class="ent">service_categories</span>; 1:N → <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 → <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 → <span class="ent">nurse_profiles</span>, <span class="ent">service_categories</span>; 1:N → <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 → <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 > start_time</code></td><td>N:1 → <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 → <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 — Verification & 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 → <span class="ent">nurse_profiles</span>; 1:N → <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 → <span class="ent">verification_steps</span>. Admin catalog — 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 → <span class="ent">nurse_verifications</span>, <span class="ent">verification_step_types</span>; 1:N → <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 → <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 → <span class="ent">nurse_profiles</span>. Cross-referenced by the relevant <span class="ent">verification_steps</span>. Powers renewal alerts & the trust badge.</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
<!-- ===== DOMAIN 5 ===== -->
|
||
<section id="dm-d5">
|
||
<h3>Domain 5 — Booking & Scheduling</h3>
|
||
<p>Two distinct phases — the request phase (pre-payment intent) and the booking phase (post-payment commitment). The previous model's biggest gap — single-visit-only bookings — 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 → <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 → <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 ← <span class="ent">booking_requests</span>; 1:N → <span class="ent">booking_sessions</span>, <span class="ent">payment_transactions</span>, <span class="ent">ledger_entries</span>; 1:1 → <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 → <span class="ent">bookings</span>; 1:1 → <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 → <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 → <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 — Payments, Ledger & 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 → <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 → <span class="ent">bookings</span>, <span class="ent">payment_gateways</span>; 1:1 → <span class="ent">bnpl_transactions</span> (if BNPL); 1:N → <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 → <span class="ent">payment_gateways</span>; optional → <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>) → <span class="ent">payment_transactions</span>, <span class="ent">bookings</span>, <span class="ent">customer_profiles</span>, <span class="ent">tickets</span>; 1:1 → <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 → <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 → <span class="ent">nurse_profiles</span>, <span class="ent">bookings</span>; 1:1 → <span class="ent">refunds</span>; → <span class="ent">nurse_payouts</span> (original & 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 → <span class="ent">bookings</span>; N:1 → <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 — 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 = Σ payouts</code></td><td>1:N → <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 → <span class="ent">nurse_payout_batches</span>, <span class="ent">nurse_profiles</span>, <span class="ent">nurse_bank_accounts</span>; 1:N → <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 → <span class="ent">nurse_payouts</span>; 1:1 → <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 — 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 — in these books — 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 → <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 → <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 — Messaging (Ticket System)</h3>
|
||
<p>All post-booking communication, admin-readable, with no direct nurse↔customer channel — 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 → <span class="ent">ticket_participants</span>, <span class="ent">ticket_messages</span>; optionally ↔ <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 → <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 → <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 — Reviews & 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–5) · <span class="col-field">body</span> · <span class="col-field">status</span> (pending_moderation/published/hidden/rejected) · moderation fields</td><td>1:1 → <span class="ent">bookings</span>; N:1 → <span class="ent">customer_profiles</span>, <span class="ent">nurse_profiles</span>; 1:N → <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 → <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 → <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 — 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 → <span class="ent">users</span>. In-app only (no push at launch); read rows > 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 — Audit, Config & 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–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 — Partner Centers (launch) & 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 → <span class="ent">nurse_profiles</span> (sponsors), <span class="ent">bookings</span> (legally covered by), <span class="ent">invoices</span> (issuer). N:1 → <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> → <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> → <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> → <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> → <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> → <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> → <span class="ent">nurse_verifications</span></td><td>1:1</td><td></td></tr>
|
||
<tr><td><span class="ent">nurse_verifications</span> → <span class="ent">verification_steps</span> → <span class="ent">verification_documents</span></td><td>1:N → 1:N</td><td></td></tr>
|
||
<tr><td><span class="ent">booking_requests</span> → <span class="ent">bookings</span></td><td>1:1</td><td>on nurse-accept + payment</td></tr>
|
||
<tr><td><span class="ent">bookings</span> → <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> → <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> → <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> → <span class="ent">payment_transactions</span></td><td>1:N</td><td>attempts</td></tr>
|
||
<tr><td><span class="ent">payment_transactions</span> → <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> → <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> → <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 → <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> → <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> → <span class="ent">nurse_payouts</span> → <span class="ent">nurse_payout_booking_links</span></td><td>1:N → 1:N</td><td><code>booking_id</code> UNIQUE</td></tr>
|
||
<tr><td><span class="ent">nurse_payout_booking_links</span> → <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> → <span class="ent">patient_care_records</span></td><td>1:N</td><td>longitudinal history</td></tr>
|
||
<tr><td><span class="ent">tickets</span> → <span class="ent">ticket_participants</span> / <span class="ent">ticket_messages</span></td><td>1:N</td><td></td></tr>
|
||
<tr><td>Sensitive entities → <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> — 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> — 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> — 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> — 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> — <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> — <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> — 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> — <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> — 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 — 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 — 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) — <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">↑ 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 — Domain map: how the clusters relate</h3>
|
||
<div class="diagram">
|
||
<pre class="mermaid">
|
||
flowchart LR
|
||
PARTNER["Partner Centers (launch)<br/>partner_centers"]
|
||
IDENTITY["Identity & Access<br/>users · nurse_profiles · customer_profiles<br/>patients · customer_addresses · nurse_bank_accounts"]
|
||
GEO["Geography<br/>provinces · cities · districts · nurse_service_areas"]
|
||
VERIFY["Verification<br/>nurse_verifications · step_types · steps<br/>documents · nurse_credentials"]
|
||
SERVICES["Services & Pricing<br/>service_categories · option_groups · option_values<br/>variants · variant_options · search_index · availability"]
|
||
BOOKING["Booking & Scheduling<br/>booking_requests · bookings · booking_sessions<br/>care_instructions · visit_verifications · cancellation_policies"]
|
||
PAY["Payments & Ledger<br/>payment_gateways · payment_transactions · webhook_events<br/>refunds · ledger_entries · nurse_clawbacks · invoices"]
|
||
BNPL["BNPL<br/>bnpl_transactions"]
|
||
PAYOUT["Payouts<br/>payout_batches · payouts · booking_links"]
|
||
REVIEW["Reviews & Records<br/>reviews · review_tags · patient_care_records"]
|
||
MSG["Messaging<br/>tickets · participants · messages"]
|
||
NOTIFY["Notifications<br/>notifications · support_alerts"]
|
||
AUDITCFG["Audit & Config<br/>audit_logs · system_events<br/>platform_configs · iranian_holidays"]
|
||
|
||
PARTNER -. "sponsors / merchant-of-record" .-> VERIFY
|
||
IDENTITY --> VERIFY
|
||
VERIFY --> SERVICES
|
||
SERVICES --> GEO
|
||
IDENTITY --> BOOKING
|
||
SERVICES --> BOOKING
|
||
BOOKING --> PAY
|
||
PAY --> BNPL
|
||
PAY --> PAYOUT
|
||
BOOKING --> REVIEW
|
||
BOOKING --> MSG
|
||
PAY --> NOTIFY
|
||
PAY --> AUDITCFG
|
||
</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 — 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 → booking → session → EVV spine, with the money split on <code>bookings</code>.</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="dgm-pay">
|
||
<h3>3 — Payments, ledger & 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 — Financial lifecycle: escrow → payout → 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 & 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 — 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 — 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">↑ 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> — Business & 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 — 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>
|