Files
zarinpal-analytics-backend/docs/PHYSICAL-SCHEMA-ERD.html

210 lines
18 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ZarinPal Analytics — Physical Schema ERD</title>
<style>
:root {
color-scheme: light;
--paper: #f1f1f3;
--surface: #ffffff;
--ink: #19191a;
--muted: #6b6b73;
--soft: #8c8c94;
--rule: #d8d8dd;
--accent: #0a33ff;
--accent-tint: #e7ecff;
--yellow: #ffd400;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font-family: Tahoma, system-ui, sans-serif;
}
main { width: min(100% - 32px, 1320px); margin: 0 auto; padding: 48px 0 64px; }
header { max-width: 880px; margin-bottom: 40px; }
.eyebrow { margin: 0 0 8px; color: var(--accent); font: 700 12px/1.5 ui-monospace, monospace; letter-spacing: .12em; }
h1 { margin: 0; font-size: clamp(28px, 4vw, 40px); line-height: 1.2; }
.lede { margin: 16px 0 0; color: var(--muted); font-size: 16px; line-height: 1.7; }
figure { margin: 0 0 32px; padding: 24px; overflow-x: auto; background: var(--surface); border: 1px solid var(--rule); border-radius: 12px; }
figcaption { margin: 0 0 20px; font-size: 20px; font-weight: 700; }
svg { display: block; min-width: 1120px; width: 100%; height: auto; }
.edge { fill: none; stroke: var(--muted); stroke-width: 1.2; }
.edge-accent { stroke: var(--accent); }
.entity-mask { fill: var(--surface); }
.entity { fill: var(--surface); stroke: var(--ink); stroke-width: 1; }
.entity.focal { fill: var(--accent-tint); stroke: var(--accent); stroke-width: 1.2; }
.tag { fill: var(--yellow); }
.tag-text { fill: var(--ink); font: 700 8px/1 ui-monospace, monospace; letter-spacing: .08em; }
.name { fill: var(--ink); font: 700 12px/1 Tahoma, system-ui, sans-serif; }
.field { fill: var(--muted); font: 400 8px/1.2 ui-monospace, monospace; }
.cardinality { fill: var(--soft); font: 700 8px/1 ui-monospace, monospace; }
.divider { stroke: var(--rule); stroke-width: .8; }
.legend { display: flex; flex-wrap: wrap; gap: 12px 24px; margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--rule); color: var(--muted); font: 12px/1.5 ui-monospace, monospace; }
.legend b { color: var(--ink); }
.note { margin-top: 24px; color: var(--muted); font-size: 13px; line-height: 1.7; }
@media (prefers-color-scheme: dark) {
:root { --paper: #0f0f10; --surface: #19191a; --ink: #ffffff; --muted: #d1d1d6; --soft: #a7a7af; --rule: #3a3a3d; --accent: #90aef4; --accent-tint: #282f42; }
.tag-text { fill: #19191a; }
}
</style>
</head>
<body>
<main>
<header>
<p class="eyebrow">DATA FOUNDATION / PHYSICAL ERD</p>
<h1>ZarinPal merchant analytics schema</h1>
<p class="lede">Two focused views keep the normalized payment model and operational state readable. The written physical-schema specification remains authoritative for every column, constraint, index, and lifecycle rule.</p>
</header>
<figure>
<figcaption>Payment facts and reference ownership</figcaption>
<svg viewBox="0 0 1280 640" role="img" aria-labelledby="payment-erd-title payment-erd-desc">
<title id="payment-erd-title">Payment facts physical entity relationships</title>
<desc id="payment-erd-desc">Merchant categories classify merchants, merchants own terminals, terminals own payment sessions, sessions contain payment attempts, and attempts reference PSP and issuer-bank codes. Bootstrap sessions reference an import run.</desc>
<path class="edge" d="M220 128 H280" />
<text class="cardinality" x="228" y="120">1</text><text class="cardinality" x="264" y="120">N</text>
<path class="edge" d="M460 128 H520" />
<text class="cardinality" x="468" y="120">1</text><text class="cardinality" x="504" y="120">N</text>
<path class="edge edge-accent" d="M700 128 H760" />
<text class="cardinality" x="708" y="120">1</text><text class="cardinality" x="744" y="120">N</text>
<path class="edge edge-accent" d="M960 144 H1020" />
<text class="cardinality" x="968" y="136">1</text><text class="cardinality" x="1004" y="136">N</text>
<path class="edge" d="M860 352 V272" />
<text class="cardinality" x="872" y="340">1</text><text class="cardinality" x="872" y="288">N</text>
<path class="edge" d="M1080 288 V376 H1016 V416" />
<text class="cardinality" x="1092" y="304">N</text><text class="cardinality" x="1028" y="404">1</text>
<path class="edge" d="M1140 288 V376 H1220 V416" />
<text class="cardinality" x="1152" y="304">N</text><text class="cardinality" x="1232" y="404">0..1</text>
<g transform="translate(40 80)">
<rect class="entity-mask" width="180" height="112" rx="8" /><rect class="entity" width="180" height="112" rx="8" />
<rect class="tag" x="12" y="12" width="48" height="16" rx="4" /><text class="tag-text" x="36" y="23" text-anchor="middle">ENTITY</text>
<text class="name" x="12" y="48">merchant_categories</text><line class="divider" x1="12" y1="60" x2="168" y2="60" />
<text class="field" x="12" y="80"># id bigint</text><text class="field" x="12" y="96">source_category_key UK</text>
</g>
<g transform="translate(280 72)">
<rect class="entity-mask" width="180" height="128" rx="8" /><rect class="entity" width="180" height="128" rx="8" />
<rect class="tag" x="12" y="12" width="48" height="16" rx="4" /><text class="tag-text" x="36" y="23" text-anchor="middle">ENTITY</text>
<text class="name" x="12" y="48">merchants</text><line class="divider" x1="12" y1="60" x2="168" y2="60" />
<text class="field" x="12" y="80"># id bigint</text><text class="field" x="12" y="96">→ category_id</text><text class="field" x="12" y="112">source_merchant_key UK</text>
</g>
<g transform="translate(520 64)">
<rect class="entity-mask" width="180" height="144" rx="8" /><rect class="entity" width="180" height="144" rx="8" />
<rect class="tag" x="12" y="12" width="48" height="16" rx="4" /><text class="tag-text" x="36" y="23" text-anchor="middle">ENTITY</text>
<text class="name" x="12" y="48">terminals</text><line class="divider" x1="12" y1="60" x2="168" y2="60" />
<text class="field" x="12" y="80"># id bigint</text><text class="field" x="12" y="96">→ merchant_id</text><text class="field" x="12" y="112">source_terminal_key UK</text><text class="field" x="12" y="128">verification_type</text>
</g>
<g transform="translate(760 48)">
<rect class="entity-mask" width="200" height="224" rx="8" /><rect class="entity focal" width="200" height="224" rx="8" />
<rect class="tag" x="12" y="12" width="48" height="16" rx="4" /><text class="tag-text" x="36" y="23" text-anchor="middle">ROOT</text>
<text class="name" x="12" y="48">payment_sessions</text><line class="divider" x1="12" y1="60" x2="188" y2="60" />
<text class="field" x="12" y="80"># id bigint</text><text class="field" x="12" y="96">→ merchant_id</text><text class="field" x="12" y="112">→ terminal_id</text><text class="field" x="12" y="128">source_session_key UK</text><text class="field" x="12" y="144">amount_rial</text><text class="field" x="12" y="160">fee_amount_rial</text><text class="field" x="12" y="176">session_status</text><text class="field" x="12" y="192">created_at / expires_at</text><text class="field" x="12" y="208">→ import_run_id nullable</text>
</g>
<g transform="translate(1020 32)">
<rect class="entity-mask" width="220" height="256" rx="8" /><rect class="entity" width="220" height="256" rx="8" />
<rect class="tag" x="12" y="12" width="48" height="16" rx="4" /><text class="tag-text" x="36" y="23" text-anchor="middle">ENTITY</text>
<text class="name" x="12" y="48">payment_attempts</text><line class="divider" x1="12" y1="60" x2="208" y2="60" />
<text class="field" x="12" y="80"># id bigint</text><text class="field" x="12" y="96">→ payment_session_id</text><text class="field" x="12" y="112">try_seq UK per session</text><text class="field" x="12" y="128">try_status</text><text class="field" x="12" y="144">→ psp_id</text><text class="field" x="12" y="160">switch_response_code?</text><text class="field" x="12" y="176">→ issuer_bank_id?</text><text class="field" x="12" y="192">payer_card_key?</text><text class="field" x="12" y="208">init/verify_time_ms?</text><text class="field" x="12" y="224">try_created_at</text><text class="field" x="12" y="240">verified/settled_at?</text>
</g>
<g transform="translate(760 352)">
<rect class="entity-mask" width="200" height="128" rx="8" /><rect class="entity" width="200" height="128" rx="8" />
<rect class="tag" x="12" y="12" width="48" height="16" rx="4" /><text class="tag-text" x="36" y="23" text-anchor="middle">ENTITY</text>
<text class="name" x="12" y="48">import_runs</text><line class="divider" x1="12" y1="60" x2="188" y2="60" />
<text class="field" x="12" y="80"># id bigint</text><text class="field" x="12" y="96">source_sha256 + versions</text><text class="field" x="12" y="112">status + reconciliation</text>
</g>
<g transform="translate(916 416)">
<rect class="entity-mask" width="200" height="112" rx="8" /><rect class="entity" width="200" height="112" rx="8" />
<rect class="tag" x="12" y="12" width="48" height="16" rx="4" /><text class="tag-text" x="36" y="23" text-anchor="middle">REF</text>
<text class="name" x="12" y="48">psps</text><line class="divider" x1="12" y1="60" x2="188" y2="60" />
<text class="field" x="12" y="80"># id bigint</text><text class="field" x="12" y="96">source_psp_code UK</text>
</g>
<g transform="translate(1120 416)">
<rect class="entity-mask" width="140" height="112" rx="8" /><rect class="entity" width="140" height="112" rx="8" />
<rect class="tag" x="12" y="12" width="40" height="16" rx="4" /><text class="tag-text" x="32" y="23" text-anchor="middle">REF</text>
<text class="name" x="12" y="48">issuer_banks</text><line class="divider" x1="12" y1="60" x2="128" y2="60" />
<text class="field" x="12" y="80"># id</text><text class="field" x="12" y="96">source_bank_code UK</text>
</g>
</svg>
<div class="legend"><span><b>#</b> primary key</span><span><b>→</b> foreign key</span><span><b>UK</b> unique key</span><span><b>?</b> nullable</span><span><b>Blue</b> payment aggregate root</span></div>
</figure>
<figure>
<figcaption>Identity, audit, reporting calendar, and cache revisions</figcaption>
<svg viewBox="0 0 1280 600" role="img" aria-labelledby="ops-erd-title ops-erd-desc">
<title id="ops-erd-title">Operational physical entity relationships</title>
<desc id="ops-erd-desc">Merchants own demo users and merchant analytics revisions; users own server-side sessions, 30-day authentication audit events, and 90-day analytics access audit events; categories own cohort revisions; the reporting calendar remains an independent date dimension.</desc>
<path class="edge edge-accent" d="M240 128 H320" /><text class="cardinality" x="248" y="120">1</text><text class="cardinality" x="304" y="120">N</text>
<path class="edge" d="M520 112 H600" /><text class="cardinality" x="528" y="104">1</text><text class="cardinality" x="584" y="104">N</text>
<path class="edge" d="M420 208 V320" /><text class="cardinality" x="432" y="224">1</text><text class="cardinality" x="432" y="308">N</text>
<path class="edge" d="M520 176 H560 V256 H1010 V224" /><text class="cardinality" x="528" y="168">1</text><text class="cardinality" x="1022" y="240">N</text>
<path class="edge" d="M140 192 V320" /><text class="cardinality" x="152" y="208">1</text><text class="cardinality" x="152" y="308">1</text>
<path class="edge" d="M820 384 H900" /><text class="cardinality" x="828" y="376">1</text><text class="cardinality" x="884" y="376">N</text>
<g transform="translate(40 64)">
<rect class="entity-mask" width="200" height="128" rx="8" /><rect class="entity focal" width="200" height="128" rx="8" />
<rect class="tag" x="12" y="12" width="48" height="16" rx="4" /><text class="tag-text" x="36" y="23" text-anchor="middle">ROOT</text>
<text class="name" x="12" y="48">merchants</text><line class="divider" x1="12" y1="60" x2="188" y2="60" />
<text class="field" x="12" y="80"># id bigint</text><text class="field" x="12" y="96">→ category_id</text><text class="field" x="12" y="112">source_merchant_key UK</text>
</g>
<g transform="translate(320 48)">
<rect class="entity-mask" width="200" height="160" rx="8" /><rect class="entity" width="200" height="160" rx="8" />
<rect class="tag" x="12" y="12" width="48" height="16" rx="4" /><text class="tag-text" x="36" y="23" text-anchor="middle">ENTITY</text>
<text class="name" x="12" y="48">merchant_users</text><line class="divider" x1="12" y1="60" x2="188" y2="60" />
<text class="field" x="12" y="80"># id bigint</text><text class="field" x="12" y="96">→ merchant_id</text><text class="field" x="12" y="112">username_normalized UK</text><text class="field" x="12" y="128">password_hash</text><text class="field" x="12" y="144">disabled_at?</text>
</g>
<g transform="translate(600 32)">
<rect class="entity-mask" width="220" height="176" rx="8" /><rect class="entity" width="220" height="176" rx="8" />
<rect class="tag" x="12" y="12" width="48" height="16" rx="4" /><text class="tag-text" x="36" y="23" text-anchor="middle">ENTITY</text>
<text class="name" x="12" y="48">auth_sessions</text><line class="divider" x1="12" y1="60" x2="208" y2="60" />
<text class="field" x="12" y="80"># id bigint</text><text class="field" x="12" y="96">→ merchant_user_id</text><text class="field" x="12" y="112">token_digest bytea UK</text><text class="field" x="12" y="128">idle_expires_at</text><text class="field" x="12" y="144">absolute_expires_at</text><text class="field" x="12" y="160">revoked_at?</text>
</g>
<g transform="translate(320 320)">
<rect class="entity-mask" width="220" height="160" rx="8" /><rect class="entity" width="220" height="160" rx="8" />
<rect class="tag" x="12" y="12" width="48" height="16" rx="4" /><text class="tag-text" x="36" y="23" text-anchor="middle">ENTITY</text>
<text class="name" x="12" y="48">auth_audit_events</text><line class="divider" x1="12" y1="60" x2="208" y2="60" />
<text class="field" x="12" y="80"># id bigint</text><text class="field" x="12" y="96">→ merchant_user_id?</text><text class="field" x="12" y="112">event_type / outcome</text><text class="field" x="12" y="128">occurred_at</text><text class="field" x="12" y="144">expires_at (30 days)</text>
</g>
<g transform="translate(900 48)">
<rect class="entity-mask" width="220" height="176" rx="8" /><rect class="entity" width="220" height="176" rx="8" />
<rect class="tag" x="12" y="12" width="48" height="16" rx="4" /><text class="tag-text" x="36" y="23" text-anchor="middle">ENTITY</text>
<text class="name" x="12" y="48">analytics_access_audit_events</text><line class="divider" x1="12" y1="60" x2="208" y2="60" />
<text class="field" x="12" y="80"># id bigint</text><text class="field" x="12" y="96">→ merchant_user_id + merchant_id</text><text class="field" x="12" y="112">event_type / result</text><text class="field" x="12" y="128">filter_fingerprint?</text><text class="field" x="12" y="144">row_count? / byte_count?</text><text class="field" x="12" y="160">expires_at (90 days)</text>
</g>
<g transform="translate(40 320)">
<rect class="entity-mask" width="200" height="128" rx="8" /><rect class="entity" width="200" height="128" rx="8" />
<rect class="tag" x="12" y="12" width="48" height="16" rx="4" /><text class="tag-text" x="36" y="23" text-anchor="middle">STATE</text>
<text class="name" x="12" y="48">merchant_analytics_revisions</text><line class="divider" x1="12" y1="60" x2="188" y2="60" />
<text class="field" x="12" y="80"># → merchant_id</text><text class="field" x="12" y="96">source_revision</text><text class="field" x="12" y="112">updated_at</text>
</g>
<g transform="translate(600 320)">
<rect class="entity-mask" width="220" height="128" rx="8" /><rect class="entity" width="220" height="128" rx="8" />
<rect class="tag" x="12" y="12" width="48" height="16" rx="4" /><text class="tag-text" x="36" y="23" text-anchor="middle">ROOT</text>
<text class="name" x="12" y="48">merchant_categories</text><line class="divider" x1="12" y1="60" x2="208" y2="60" />
<text class="field" x="12" y="80"># id bigint</text><text class="field" x="12" y="96">source_category_key UK</text><text class="field" x="12" y="112">title_fa</text>
</g>
<g transform="translate(900 320)">
<rect class="entity-mask" width="220" height="128" rx="8" /><rect class="entity" width="220" height="128" rx="8" />
<rect class="tag" x="12" y="12" width="48" height="16" rx="4" /><text class="tag-text" x="36" y="23" text-anchor="middle">STATE</text>
<text class="name" x="12" y="48">cohort_analytics_revisions</text><line class="divider" x1="12" y1="60" x2="208" y2="60" />
<text class="field" x="12" y="80"># → category_id</text><text class="field" x="12" y="96">source_revision</text><text class="field" x="12" y="112">updated_at</text>
</g>
<g transform="translate(900 480)">
<rect class="entity-mask" width="220" height="96" rx="8" /><rect class="entity" width="220" height="96" rx="8" />
<rect class="tag" x="12" y="12" width="48" height="16" rx="4" /><text class="tag-text" x="36" y="23" text-anchor="middle">DIM</text>
<text class="name" x="12" y="48">calendar_dates</text><line class="divider" x1="12" y1="60" x2="208" y2="60" /><text class="field" x="12" y="80"># gregorian_date + Jalali periods</text>
</g>
</svg>
<div class="legend"><span><b>Identity</b> is demo-sized and replaceable by SSO</span><span><b>Revision</b> rows select cache namespaces</span><span><b>Calendar</b> contains no holiday dimension</span></div>
</figure>
<p class="note">Typography note: this standalone backend document uses a system fallback to remain self-contained. The product UI uses the licensed Git-tracked Iran Yekan X FaNum variable webfont. Diagram palette follows the approved ZarinPal-inspired light/dark semantic tokens.</p>
</main>
</body>
</html>