182 lines
64 KiB
HTML
182 lines
64 KiB
HTML
<!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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & multi-instance readiness</td><td>Unattended operation; >1 API instance</td></tr><tr><td>post-phase-5</td><td>Identity & 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 & ops hardening</td><td>Production diagnosability</td></tr><tr><td>post-phase-8</td><td>Scale & 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 & 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> low–medium (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 & 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> S–M · <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> S–M · <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 (S–M):</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 & 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 (S–M):</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 & 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 & 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> M–L · <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> S–M · <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 & 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> M–L (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 > 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 39–41: 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> M–L (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> M–L (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 & 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> S–M · <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 & 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> S–M · <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> S–M · <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 & 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 f9–f15)
|
||
│
|
||
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> |