Files
baya-monorepo/dev/post-phase/server/post-phase-backend-plan.html
T
2026-07-10 11:49:55 +03:30

182 lines
64 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Post-development backend plan — fixes &amp; improvements</title>
<style>
:root{
--bg:#faf9f6; --fg:#26221c; --muted:#6d675e; --panel:#ffffff; --border:#ddd7cc;
--accent:#0e7a63; --accent-soft:#e4f2ee; --code-bg:#f1ede5; --th-bg:#efeadf;
--warn:#a04b12; color-scheme: light dark;
}
@media (prefers-color-scheme: dark){
:root{
--bg:#191714; --fg:#e8e3da; --muted:#a29a8d; --panel:#211e1a; --border:#3a352d;
--accent:#4fc3a8; --accent-soft:#1e3630; --code-bg:#2a261f; --th-bg:#2d2921;
--warn:#e09355;
}
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);
font:16px/1.62 ui-sans-serif,system-ui,"Segoe UI",Roboto,"Vazirmatn",sans-serif;}
main{max-width:72rem;margin:0 auto;padding:2.5rem 1.5rem 5rem;}
h1{font-size:1.75rem;line-height:1.25;margin:.2rem 0 1rem;}
h2{font-size:1.35rem;margin:2.4rem 0 .7rem;padding-top:1rem;border-top:1px solid var(--border);}
h3{font-size:1.08rem;margin:1.8rem 0 .5rem;color:var(--accent);}
p{margin:.6rem 0;}
a{color:var(--accent);text-decoration:none;} a:hover{text-decoration:underline;}
code{background:var(--code-bg);border-radius:4px;padding:.1em .35em;
font:.86em ui-monospace,"Cascadia Code",Consolas,monospace;overflow-wrap:anywhere;}
pre{background:var(--code-bg);border:1px solid var(--border);border-radius:8px;
padding: .9rem 1rem;overflow-x:auto;}
pre code{background:none;padding:0;}
hr{border:none;border-top:1px solid var(--border);margin:2rem 0;}
.tblwrap{overflow-x:auto;margin:1rem 0;border:1px solid var(--border);border-radius:8px;}
table{border-collapse:collapse;width:100%;font-size:.92rem;}
th{background:var(--th-bg);text-align:start;position:sticky;top:0;}
th,td{border-bottom:1px solid var(--border);padding:.5rem .7rem;vertical-align:top;}
td:not(:last-child),th:not(:last-child){border-inline-end:1px solid var(--border);}
tbody tr:last-child td{border-bottom:none;}
ul,ol{margin:.6rem 0;padding-inline-start:1.5rem;}
li{margin:.35rem 0;}
li>code:first-child{font-weight:600;}
nav.toc{background:var(--panel);border:1px solid var(--border);border-radius:10px;
padding:1rem 1.3rem;margin:1.4rem 0 2rem;font-size:.92rem;}
nav.toc strong{display:block;margin-bottom:.4rem;}
nav.toc ul{margin:.2rem 0;padding-inline-start:1.1rem;list-style:none;}
nav.toc>ul{padding-inline-start:0;}
nav.toc li{margin:.2rem 0;}
nav.toc .l3{padding-inline-start:1.1rem;font-size:.88em;color:var(--muted);}
nav.toc .l3 a{color:var(--muted);}
.crumbs{font-size:.85rem;color:var(--muted);margin-bottom:.3rem;}
.crumbs a{color:var(--muted);}
.stamp{font-size:.85rem;color:var(--muted);margin:-.4rem 0 1rem;}
figure.diagram{margin:1.5rem 0;padding:1rem;background:var(--panel);
border:1px solid var(--border);border-radius:10px;overflow-x:auto;}
figure.diagram svg{display:block;min-width:900px;width:100%;height:auto;}
details.src{margin:.6rem 0 1.6rem;font-size:.85rem;color:var(--muted);}
details.src summary{cursor:pointer;}
svg text{fill:var(--fg);font:13px ui-sans-serif,system-ui,"Segoe UI",sans-serif;}
svg .t2{font-size:11px;fill:var(--muted);}
svg .grp-title{font-size:12px;font-weight:600;fill:var(--muted);letter-spacing:.04em;}
svg .box{fill:var(--panel);stroke:var(--fg);stroke-opacity:.55;rx:8;}
svg .box.live{stroke:var(--accent);stroke-opacity:1;stroke-width:1.6;}
svg .box.mock{stroke-dasharray:5 4;}
svg .grp{fill:none;stroke:var(--border);stroke-width:1.2;rx:12;}
svg .edge{fill:none;stroke-width:1.7;}
svg .edge.real{stroke:var(--accent);}
svg .edge.mock{stroke:var(--muted);stroke-dasharray:6 4;}
svg .lbl{font-size:10.5px;fill:var(--muted);}
svg .arr-real{fill:var(--accent);} svg .arr-mock{fill:var(--muted);}
</style>
</head>
<body><main>
<div class="crumbs"><a href="index.html">← Post-phase server audit</a></div>
<h1 id="post-development-backend-plan-fixes-improvements">Post-development backend plan — fixes &amp; improvements</h1><p class="stamp">Generated from the canonical Markdown — do not hand-edit. Audit date 2026-07-10.</p><nav class="toc"><strong>Contents</strong><ul><li class="l2"><a href="#post-phase-1-security-config-hygiene-do-before-anything-is-deployed">post-phase-1 — Security &amp; config hygiene (do before anything is deployed)</a></li><li class="l3"><a href="#1-1-rotate-and-remove-the-committed-sql-server-sa-connection-string">1.1 Rotate and remove the committed SQL Server <code>sa</code> connection string</a></li><li class="l3"><a href="#1-2-replace-placeholder-jwe-signing-encryption-keys-and-field-encryption-keys">1.2 Replace placeholder JWE signing/encryption keys and field-encryption keys</a></li><li class="l3"><a href="#1-3-remove-or-environment-gate-the-seeded-admin-qw123321-user">1.3 Remove or environment-gate the seeded <code>admin</code> / <code>qw123321</code> user</a></li><li class="l3"><a href="#1-4-environment-gate-the-auto-seeded-sandbox-zarinpal-payment-gateway">1.4 Environment-gate the auto-seeded sandbox ZarinPal payment gateway</a></li><li class="l3"><a href="#1-5-fix-the-kestrel-http-2-only-default">1.5 Fix the Kestrel HTTP/2-only default</a></li><li class="l3"><a href="#1-6-make-rate-limiting-proxy-aware-and-align-the-two-payment-webhooks">1.6 Make rate limiting proxy-aware and align the two payment webhooks</a></li><li class="l2"><a href="#post-phase-2-money-path-correctness-completion">post-phase-2 — Money-path correctness completion</a></li><li class="l3"><a href="#2-1-wire-the-unreachable-bnpl-manual-refund-settlement-dead-end-money-state-top-code-fix">2.1 Wire the unreachable BNPL/manual refund settlement (dead-end money state) — <strong>top code fix</strong></a></li><li class="l3"><a href="#2-2-add-the-promised-but-missing-fks-on-the-forward-dep-columns">2.2 Add the promised-but-missing FKs on the forward-dep columns</a></li><li class="l3"><a href="#2-3-extend-iauditable-to-the-admin-decided-money-trust-entities">2.3 Extend <code>IAuditable</code> to the admin-decided money &amp; trust entities</a></li><li class="l3"><a href="#2-4-close-the-refund-channel-execute-before-commit-crash-window">2.4 Close the refund channel-execute-before-commit crash window</a></li><li class="l3"><a href="#2-5-test-the-untested-admin-money-paths">2.5 Test the untested admin money paths</a></li><li class="l3"><a href="#2-6-retire-the-orphaned-refund-ticket-required-config-key">2.6 Retire the orphaned <code>refund_ticket_required</code> config key</a></li><li class="l2"><a href="#post-phase-3-frontend-unblock-contract-batch">post-phase-3 — Frontend-unblock contract batch</a></li><li class="l2"><a href="#post-phase-4-scheduling-locking-multi-instance-readiness">post-phase-4 — Scheduling, locking &amp; multi-instance readiness</a></li><li class="l3"><a href="#4-1-real-job-scheduler-register-the-four-deferred-crons">4.1 Real job scheduler + register the four deferred crons</a></li><li class="l3"><a href="#4-2-redis-for-icacheservice-idistributedlock">4.2 Redis for <code>ICacheService</code> + <code>IDistributedLock</code></a></li><li class="l3"><a href="#4-3-separate-migrations-from-boot-multi-instance-least-privilege">4.3 Separate migrations from boot (multi-instance + least privilege)</a></li><li class="l2"><a href="#post-phase-5-identity-trust-rails-go-real">post-phase-5 — Identity &amp; trust rails go real</a></li><li class="l3"><a href="#5-1-real-sms-gateway-behind-ismssender-launch-critical">5.1 Real SMS gateway behind <code>ISmsSender</code><strong>launch-critical</strong></a></li><li class="l3"><a href="#5-2-real-shahkar-e-kyc-vendors-ishahkarverifier-iidentitykycprovider">5.2 Real Shahkar + e-KYC vendors (<code>IShahkarVerifier</code>, <code>IIdentityKycProvider</code>)</a></li><li class="l3"><a href="#5-3-real-استعلام-شبا-ibankaccountownershipverifier">5.3 Real استعلام شبا (<code>IBankAccountOwnershipVerifier</code>)</a></li><li class="l3"><a href="#5-4-real-geocoder-igeocoder-behind-neshan">5.4 Real geocoder (<code>IGeocoder</code>) behind Neshan</a></li><li class="l3"><a href="#5-5-real-object-storage-iobjectstorage-minio-s3-arvancloud">5.5 Real object storage (<code>IObjectStorage</code>) — MinIO/S3/ArvanCloud</a></li><li class="l3"><a href="#5-6-accept-manual-as-the-real-path-for-moh-ino-credentials-and-partner-licensing-document-don-t-build">5.6 Accept manual as the real path for MoH/INO credentials and partner licensing (document, don't build)</a></li><li class="l2"><a href="#post-phase-6-money-rails-go-real">post-phase-6 — Money rails go real</a></li><li class="l3"><a href="#6-1-real-psp-ipg-webhook-signatures-تسهیم-ipaymentprovider-iwebhookverifier-isettlementsplitprovider">6.1 Real PSP/IPG + webhook signatures + تسهیم (<code>IPaymentProvider</code>, <code>IWebhookVerifier</code>, <code>ISettlementSplitProvider</code>)</a></li><li class="l3"><a href="#6-2-real-bnpl-adapters-ibnplprovider-ibnplproviderresolver">6.2 Real BNPL adapters (<code>IBnplProvider</code> / <code>IBnplProviderResolver</code>)</a></li><li class="l3"><a href="#6-3-real-paya-satna-payout-rail-ibanktransferprovider-async-reconciliation">6.3 Real PAYA/SATNA payout rail (<code>IBankTransferProvider</code>) + async reconciliation</a></li><li class="l3"><a href="#6-4-remove-ipaymentcapturesimulator-the-registry-s-own-end-state">6.4 Remove <code>IPaymentCaptureSimulator</code> (the registry's own end-state)</a></li><li class="l3"><a href="#6-5-real-moadian-submission-reconciliation-imoadianclient">6.5 Real Moadian submission + reconciliation (<code>IMoadianClient</code>)</a></li><li class="l3"><a href="#6-6-decide-the-partner-center-settlement-rail-currently-resolver-without-money">6.6 Decide the partner-center settlement rail (currently: resolver without money)</a></li><li class="l2"><a href="#post-phase-7-observability-audit-ops-hardening">post-phase-7 — Observability, audit &amp; ops hardening</a></li><li class="l3"><a href="#7-1-add-tracing-and-consolidate-the-two-metric-stacks">7.1 Add tracing and consolidate the two metric stacks</a></li><li class="l3"><a href="#7-2-broaden-health-checks-and-split-readiness-liveness">7.2 Broaden health checks and split readiness/liveness</a></li><li class="l3"><a href="#7-3-revisit-production-log-levels-and-the-notification-channel-plan">7.3 Revisit production log levels and the notification-channel plan</a></li><li class="l3"><a href="#7-4-audit-log-growth-archival">7.4 Audit-log growth &amp; archival</a></li><li class="l3"><a href="#7-5-decide-ticketmessage-body-encryption-and-the-grpc-plugin-s-fate">7.5 Decide TicketMessage.Body encryption and the gRPC plugin's fate</a></li><li class="l3"><a href="#7-6-keep-the-docs-honest-registry-tracker-map">7.6 Keep the docs honest (registry + tracker + map)</a></li><li class="l2"><a href="#post-phase-8-scale-later-explicitly-not-mvp">post-phase-8 — Scale &amp; later (explicitly not MVP)</a></li><li class="l2"><a href="#suggested-sequencing">Suggested sequencing</a></li></ul></nav>
<p><strong>Audit date:</strong> 2026-07-10 · <strong>Scope:</strong> the completed backend chain (backend-phase-0 → 15) under <code>server/</code> · <strong>Method:</strong> read-only audit of code + <code>dev/</code> docs; every finding cites the file/line it was verified at. Companion documents: <a href="frontend-backend-gaps.html">frontend-backend-gaps.md</a> (the REQ-by-REQ contract reconciliation) and <a href="runtime-services.html">runtime-services.md</a> (the deployment topology).</p>
<p>The chain is genuinely complete against its own specs: all 16 backend phases shipped, 358 tests are green, and the load-bearing invariants (balanced ledger, DB CHECKs, idempotency uniques, tenancy 404s, forward-only status machines) verifiably exist in code. What remains falls into eight coherent "post-phases", ordered so that each is a runnable unit of work: deployment blockers first, then a code-level money-correctness fix, then the contract batch that unblocks the frontend lane, then the infrastructure and vendor swaps the seam architecture was built for.</p>
<div class="tblwrap"><table><thead><tr><th>Bucket</th><th>Theme</th><th>Blocking what?</th></tr></thead><tbody><tr><td>post-phase-1</td><td>Security &amp; config hygiene</td><td>Any non-local deployment</td></tr><tr><td>post-phase-2</td><td>Money-path correctness completion</td><td>Ledger ⇄ bank reconciliation</td></tr><tr><td>post-phase-3</td><td>Frontend-unblock contract batch</td><td>11 of 12 client domains are still mock-primary</td></tr><tr><td>post-phase-4</td><td>Scheduling, locking &amp; multi-instance readiness</td><td>Unattended operation; &gt;1 API instance</td></tr><tr><td>post-phase-5</td><td>Identity &amp; trust rails go real</td><td>Real nurses onboarding (OTP, KYC, docs)</td></tr><tr><td>post-phase-6</td><td>Money rails go real</td><td>Real payments, payouts, tax</td></tr><tr><td>post-phase-7</td><td>Observability, audit &amp; ops hardening</td><td>Production diagnosability</td></tr><tr><td>post-phase-8</td><td>Scale &amp; later</td><td>Search scale, analytics, doc debt</td></tr></tbody></table></div>
<p>Status legend used below — <strong>Current state</strong> always cites what the code does <em>today</em>.</p>
<hr>
<h2 id="post-phase-1-security-config-hygiene-do-before-anything-is-deployed">post-phase-1 — Security &amp; config hygiene (do before anything is deployed)</h2>
<p>Everything in this bucket is small (S) and none of it changes behavior — but each item is a deployment blocker, and two of them are live credential leaks sitting in git today.</p>
<h3 id="1-1-rotate-and-remove-the-committed-sql-server-sa-connection-string">1.1 Rotate and remove the committed SQL Server <code>sa</code> connection string</h3>
<ul><li><strong>Why:</strong> <code>appsettings.json</code> and <code>appsettings.Development.json</code> both commit a real connection string — public IP <code>87.107.152.16</code>, login <code>sa</code>, plaintext password — for the app DB <em>and</em> the log DB. Anyone with repo access owns the database (all PII ciphertext + the encryption keys sit in the same repo, see 1.2). This directly violates root <code>CLAUDE.md</code> working agreement #6 ("Never commit secrets").</li><li><strong>Current state:</strong> <code>server/src/API/Baya.Web.Api/appsettings.json:3-4</code> and <code>appsettings.Development.json:3-4</code> (byte-identical files); consumed at <code>server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs:41</code> and by the Serilog sink at <code>server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Logging/LoggingConfiguration.cs:44</code>.</li><li><strong>Change:</strong> rotate the <code>sa</code> password on that server (assume compromised); create a least-privilege app login; move both connection strings to user-secrets (dev) / environment variables (deploy); commit only a placeholder. Consider <code>git filter-repo</code> history scrubbing, and add a secret-scanning pre-commit hook.</li><li><strong>Files/layers:</strong> <code>appsettings*.json</code> only (config).</li><li><strong>Effort:</strong> S · <strong>Risk:</strong> low (config move) · <strong>Deps:</strong> none. <strong>Do this first.</strong></li></ul>
<h3 id="1-2-replace-placeholder-jwe-signing-encryption-keys-and-field-encryption-keys">1.2 Replace placeholder JWE signing/encryption keys and field-encryption keys</h3>
<ul><li><strong>Why:</strong> the JWT/JWE <code>SecretKey</code>/<code>Encryptkey</code> are starter-template placeholders and the PII field-encryption keys are committed <code>local-dev-…-change-me</code> strings. If these defaults reach any shared environment, every token is forgeable and every encrypted PII column is decryptable. The access-token lifetime is also ~7 days (<code>ExpirationMinutes: 10000</code>) and <code>RequireHttpsMetadata = false</code>.</li><li><strong>Current state:</strong> <code>server/src/API/Baya.Web.Api/appsettings.json:7-8</code> (keys), <code>:12</code> (expiry); <code>Seams:FieldEncryption</code> at <code>appsettings.json:15-18</code>; the JWE decryption key is wired at <code>server/src/Infrastructure/Baya.Infrastructure.Identity/ServiceConfiguration/ServiceCollectionExtension.cs:135</code> and <code>RequireHttpsMetadata = false</code> at <code>:139</code>. <code>SymmetricFieldEncryptor</code> derives a real AES-256-CBC key from whatever string is configured (<code>server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SymmetricFieldEncryptor.cs:25</code>) — the crypto is fine; the key <em>management</em> is dev-grade (mocks-registry row <code>IFieldEncryptor</code> 🟡 agrees).</li><li><strong>Change:</strong> per-environment secrets (env vars / Key Vault / KMS per the registry's "make it real"), a sane access-token lifetime (≤ 60 min; refresh flow already exists), <code>RequireHttpsMetadata = true</code> outside Development, real <code>Issuer</code>/<code>Audience</code> values (currently <code>"MyWebsite"</code>). Note: rotating the field key requires a re-encryption migration for existing rows — do it before real PII exists.</li><li><strong>Effort:</strong> S (config) + M if key-rotation tooling is wanted · <strong>Risk:</strong> medium (existing dev-DB ciphertext becomes unreadable — acceptable pre-launch) · <strong>Deps:</strong> 1.1.</li></ul>
<h3 id="1-3-remove-or-environment-gate-the-seeded-admin-qw123321-user">1.3 Remove or environment-gate the seeded <code>admin</code> / <code>qw123321</code> user</h3>
<ul><li><strong>Why:</strong> every non-Testing boot creates a well-known admin account with a hardcoded weak password and the full admin role — in production too.</li><li><strong>Current state:</strong> <code>server/src/Infrastructure/Baya.Infrastructure.Identity/Identity/SeedDatabaseService/SeedDataBase.cs:48</code> (<code>CreateAsync(user, "qw123321")</code>), invoked from <code>server/src/API/Baya.Web.Api/Program.cs:102</code>.</li><li><strong>Change:</strong> read the bootstrap admin credentials from configuration and only seed when explicitly configured (or Development-only); force a password change on first login.</li><li><strong>Effort:</strong> S · <strong>Risk:</strong> low · <strong>Deps:</strong> none.</li></ul>
<h3 id="1-4-environment-gate-the-auto-seeded-sandbox-zarinpal-payment-gateway">1.4 Environment-gate the auto-seeded sandbox ZarinPal payment gateway</h3>
<ul><li><strong>Why:</strong> <code>SeedPaymentGatewaysAsync</code> idempotently inserts an <strong>active</strong> sandbox ZarinPal gateway row (all-zeros merchant id) on every boot — a production DB would silently contain an active sandbox money gateway.</li><li><strong>Current state:</strong> <code>server/src/API/Baya.Web.Api/Program.cs:103</code><code>server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs:106-117</code> (sandbox row, <code>config_json</code> encrypted via the DbContext converter).</li><li><strong>Change:</strong> seed only in Development/Testing, or seed <code>is_active = false</code> and require an admin to activate a real gateway (<code>payment_gateways</code> is already admin data).</li><li><strong>Effort:</strong> S · <strong>Risk:</strong> low · <strong>Deps:</strong> none; pairs with 6.1.</li></ul>
<h3 id="1-5-fix-the-kestrel-http-2-only-default">1.5 Fix the Kestrel HTTP/2-only default</h3>
<ul><li><strong>Why:</strong> <code>Kestrel:EndpointDefaults:Protocols = "Http2"</code> (set for the gRPC plugin) makes every endpoint HTTP/2-only. Browsers can't speak h2c, so any non-TLS hop — container health probes, an HTTP/1.1 reverse proxy leg, plain-HTTP Swagger — breaks. Locally it only works because <code>https://localhost:5002</code> negotiates via ALPN.</li><li><strong>Current state:</strong> <code>server/src/API/Baya.Web.Api/appsettings.json:29-33</code> (both files). The gRPC plugin that motivated it serves a single duplicate OTP/token service (<code>server/src/API/Plugins/Baya.Web.Plugins.Grpc/Services/UserGrpcServices.cs:18</code>).</li><li><strong>Change:</strong> default <code>Http1AndHttp2</code>; give gRPC its own <code>Http2</code> endpoint if kept (see 7.5 for the keep-or-remove decision).</li><li><strong>Effort:</strong> S · <strong>Risk:</strong> low · <strong>Deps:</strong> none.</li></ul>
<h3 id="1-6-make-rate-limiting-proxy-aware-and-align-the-two-payment-webhooks">1.6 Make rate limiting proxy-aware and align the two payment webhooks</h3>
<ul><li><strong>Why:</strong> all rate-limit partitions key on <code>RemoteIpAddress</code> and no <code>ForwardedHeaders</code> middleware is registered — behind any reverse proxy every client shares one 100 req/min bucket (self-DoS). Separately, the two webhook siblings disagree: the BNPL webhook runs the 20/min <code>sensitive</code> policy while the card webhook has only the global 100/min fallback — one of them is wrong on purpose or both by accident.</li><li><strong>Current state:</strong> partition key at <code>server/src/API/Baya.WebFramework/ServiceConfiguration/RateLimitingServiceExtension.cs:69</code>; global limiter <code>:35</code>; <code>Program.cs</code> has no <code>UseForwardedHeaders</code> (checked <code>server/src/API/Baya.Web.Api/Program.cs</code>). Webhooks: <code>Controllers/V1/WebhooksBnplController.cs:28</code> (<code>sensitive</code>) vs <code>Controllers/V1/WebhooksController.cs:25</code> (<code>[AllowAnonymous]</code>, global only).</li><li><strong>Change:</strong> add <code>ForwardedHeaders</code> middleware (trusting only the known proxy), partition on the resolved client IP, and pick one deliberate webhook policy (PSP callbacks are bursty — a dedicated <code>webhook</code> policy keyed per-provider is safer than <code>sensitive</code>).</li><li><strong>Effort:</strong> S · <strong>Risk:</strong> lowmedium (limiter behavior changes) · <strong>Deps:</strong> deployment topology decision.</li></ul>
<hr>
<h2 id="post-phase-2-money-path-correctness-completion">post-phase-2 — Money-path correctness completion</h2>
<p>The ledger invariants verified clean (balanced groups throw at <code>server/src/Core/Baya.Domain/Entities/Payments/LedgerPosting.cs:26,65,178,204</code>; the four DB CHECKs exist — <code>CK_Bookings_AmountSplit</code>, <code>CK_NursePayouts_NetSplit</code>, <code>CK_Refunds_LegSplit</code>, <code>CK_BnplTransactions_SettleSplit</code> — per the EF configs and <code>Migrations/ApplicationDbContextModelSnapshot.cs:345,3543,3842,206</code>). This bucket closes the holes <em>around</em> those invariants.</p>
<h3 id="2-1-wire-the-unreachable-bnpl-manual-refund-settlement-dead-end-money-state-top-code-fix">2.1 Wire the unreachable BNPL/manual refund settlement (dead-end money state) — <strong>top code fix</strong></h3>
<ul><li><strong>Why:</strong> a card refund posts its <code>refund_payable ↔ escrow_held</code> clearing immediately. A BNPL-revert or manual-bank refund is left in <code>processing</code> with the clearing "deferred to reconciliation" — but <strong>no reconciliation path exists anywhere</strong>: <code>Refund.MarkSucceededAsync</code> has zero call sites, no admin endpoint/webhook/job performs <code>processing → succeeded</code>, and <code>LedgerPosting.RefundPayableClearing</code> has exactly one call site (the immediate card path). Every BNPL/manual refund permanently overstates <code>escrow_held</code> and strands <code>refund_payable</code> — the ledger will never reconcile with the bank.</li><li><strong>Current state:</strong> <code>server/src/Core/Baya.Domain/Entities/Refunds/Refund.cs:105</code> (uncalled); <code>server/src/Core/Baya.Application/Features/Refunds/Commands/CreateRefund/CreateRefundCommand.Handler.cs:139-143</code> (clearing only when already <code>Succeeded</code>); <code>:226-231</code> (BNPL/manual → <code>MarkProcessing</code>, never succeeded); the <code>Processing → Succeeded</code> edge exists unused in <code>server/src/Core/Baya.Domain/Entities/Refunds/RefundTransitions.cs:16</code>; admin surface is create+list only (<code>Controllers/V1/AdminRefundsController.cs:33</code>). The b11 handoff promised at least a manual trigger (<code>dev/shared-working-context/backend/handoff/after-backend-phase-11.md:37</code>).</li><li><strong>Change:</strong> a <code>ConfirmRefundSettlementCommand</code> (admin <code>POST admin_refunds/{id}/confirm_settlement</code>, plus a BNPL-callback branch when the provider confirms customer cash-back) that transitions <code>processing → succeeded</code>, stamps <code>settled_at</code>, and posts <code>LedgerPosting.RefundPayableClearing</code> in the same commit; a <code>mark_failed</code> counterpart. Tests for both channels.</li><li><strong>Files/layers:</strong> Application <code>Features/Refunds/</code>, API <code>AdminRefundsController</code>, Domain (rename <code>MarkSucceededAsync</code> — it's not async), tests.</li><li><strong>Effort:</strong> M · <strong>Risk:</strong> medium (money path — but additive) · <strong>Deps:</strong> none; do before real BNPL (6.2).</li></ul>
<h3 id="2-2-add-the-promised-but-missing-fks-on-the-forward-dep-columns">2.2 Add the promised-but-missing FKs on the forward-dep columns</h3>
<ul><li><strong>Why:</strong> b11 created <code>refunds.ticket_id</code>, <code>nurse_clawbacks.original_payout_id</code> / <code>recovered_in_payout_id</code>, and <code>invoices.partner_center_id</code> as FK-less nullable columns "until the target table ships". The targets all shipped (b13 <code>nurse_payouts</code>, b15 <code>tickets</code>/<code>partner_centers</code>) and the <strong>values</strong> are wired, but no phase added the constraints — referential integrity rests on application discipline, and the config comments are now false.</li><li><strong>Current state:</strong> stale comment "no FK yet (tickets does not exist)" at <code>server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/RefundsConfig/RefundConfig.cs:41</code>; <code>NurseClawbackConfig.cs:12</code> (comment says b13 "wires the FKs" — it didn't; b13's <code>NursePayoutEngine</code> migration touches no clawback FK); <code>InvoicesConfig/InvoiceConfig.cs:11</code> (<code>partner_center_id</code> has <strong>no FK and no index</strong>); the b15 migration adds only <code>FK_NurseProfiles_PartnerCenters_PartnerCenterId</code> (<code>Migrations/20260709232741_MessagingAndPartnerCenters.cs:293</code>). Value-side wiring confirmed: <code>CreateRefundCommand.Handler.cs:53-61</code> (auto-ticket), <code>Refunds/NurseClawback.cs:50</code> (<code>MarkRecovered</code>), <code>IssueInvoiceCommand.Handler.cs:63-64</code> (issuer/center).</li><li><strong>Change:</strong> one additive migration adding the three FK sets (<code>ON DELETE NO ACTION</code>) + an index on <code>invoices.partner_center_id</code>; update the three config comments.</li><li><strong>Effort:</strong> S · <strong>Risk:</strong> low (data is young; verify no orphans first) · <strong>Deps:</strong> none.</li></ul>
<h3 id="2-3-extend-iauditable-to-the-admin-decided-money-trust-entities">2.3 Extend <code>IAuditable</code> to the admin-decided money &amp; trust entities</h3>
<ul><li><strong>Why:</strong> the append-only <code>audit_logs</code> diff interceptor covers exactly three entities — <code>PlatformConfig</code>, <code>PartnerCenter</code>, <code>Review</code>. Admin decisions on refunds (approve/reject), payouts (process/retry/fail), and <strong>nurse verification</strong> (the trust-critical <code>is_verified</code> flip / suspend) leave no audit-diff row. For a trust-first escrow platform these are precisely the actions an auditor asks about. (The ledger itself is fine — append-only by construction, <code>LedgerEntry</code> is <code>IEntity</code>-only at <code>server/src/Core/Baya.Domain/Entities/Payments/LedgerEntry.cs:13</code>.)</li><li><strong>Current state:</strong> the three implementors — <code>Domain/Entities/Configuration/PlatformConfig.cs:12</code>, <code>Domain/Entities/PartnerCenters/PartnerCenter.cs:16</code>, <code>Domain/Entities/Reviews/Review.cs:15</code>. <code>Refund</code> (<code>Domain/Entities/Refunds/Refund.cs:77</code>) and <code>NursePayout</code> (<code>Domain/Entities/Payouts/NursePayout.cs:62</code>) are not <code>IAuditable</code>; <code>NurseVerification</code> isn't either.</li><li><strong>Change:</strong> add <code>IAuditable</code> to <code>Refund</code>, <code>NurseClawback</code>, <code>NursePayout</code>, <code>NursePayoutBatch</code>, <code>NurseVerification</code> (the interceptor at <code>Persistence/Interceptors/AuditFieldInterceptor</code> already handles any <code>IAuditable</code>); confirm <code>[AuditRedacted]</code> covers <code>iban_snapshot</code> before enabling.</li><li><strong>Effort:</strong> SM · <strong>Risk:</strong> low (write-volume growth on <code>audit_logs</code>; see 7.4 archival) · <strong>Deps:</strong> none.</li></ul>
<h3 id="2-4-close-the-refund-channel-execute-before-commit-crash-window">2.4 Close the refund channel-execute-before-commit crash window</h3>
<ul><li><strong>Why:</strong> <code>CreateRefundCommand</code> executes the external channel call (PSP refund / BNPL revert) <strong>before</strong> the first DB commit — a crash between provider success and commit loses the record of an executed refund. The idempotency key means a <em>retry</em> won't double-refund, but nothing retries automatically and no record exists to reconcile against.</li><li><strong>Current state:</strong> <code>Features/Refunds/Commands/CreateRefund/CreateRefundCommand.Handler.cs:106-111</code> (channel executes), commit later in the same handler; idempotency key at <code>:85</code>.</li><li><strong>Change:</strong> persist the refund row in <code>pending</code> state (commit) <em>before</em> the channel call, then execute and update — the standard two-phase intent/confirm shape the webhook handler already uses (claim-key-first at <code>Features/Payments/Commands/HandlePaymentWebhook/HandlePaymentWebhookCommand.Handler.cs:76-88</code>).</li><li><strong>Effort:</strong> M · <strong>Risk:</strong> medium (touches the refund state machine; full test pass required) · <strong>Deps:</strong> do together with 2.1.</li></ul>
<h3 id="2-5-test-the-untested-admin-money-paths">2.5 Test the untested admin money paths</h3>
<ul><li><strong>Why:</strong> <code>WriteOffClawbackCommand</code> — an admin action that posts a <code>bad_debt</code> ledger group — has <strong>zero tests</strong> (grep across <code>server/src/Tests</code> finds no <code>WriteOff</code> match). The webhook duplicate-race path (<code>DbUpdateException</code> on a concurrent same-key insert) is only exercised sequentially. Messaging has no Foundation-level handler tests (the <code>is_internal</code> boundary is covered only end-to-end in <code>Tests/Baya.Test.Api/MessagingApiTests.cs:33</code>).</li><li><strong>Current state:</strong> handler at <code>Features/Refunds/Commands/WriteOffClawback/WriteOffClawbackCommand.Handler.cs:26</code>; coverage inventory: 49 Foundation + 33 Api test files; payout retry / webhook replay / clawback fork / rebuild convergence <strong>are</strong> tested (<code>Tests/Baya.Test.Foundation/Payouts/PayoutHandlerTests.cs:211</code>, <code>Payments/PaymentWebhookTests.cs:64</code>, <code>Refunds/RefundHandlerTests.cs:81</code>, <code>Search/SearchIndexTests.cs:184</code>).</li><li><strong>Change:</strong> add write-off unit + API tests (balanced group, idempotency, 404/409 paths), a true racing webhook-insert test, and Foundation tests for <code>PostMessage</code>/<code>GetTicketThread</code> internal-flag handling.</li><li><strong>Effort:</strong> SM · <strong>Risk:</strong> none · <strong>Deps:</strong> none.</li></ul>
<h3 id="2-6-retire-the-orphaned-refund-ticket-required-config-key">2.6 Retire the orphaned <code>refund_ticket_required</code> config key</h3>
<ul><li><strong>Why:</strong> b15 superseded the config-gated rule by unconditionally auto-opening a refund ticket, so the seeded key has zero production consumers and a now-false description ("Off until b15 ships the tickets table") — a small honesty debt that will mislead an operator.</li><li><strong>Current state:</strong> seed row at <code>Persistence/Configuration/ConfigurationConfig/PlatformConfigConfig.cs:49</code>; auto-open at <code>CreateRefundCommand.Handler.cs:53-61</code>; only remaining reference is a test stub (<code>Tests/Baya.Test.Foundation/Refunds/RefundsTestHost.cs:174</code>).</li><li><strong>Change:</strong> delete the seed row (migration) or repurpose it to gate whether a <em>customer-visible</em> ticket is required; update the description either way.</li><li><strong>Effort:</strong> S · <strong>Risk:</strong> low · <strong>Deps:</strong> none.</li></ul>
<hr>
<h2 id="post-phase-3-frontend-unblock-contract-batch">post-phase-3 — Frontend-unblock contract batch</h2>
<p>Full field-level detail, evidence, and priority ordering live in <a href="frontend-backend-gaps.html">frontend-backend-gaps.md</a>. Summary: of the 15 filed REQs, <strong>REQ-001 and REQ-015 are effectively done, REQ-010 is a doc fix, and the other 12 are undelivered</strong>; 11 of the client's 12 service domains still default to mock-primary, most gated on exactly these items. This bucket is one backend phase-sized batch of small DTO/endpoint additions:</p>
<ul><li><strong>3.1 Booking-surface fields (S):</strong> <code>variantPrice</code> on <code>BookingRequestDto</code>, <code>variantLabel</code> (+ optional <code>patientAge</code>) on <code>BookingRequestListItemDto</code> (REQ-013/014).</li><li><strong>3.2 Identity/profile fields (SM):</strong> patient <code>relation</code> + <code>conditions</code> (REQ-005); customer name/preferred-language upsert (REQ-007); avatar upload endpoint + <code>avatarUrl</code> (REQ-006 — the only item needing multipart + <code>IObjectStorage</code>).</li><li><strong>3.3 Address fields (S):</strong> accept the client map pin on create/update (REQ-008 — matters for EVV accuracy later) + <code>provinceId</code> on <code>CustomerAddressDto</code> (REQ-009).</li><li><strong>3.4 Search &amp; public profile (M — the single highest-leverage item):</strong> enrich <code>NurseSearchResultDto</code> with <code>nurseName</code>/<code>avatarUrl</code> (+ optional <code>distanceKm</code>) and add the aggregated public <code>GET nurses/{id}/profile</code> (REQ-012). Unblocks the discovery funnel (C2/C3).</li><li><strong>3.5 Verification details (SM):</strong> nurse-facing <code>credential_details</code> command + <code>isRequired</code> on <code>VerificationStepDto</code> (REQ-011 — without it the INO number/specialties are silently dropped).</li><li><strong>3.6 Auth polish (S):</strong> <code>codeLength</code>/<code>expiresInSeconds</code> on <code>RequestOtpResult</code> (REQ-002); machine-readable <code>code</code> (+ <code>retryAfterSeconds</code>) on OTP failures (REQ-003 — needs a small <code>OperationResult</code>/envelope extension, the only cross-cutting piece).</li><li><strong>3.7 Zero-code confirmations &amp; doc fixes (S):</strong> answer REQ-001/004/015 in the tracker; fix the stale <code>page_size</code> occurrences in <code>dev/contracts/domains/*.md</code> (REQ-010 — the server verifiably binds camelCase <code>pageSize</code>); mark every delivered REQ <code>delivered in …</code> (all 15 currently read <code>Status: open</code>).</li></ul>
<p><strong>Effort:</strong> one M-sized phase overall · <strong>Risk:</strong> low (additive DTO fields; regenerate <code>dev/contracts/openapi/swagger.v1.json</code> after) · <strong>Deps:</strong> none — can run in parallel with post-phase-1/2.</p>
<hr>
<h2 id="post-phase-4-scheduling-locking-multi-instance-readiness">post-phase-4 — Scheduling, locking &amp; multi-instance readiness</h2>
<h3 id="4-1-real-job-scheduler-register-the-four-deferred-crons">4.1 Real job scheduler + register the four deferred crons</h3>
<ul><li><strong>Why:</strong> only two recurring jobs exist (booking-request expiry every 1 min, notification retention every 24 h). The verification credential-expiry scan, the EVV no-show sweep, the <strong>weekly payout batch</strong>, and the Moadian reconciliation poll are admin-manual-only — their cadence config keys are seeded but nothing reads them on a schedule. Operationally today: credentials never re-expire, no-shows are never flagged, and <strong>nurses are not paid unless an operator clicks</strong>.</li><li><strong>Current state:</strong> the two hosted services at <code>Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs:63,67</code> (intervals hardcoded at <code>Services/Notifications/NotificationRetentionHostedService.cs:19</code> and <code>Services/Booking/BookingRequestExpiryHostedService.cs:21</code>). Manual triggers: <code>Controllers/V1/AdminVerificationsController.cs:49</code> (<code>scan_expiring</code>), <code>AdminEvvController.cs:37</code> (<code>detect_no_shows</code>), <code>AdminPayoutsController.cs:44</code> (batch generate). Unconsumed cadence keys: <code>verification_expiry_scan_cadence_hours</code>, <code>no_show_scan_cadence_hours</code>, <code>nurse_payout_interval_days</code> (<code>PlatformConfigConfig.cs:46,48,36</code>). Note: <strong>no <code>IJobScheduler</code> interface exists</strong> — the registry row is aspirational naming; there is nothing to swap behind, only hosted services to re-home. No Hangfire/Quartz package is referenced (<code>server/Directory.Packages.props</code> — verified absent).</li><li><strong>Change:</strong> adopt Hangfire (SQL Server storage — no new infra) or Quartz; move the two existing sweeps and add recurring jobs for expiry-scan, no-show, payout-batch generation (+ 2.1's reconciliation and 6.5's Moadian poll), each reading its seeded cadence key; keep the admin manual triggers as overrides. Payout processing (money-moving) can stay human-approved — schedule <em>generation</em>, keep <code>process</code> manual until trust is earned.</li><li><strong>Effort:</strong> ML · <strong>Risk:</strong> medium (new infra dependency in-app; jobs must stay idempotent — they already are by design) · <strong>Deps:</strong> none hard; pairs with 4.2 for multi-instance.</li></ul>
<h3 id="4-2-redis-for-icacheservice-idistributedlock">4.2 Redis for <code>ICacheService</code> + <code>IDistributedLock</code></h3>
<ul><li><strong>Why:</strong> both are in-process today. Cache: fine single-instance, silently wrong (stale geo/catalog/config reads, generation-token invalidation not shared) the moment a second instance runs. Lock: the money-path mutex (<code>booking:{id}:payment</code> / <code>:refund</code>) is a per-key <code>SemaphoreSlim</code> — no cross-instance protection (DB uniques remain the correctness backstop, as designed, but the lock is doing nothing across nodes).</li><li><strong>Current state:</strong> <code>CrossCutting/Seams/MemoryCacheService.cs:11</code> and <code>InProcessDistributedLock.cs:14-22</code>, registered at <code>CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs:27,61</code>. No Redis package referenced.</li><li><strong>Change:</strong> add <code>StackExchange.Redis</code>; <code>RedisCacheService</code> (same key/TTL scheme) + <code>RedisDistributedLock</code> (SET NX PX + token-checked release, lease ≥ the longest money handler); config-selected registration per the registry's make-it-real steps (rows <code>ICacheService</code>, <code>IDistributedLock</code>).</li><li><strong>Effort:</strong> M · <strong>Risk:</strong> medium (lock semantics under expiry; keep DB uniques authoritative) · <strong>Deps:</strong> Redis service (see runtime-services.md).</li></ul>
<h3 id="4-3-separate-migrations-from-boot-multi-instance-least-privilege">4.3 Separate migrations from boot (multi-instance + least privilege)</h3>
<ul><li><strong>Why:</strong> every non-Testing boot runs <code>MigrateAsync</code> + three seeders — concurrent instance start-ups race on DDL (no distributed lock exists at boot), and the app login needs permanent DDL rights.</li><li><strong>Current state:</strong> <code>server/src/API/Baya.Web.Api/Program.cs:99-104</code>; <code>Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs:92</code> (<code>MigrateAsync</code> unconditional).</li><li><strong>Change:</strong> a deploy-time migration step (<code>dotnet ef database update</code> in CI, or a <code>--migrate</code> one-shot mode) and boot-time schema <em>check</em> instead of apply; seeders become idempotent deploy steps.</li><li><strong>Effort:</strong> SM · <strong>Risk:</strong> low · <strong>Deps:</strong> CI/CD pipeline exists.</li></ul>
<hr>
<h2 id="post-phase-5-identity-trust-rails-go-real">post-phase-5 — Identity &amp; trust rails go real</h2>
<p>Ordered by user impact: nobody can log in without 5.1.</p>
<h3 id="5-1-real-sms-gateway-behind-ismssender-launch-critical">5.1 Real SMS gateway behind <code>ISmsSender</code><strong>launch-critical</strong></h3>
<ul><li><strong>Why:</strong> OTP delivery is a log statement; no real user can ever log in. This is the single seam standing between the platform and its first real session.</li><li><strong>Current state:</strong> <code>CrossCutting/Seams/LoggingSmsSender.cs:12-16</code> (logs the code, phone last-4), registered at <code>ServiceCollectionExtension.cs:32</code>. No <code>Seams:Sms</code> options exist yet (verified: <code>SeamOptions.cs</code> has no Sms group).</li><li><strong>Change:</strong> per registry row <code>ISmsSender</code>: pick Kavenegar/Ghasedak/SMS.ir, add <code>Seams:Sms:{ApiKey, SenderLine,BaseUrl}</code> options + package, implement pattern/template OTP send, config-selected registration. Keep the per-phone resend window and <code>otp</code> rate policy untouched.</li><li><strong>Effort:</strong> M · <strong>Risk:</strong> low (isolated seam) · <strong>Deps:</strong> vendor account.</li></ul>
<h3 id="5-2-real-shahkar-e-kyc-vendors-ishahkarverifier-iidentitykycprovider">5.2 Real Shahkar + e-KYC vendors (<code>IShahkarVerifier</code>, <code>IIdentityKycProvider</code>)</h3>
<ul><li><strong>Why:</strong> nurse verification currently passes any well-formed input (mock passes everything except two configured magic values) — the trust engine's automated steps assert nothing real.</li><li><strong>Current state:</strong> <code>CrossCutting/Seams/MockShahkarVerifier.cs:17-37</code>, <code>MockIdentityKycProvider.cs:17-25</code>; registrations <code>:45-46</code>; config defaults <code>SeamOptions.cs:157-184</code>. The pipeline already persists <code>external_response_json</code> and handles shared-SIM/mismatch as explicit states — the handler side is ready.</li><li><strong>Change:</strong> per registry rows: one Finnotech-style bridge client for both استعلام‌ها; keep shared-SIM as a handled failure; persist real vendor refs. The phone-change re-trigger already works (<code>ShahkarVerifiedAt</code> reset on phone change — b2).</li><li><strong>Effort:</strong> ML (vendor onboarding dominates) · <strong>Risk:</strong> medium (real-world failure modes) · <strong>Deps:</strong> vendor contract; 5.1 not required but sensible first.</li></ul>
<h3 id="5-3-real-استعلام-شبا-ibankaccountownershipverifier">5.3 Real استعلام شبا (<code>IBankAccountOwnershipVerifier</code>)</h3>
<ul><li><strong>Why:</strong> the b13 first-payout gate (<code>matched_national_id = 1</code>) is currently satisfied by a mock that matches every IBAN except one magic value — real money would flow against unverified account ownership.</li><li><strong>Current state:</strong> <code>CrossCutting/Seams/MockBankAccountOwnershipVerifier.cs:17-26</code> (nurseNationalId deliberately ignored, comment <code>:21-22</code>); registration <code>:36</code>; defaults <code>SeamOptions.cs:204</code>.</li><li><strong>Change:</strong> per registry row; verify the payout gate end-to-end (<code>is_primary=1 AND is_verified=1 AND matched_national_id=1</code> skip-with-reason path already exists in b13).</li><li><strong>Effort:</strong> M · <strong>Risk:</strong> medium (money gate) · <strong>Deps:</strong> same vendor family as 5.2 — bundle them.</li></ul>
<h3 id="5-4-real-geocoder-igeocoder-behind-neshan">5.4 Real geocoder (<code>IGeocoder</code>) behind Neshan</h3>
<ul><li><strong>Why:</strong> address coordinates (and therefore the EVV distance check) are deterministic fakes jittered ±5 km around 8 hardcoded city centroids — EVV mismatch alerts are currently noise. Accepting the client pin (REQ-008, post-phase-3) reduces but doesn't remove the need.</li><li><strong>Current state:</strong> <code>CrossCutting/Seams/MockGeocoder.cs:15-52</code>; registration <code>:40</code>; <code>Seams:Geocoding</code> is one of only three seam sections present in <code>appsettings.json:22-26</code>.</li><li><strong>Change:</strong> per registry row (Neshan client, rate-limit/retry, keep the null-coordinate path).</li><li><strong>Effort:</strong> M · <strong>Risk:</strong> low · <strong>Deps:</strong> REQ-008 first (pin &gt; geocode for EVV).</li></ul>
<h3 id="5-5-real-object-storage-iobjectstorage-minio-s3-arvancloud">5.5 Real object storage (<code>IObjectStorage</code>) — MinIO/S3/ArvanCloud</h3>
<ul><li><strong>Why:</strong> verification documents (and future avatars/invoice PDFs) live on the API host's local disk under a temp root — non-durable, non-shared, and <code>GetUrl</code> returns a <code>file://</code> URI rather than a presigned URL, so the b6 "short-lived signed URL" contract is only shape-deep.</li><li><strong>Current state:</strong> <code>CrossCutting/Seams/LocalDiskObjectStorage.cs:12-56</code> (temp-dir fallback <code>:20</code>, <code>file://</code> URL); <code>Seams:ObjectStorage:RootPath</code> empty in <code>appsettings.json:19-21</code>.</li><li><strong>Change:</strong> per registry row: S3-compatible client, presigned PUT/GET with expiry, bucket + creds from config; migrate any existing dev files or reset.</li><li><strong>Effort:</strong> M · <strong>Risk:</strong> low · <strong>Deps:</strong> storage service; REQ-006 (avatar) builds on this.</li></ul>
<h3 id="5-6-accept-manual-as-the-real-path-for-moh-ino-credentials-and-partner-licensing-document-don-t-build">5.6 Accept manual as the real path for MoH/INO credentials and partner licensing (document, don't build)</h3>
<ul><li><strong>Why:</strong> <code>ICredentialVerifier</code> and <code>ILicenseVerificationService</code> return "needs manual review" by design — there is <strong>no public MoH/INO/eNamad B2B API</strong> today. The admin review flows are the real mechanism; the seams exist so a portal API can slot in if one appears.</li><li><strong>Current state:</strong> <code>CrossCutting/Seams/MockCredentialVerifier.cs:18</code>, <code>MockLicenseVerificationService.cs:26</code>; registry rows agree ("no public B2B API today").</li><li><strong>Change:</strong> none in code. Mark these 🟡 rows as "manual = intended MVP state" in the registry so they stop reading as debt.</li><li><strong>Effort:</strong> S (docs) · <strong>Risk:</strong> none.</li></ul>
<hr>
<h2 id="post-phase-6-money-rails-go-real">post-phase-6 — Money rails go real</h2>
<p>The seam shapes are faithful (idempotency keys, server-side re-verify, upsert-first webhooks are already the handler behavior — verified at <code>HandlePaymentWebhookCommand.Handler.cs:31-88</code>), so each swap is an adapter, not a redesign. <strong>Do 2.1 first</strong> so the BNPL refund path is complete before real money uses it.</p>
<h3 id="6-1-real-psp-ipg-webhook-signatures-تسهیم-ipaymentprovider-iwebhookverifier-isettlementsplitprovider">6.1 Real PSP/IPG + webhook signatures + تسهیم (<code>IPaymentProvider</code>, <code>IWebhookVerifier</code>, <code>ISettlementSplitProvider</code>)</h3>
<ul><li><strong>Why:</strong> card capture, callback authenticity, and the settlement split are all deterministic mocks (<code>VerifyAsync</code> always succeeds echoing the expected amount; any callback is "validly signed" unless it contains a magic marker; any balanced split "settles"). The legal تسهیم model (provider splits to registered IBANs; platform never holds funds) only exists as an interface.</li><li><strong>Current state:</strong> <code>CrossCutting/Seams/MockPaymentProvider.cs:14-24</code>, <code>MockWebhookVerifier.cs:15-21</code>, <code>MockSettlementSplitProvider.cs:13</code>; registrations <code>:58-60</code>; merchant creds intended to come from the encrypted <code>payment_gateways.config_json</code> (seed at <code>Persistence/ServiceCollectionExtensions.cs:109-117</code>).</li><li><strong>Change:</strong> per registry rows 3941: one acquirer-with-تسهیم (ZarinPal/Sadad/Vandar/Jibit); real <code>InitPaymentAsync</code>/<code>VerifyAsync</code>/<code>RefundAsync</code>; per-provider HMAC verification of the raw body; a provider registry/factory selected per gateway row; persist full gateway responses.</li><li><strong>Effort:</strong> L · <strong>Risk:</strong> high (real money; Shaparak certification lead time) · <strong>Deps:</strong> merchant registration; 1.4; 2.1; 4.2 (real lock) strongly recommended.</li></ul>
<h3 id="6-2-real-bnpl-adapters-ibnplprovider-ibnplproviderresolver">6.2 Real BNPL adapters (<code>IBnplProvider</code> / <code>IBnplProviderResolver</code>)</h3>
<ul><li><strong>Why:</strong> the full BNPL state machine runs against one mock provider; per-contract commission and non-instant settlement are simulated by config.</li><li><strong>Current state:</strong> <code>CrossCutting/Seams/MockBnplProvider.cs:17-53</code>, <code>MockBnplProviderResolver.cs:14-17</code>; registrations <code>:72-74</code>; <code>Seams:Bnpl:*</code> defaults <code>SeamOptions.cs:98-120</code>. <code>bnpl_settlement_entries</code> (tranched settlement) is modeled-but-not-built by design (<code>dev/shared-working-context/backend/handoff/after-backend-phase-12.md:40</code>).</li><li><strong>Change:</strong> per registry row 46: SnappPay (OAuth verb set) and/or Digipay adapters, creds from encrypted gateway config, Toman↔IRR only via <code>ICurrencyNormalizer</code>, per-contract commission read from the settle response, resolver registered per <code>provider_code</code>.</li><li><strong>Effort:</strong> L · <strong>Risk:</strong> high · <strong>Deps:</strong> 2.1 (revert clearing), 6.1 patterns, provider contracts.</li></ul>
<h3 id="6-3-real-paya-satna-payout-rail-ibanktransferprovider-async-reconciliation">6.3 Real PAYA/SATNA payout rail (<code>IBankTransferProvider</code>) + async reconciliation</h3>
<ul><li><strong>Why:</strong> payouts "settle" instantly in the mock, collapsing the real <code>submitted → paid/failed</code> async reconciliation; no money reaches nurses.</li><li><strong>Current state:</strong> <code>CrossCutting/Seams/MockBankTransferProvider.cs:18-31</code>; registration <code>:82</code>; <code>Seams:BankTransfer</code> defaults <code>SeamOptions.cs:61-72</code>. The status machine, batch idempotency key, <code>nurse_payout_booking_links</code> UNIQUE (<code>Persistence/Configuration/PayoutsConfig/NursePayoutBookingLinkConfig.cs:22</code>), and PAYA/SATNA threshold selection already exist.</li><li><strong>Change:</strong> per registry row 23: Jibit/Vandar/Sadad payout API, source settlement account config, the async callback that flips <code>submitted → paid/failed</code>, batch caps/minimums. Keep whole-batch/single-row failure + retry semantics (already tested at <code>Tests/.../Payouts/PayoutHandlerTests.cs:211</code>).</li><li><strong>Effort:</strong> L · <strong>Risk:</strong> high (irreversible transfers — the UNIQUE link + ledger-exists guard are the backstops, and they're in place) · <strong>Deps:</strong> 5.3 (real ownership check) before real runs; 4.1 for the weekly trigger.</li></ul>
<h3 id="6-4-remove-ipaymentcapturesimulator-the-registry-s-own-end-state">6.4 Remove <code>IPaymentCaptureSimulator</code> (the registry's own end-state)</h3>
<ul><li><strong>Why:</strong> the b9 temporary conversion trigger was supposed to be removed once b10's real capture shipped (registry row 37, "make it real" step 3). b10 shipped; the seam and registration remain. Harmless as a test trigger, but it's a second way to mint a booking without a payment row — undesirable once real money exists.</li><li><strong>Current state:</strong> registered at <code>CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs:52</code>; <code>Seams:PaymentCapture</code> options <code>SeamOptions.cs:142</code>.</li><li><strong>Change:</strong> move the simulator into the test host (b9's tests are its only legitimate consumer) and drop the production registration; or gate the registration to Development/Testing.</li><li><strong>Effort:</strong> S · <strong>Risk:</strong> low (b9 Convert tests must keep a path) · <strong>Deps:</strong> none.</li></ul>
<h3 id="6-5-real-moadian-submission-reconciliation-imoadianclient">6.5 Real Moadian submission + reconciliation (<code>IMoadianClient</code>)</h3>
<ul><li><strong>Why:</strong> e-invoicing to سامانه مودیان is a legal obligation; today every invoice stays <code>moadian_status = pending</code> forever (mock leaves it pending; <strong>no reconciliation job or endpoint exists</strong> — the only <code>ApplyMoadianResult</code> caller is issue-time, <code>Features/Invoices/Commands/IssueInvoice/IssueInvoiceCommand.Handler.cs:72</code>).</li><li><strong>Current state:</strong> <code>CrossCutting/Seams/MockMoadianClient.cs:15-25</code>; registration <code>:65</code>; <code>Seams:Moadian:ForceRegistered</code> default false.</li><li><strong>Change:</strong> enrollment (memory/economic code + signing cert), real <code>SubmitAsync</code>, and the <code>pending → submitted → registered/failed</code> reconciliation job (register it under 4.1). The invoice number sequence and VAT-on-commission math are already correct and tested.</li><li><strong>Effort:</strong> ML (enrollment dominates) · <strong>Risk:</strong> medium · <strong>Deps:</strong> 4.1 (scheduler) for the poll.</li></ul>
<h3 id="6-6-decide-the-partner-center-settlement-rail-currently-resolver-without-money">6.6 Decide the partner-center settlement rail (currently: resolver without money)</h3>
<ul><li><strong>Why:</strong> b15 resolves merchant-of-record per booking and stores each center's encrypted <code>settlement_iban</code> + <code>commission_rate</code>, but no money path pays a center or applies its rate — the b15 report itself lists the settlement rail as a follow-up.</li><li><strong>Current state:</strong> resolver + invoice wiring real (<code>Persistence/Repositories/PartnerCenterRepository.cs:118</code>, <code>IssueInvoiceCommand.Handler.cs:63</code>); <code>IBankTransferProvider</code> consumed only by nurse payouts (<code>Controllers/V1/AdminPayoutsController.cs:44</code>); follow-up noted at <code>dev/shared-working-context/reports/backend-phase-15-report.md:83</code>.</li><li><strong>Change:</strong> product decision first (does a merchant-of-record center receive the commission split at launch, or is it bookkeeping-only?). If money moves: a center-settlement ledger account + payout command reusing the b13 machinery.</li><li><strong>Effort:</strong> ML (if built) · <strong>Risk:</strong> medium · <strong>Deps:</strong> product decision; 6.3.</li></ul>
<hr>
<h2 id="post-phase-7-observability-audit-ops-hardening">post-phase-7 — Observability, audit &amp; ops hardening</h2>
<h3 id="7-1-add-tracing-and-consolidate-the-two-metric-stacks">7.1 Add tracing and consolidate the two metric stacks</h3>
<ul><li><strong>Why:</strong> OTel is metrics-only (no <code>WithTracing</code>, no OTLP exporter) — cross-service money flows (webhook → confirm → ledger) can't be traced in production. Two overlapping Prometheus stacks run simultaneously (OTel's <code>AddPrometheusExporter</code> + prometheus-net's <code>UseMetricServer</code>/<code>UseHttpMetrics</code>).</li><li><strong>Current state:</strong> <code>Monitoring/Configurations/OpenTelemetryConfigurations.cs:11-21</code>; <code>PrometheusMetricsConfigurations.cs:11</code>; W3C activity format set but unexported (<code>Program.cs:32</code>); Serilog already enriches with span ids (<code>LoggingConfiguration.cs:24</code>).</li><li><strong>Change:</strong> add <code>WithTracing</code> (AspNetCore + EF instrumentation) exporting OTLP; pick <strong>one</strong> metrics stack; wire trace-id into the <code>ApiResult.requestId</code> for support correlation.</li><li><strong>Effort:</strong> SM · <strong>Risk:</strong> low · <strong>Deps:</strong> an OTLP-capable collector (optional at MVP; Prometheus alone is acceptable — see runtime-services.md).</li></ul>
<h3 id="7-2-broaden-health-checks-and-split-readiness-liveness">7.2 Broaden health checks and split readiness/liveness</h3>
<ul><li><strong>Why:</strong> the single check is app-DB connectivity; the log DB, object-storage root, and (future) Redis/PSP get no signal. A deploy can pass health while logging or uploads are broken.</li><li><strong>Current state:</strong> <code>Monitoring/Configurations/HealthCheckConfigurations.cs:17</code> (SQL Server only), <code>/HealthCheck</code> endpoint <code>:28</code>, dead <code>currentUrl</code> variable <code>:20</code>.</li><li><strong>Change:</strong> add checks for <code>logDb</code>, object storage (write probe), Redis when 4.2 lands; tag checks and expose <code>/healthz/live</code> (process) vs <code>/healthz/ready</code> (dependencies); remove the dead line.</li><li><strong>Effort:</strong> S · <strong>Risk:</strong> low · <strong>Deps:</strong> tracks new infra as it arrives.</li></ul>
<h3 id="7-3-revisit-production-log-levels-and-the-notification-channel-plan">7.3 Revisit production log levels and the notification-channel plan</h3>
<ul><li><strong>Why:</strong> deployed environments write <strong>only Warning+</strong> to the SQL sink — every Information-level audit trail (logins, money operations context) is dropped in production while Development keeps it. Also note: OTP codes are currently logged by design (<code>LoggingSmsSender</code>) — that must not survive 5.1.</li><li><strong>Current state:</strong> <code>CrossCutting/Logging/LoggingConfiguration.cs:40-53</code>; Elasticsearch sink referenced but commented out (<code>:58-70</code>; package still pinned at <code>server/Directory.Packages.props:51</code>).</li><li><strong>Change:</strong> Information+ to the sink with table retention (or a file/OTLP sink), structured category filters; delete the dead Elastic sink block + package (or revive it deliberately); verify no PII is logged (the SMS mock's OTP log disappears with 5.1).</li><li><strong>Effort:</strong> S · <strong>Risk:</strong> low · <strong>Deps:</strong> none.</li></ul>
<h3 id="7-4-audit-log-growth-archival">7.4 Audit-log growth &amp; archival</h3>
<ul><li><strong>Why:</strong> <code>audit_logs</code> is append-only with no archival or retention (deferred since b1); 2.3 will grow it faster. The notification purge job is the only retention job in the system.</li><li><strong>Current state:</strong> deferral recorded at <code>dev/phases/backend/backend-phase-1.md:124</code>; no purge/archive job exists for <code>ops.AuditLogs</code> (only <code>NotificationRetentionHostedService</code>).</li><li><strong>Change:</strong> a retention/archival policy (cold table or export) as a 4.1 job; define legal retention for money/verification audit rows first.</li><li><strong>Effort:</strong> SM · <strong>Risk:</strong> low · <strong>Deps:</strong> 4.1.</li></ul>
<h3 id="7-5-decide-ticketmessage-body-encryption-and-the-grpc-plugin-s-fate">7.5 Decide TicketMessage.Body encryption and the gRPC plugin's fate</h3>
<ul><li><strong>Why (tickets):</strong> ticket messages are the refund/dispute paper trail — users will type phone numbers, addresses, and clinical details. <code>TicketMessage.Body</code> is plaintext with <strong>no documented decision</strong>, unlike <code>BookingRequest.CustomerNotes</code> which carries an explicit "deliberately plaintext" comment (<code>Domain/Entities/Booking/BookingRequest.cs:45</code>).</li><li><strong>Why (gRPC):</strong> the plugin duplicates the OTP/token flow only, forces the HTTP/2 posture (1.5), and enables reflection unconditionally — cost without a consumer (the Next.js client is HTTP/JSON only).</li><li><strong>Current state:</strong> <code>Domain/Entities/Messaging/TicketMessage.cs:20</code>; <code>Plugins/Baya.Web.Plugins.Grpc/GrpcPluginStartup.cs:14-23</code>, <code>Services/UserGrpcServices.cs:18-53</code>.</li><li><strong>Change:</strong> (a) either encrypt <code>Body</code> via the existing converter pattern (accepting the search/ops cost — admin thread reads already decrypt per-row elsewhere) or add the explicit "deliberately plaintext" decision comment + docs; recommend encrypting. (b) remove the gRPC plugin or disable reflection outside Development and give it a dedicated HTTP/2 endpoint.</li><li><strong>Effort:</strong> SM · <strong>Risk:</strong> low · <strong>Deps:</strong> 1.5 pairs with (b).</li></ul>
<h3 id="7-6-keep-the-docs-honest-registry-tracker-map">7.6 Keep the docs honest (registry + tracker + map)</h3>
<ul><li><strong>Why:</strong> the mocks-registry contains stale duplicate rows — the early block still says 🔴 "not built" for <code>IDistributedLock</code>/<code>INurseSearch</code>/<code>IPaymentProvider</code>/<code>ISettlementSplitProvider</code>/<code>IWebhookVerifier</code>/ <code>IMoadianClient</code>/<code>ILicenseVerificationService</code> while later rows correct all seven (e.g. rows 15/16 vs 38/42). <code>IJobScheduler</code> is listed as a seam but no such interface exists. All 15 REQs read <code>Status: open</code>. Stale instructions are worse than none (root CLAUDE.md rule 7).</li><li><strong>Current state:</strong> <code>dev/shared-working-context/reports/mocks-registry.md:15-19,32,36</code> (stale block) vs <code>:38-50</code> (corrected rows); <code>dev/shared-working-context/frontend/requests/for-backend.md</code> (all open).</li><li><strong>Change:</strong> prune the stale registry block, rename the <code>IJobScheduler</code> row to "recurring jobs (hosted services)", mark delivered/answered REQs, and note <code>IPaymentCaptureSimulator</code>'s intended removal (6.4).</li><li><strong>Effort:</strong> S · <strong>Risk:</strong> none · <strong>Deps:</strong> post-phase-3 outcomes.</li></ul>
<hr>
<h2 id="post-phase-8-scale-later-explicitly-not-mvp">post-phase-8 — Scale &amp; later (explicitly not MVP)</h2>
<ul><li><strong>8.1 Elasticsearch read backend + outbox feeder</strong><code>SqlNurseSearch</code> is real and correct (<code>Persistence/Services/Search/SqlNurseSearch.cs:18</code>); <code>Search:Backend</code> fails fast on any non-<code>sql</code> value (<code>ServiceCollectionExtensions.cs:74-78</code>). Build <code>ElasticNurseSearch</code> + the outbox/CDC feeder per registry rows 38/43 only when SQL search shows strain. <strong>Effort:</strong> L.</li><li><strong>8.2 Analytics pipeline</strong><code>IAnalyticsSink</code> writes <code>ops.SystemEvents</code> rows fire-and-forget (<code>Persistence/Services/Analytics/AnalyticsSink.cs:15-35</code>); pipe to a warehouse/stream when product needs it. <strong>Effort:</strong> M.</li><li><strong>8.3 Holiday-calendar feed</strong> — the table is manually maintained; a lunar-Hijri drift shifts payout dates (<code>Persistence/Services/Holidays/HolidayCalendarService.cs:25-45</code>). A yearly ops checklist item is an acceptable alternative to a feed. <strong>Effort:</strong> S.</li><li><strong>8.4 Push/SMS notification channels</strong><code>InAppNotificationDispatcher</code> silently drops non-InApp channels (<code>Persistence/Services/Notifications/InAppNotificationDispatcher.cs:17</code>); add channel fan-out (SMS via 5.1's sender, FCM push) when the mobile/notification UX demands it. <strong>Effort:</strong> M.</li><li><strong>8.5 Deferred product tables</strong><code>organizations</code>, <code>organization_nurses</code>, <code>fraud_flags</code>, <code>recurring_booking_schedules</code> (b15), <code>bnpl_settlement_entries</code> (b12), nurse availability slots (b5/b8), customer national-ID KYC (b3), geo bulk import (b4) — all verified absent and all pure additive migrations when product pulls them (evidence: <code>dev/phases/backend/backend-phase-15.md:333-336</code>, <code>after-backend-phase-12.md:40</code>, <code>backend-phase-3.md:188</code>, <code>backend-phase-4.md:194</code>; model snapshot clean).</li></ul>
<hr>
<h2 id="suggested-sequencing">Suggested sequencing</h2>
<pre><code>post-phase-1 (security) ──┬──► post-phase-2 (money correctness) ──► post-phase-6 (money rails)
post-phase-3 (contract batch — parallel, unblocks frontend f9f15)
post-phase-4 (scheduler/Redis) ──► needed by 6.3/6.5 triggers
post-phase-5 (trust rails; 5.1 SMS is launch-critical, schedule early)
post-phase-7 (observability — start anytime, finish before launch)
post-phase-8 (later)</code></pre>
<p>The two items that should not wait for their bucket: <strong>1.1 (rotate the committed sa credentials — today)</strong> and <strong>2.1 (the unreachable refund clearing — before any real BNPL/manual refund exists)</strong>.</p>
</main></body></html>