Post-development backend plan — fixes & improvements
Generated from the canonical Markdown — do not hand-edit. Audit date 2026-07-10.
Audit date: 2026-07-10 · Scope: the completed backend chain (backend-phase-0 → 15) under server/ · Method: read-only audit of code + dev/ docs; every finding cites the file/line it was verified at. Companion documents: frontend-backend-gaps.md (the REQ-by-REQ contract reconciliation) and runtime-services.md (the deployment topology).
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.
| Bucket | Theme | Blocking what? |
|---|---|---|
| post-phase-1 | Security & config hygiene | Any non-local deployment |
| post-phase-2 | Money-path correctness completion | Ledger ⇄ bank reconciliation |
| post-phase-3 | Frontend-unblock contract batch | 11 of 12 client domains are still mock-primary |
| post-phase-4 | Scheduling, locking & multi-instance readiness | Unattended operation; >1 API instance |
| post-phase-5 | Identity & trust rails go real | Real nurses onboarding (OTP, KYC, docs) |
| post-phase-6 | Money rails go real | Real payments, payouts, tax |
| post-phase-7 | Observability, audit & ops hardening | Production diagnosability |
| post-phase-8 | Scale & later | Search scale, analytics, doc debt |
Status legend used below — Current state always cites what the code does today.
post-phase-1 — Security & config hygiene (do before anything is deployed)
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.
1.1 Rotate and remove the committed SQL Server sa connection string
- Why:
appsettings.jsonandappsettings.Development.jsonboth commit a real connection string — public IP87.107.152.16, loginsa, plaintext password — for the app DB and 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 rootCLAUDE.mdworking agreement #6 ("Never commit secrets"). - Current state:
server/src/API/Baya.Web.Api/appsettings.json:3-4andappsettings.Development.json:3-4(byte-identical files); consumed atserver/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs:41and by the Serilog sink atserver/src/Infrastructure/Baya.Infrastructure.CrossCutting/Logging/LoggingConfiguration.cs:44. - Change: rotate the
sapassword 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. Considergit filter-repohistory scrubbing, and add a secret-scanning pre-commit hook. - Files/layers:
appsettings*.jsononly (config). - Effort: S · Risk: low (config move) · Deps: none. Do this first.
1.2 Replace placeholder JWE signing/encryption keys and field-encryption keys
- Why: the JWT/JWE
SecretKey/Encryptkeyare starter-template placeholders and the PII field-encryption keys are committedlocal-dev-…-change-mestrings. 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 (ExpirationMinutes: 10000) andRequireHttpsMetadata = false. - Current state:
server/src/API/Baya.Web.Api/appsettings.json:7-8(keys),:12(expiry);Seams:FieldEncryptionatappsettings.json:15-18; the JWE decryption key is wired atserver/src/Infrastructure/Baya.Infrastructure.Identity/ServiceConfiguration/ServiceCollectionExtension.cs:135andRequireHttpsMetadata = falseat:139.SymmetricFieldEncryptorderives a real AES-256-CBC key from whatever string is configured (server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SymmetricFieldEncryptor.cs:25) — the crypto is fine; the key management is dev-grade (mocks-registry rowIFieldEncryptor🟡 agrees). - Change: 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),
RequireHttpsMetadata = trueoutside Development, realIssuer/Audiencevalues (currently"MyWebsite"). Note: rotating the field key requires a re-encryption migration for existing rows — do it before real PII exists. - Effort: S (config) + M if key-rotation tooling is wanted · Risk: medium (existing dev-DB ciphertext becomes unreadable — acceptable pre-launch) · Deps: 1.1.
1.3 Remove or environment-gate the seeded admin / qw123321 user
- Why: every non-Testing boot creates a well-known admin account with a hardcoded weak password and the full admin role — in production too.
- Current state:
server/src/Infrastructure/Baya.Infrastructure.Identity/Identity/SeedDatabaseService/SeedDataBase.cs:48(CreateAsync(user, "qw123321")), invoked fromserver/src/API/Baya.Web.Api/Program.cs:102. - Change: read the bootstrap admin credentials from configuration and only seed when explicitly configured (or Development-only); force a password change on first login.
- Effort: S · Risk: low · Deps: none.
1.4 Environment-gate the auto-seeded sandbox ZarinPal payment gateway
- Why:
SeedPaymentGatewaysAsyncidempotently inserts an active sandbox ZarinPal gateway row (all-zeros merchant id) on every boot — a production DB would silently contain an active sandbox money gateway. - Current state:
server/src/API/Baya.Web.Api/Program.cs:103→server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs:106-117(sandbox row,config_jsonencrypted via the DbContext converter). - Change: seed only in Development/Testing, or seed
is_active = falseand require an admin to activate a real gateway (payment_gatewaysis already admin data). - Effort: S · Risk: low · Deps: none; pairs with 6.1.
1.5 Fix the Kestrel HTTP/2-only default
- Why:
Kestrel:EndpointDefaults:Protocols = "Http2"(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 becausehttps://localhost:5002negotiates via ALPN. - Current state:
server/src/API/Baya.Web.Api/appsettings.json:29-33(both files). The gRPC plugin that motivated it serves a single duplicate OTP/token service (server/src/API/Plugins/Baya.Web.Plugins.Grpc/Services/UserGrpcServices.cs:18). - Change: default
Http1AndHttp2; give gRPC its ownHttp2endpoint if kept (see 7.5 for the keep-or-remove decision). - Effort: S · Risk: low · Deps: none.
1.6 Make rate limiting proxy-aware and align the two payment webhooks
- Why: all rate-limit partitions key on
RemoteIpAddressand noForwardedHeadersmiddleware 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/minsensitivepolicy while the card webhook has only the global 100/min fallback — one of them is wrong on purpose or both by accident. - Current state: partition key at
server/src/API/Baya.WebFramework/ServiceConfiguration/RateLimitingServiceExtension.cs:69; global limiter:35;Program.cshas noUseForwardedHeaders(checkedserver/src/API/Baya.Web.Api/Program.cs). Webhooks:Controllers/V1/WebhooksBnplController.cs:28(sensitive) vsControllers/V1/WebhooksController.cs:25([AllowAnonymous], global only). - Change: add
ForwardedHeadersmiddleware (trusting only the known proxy), partition on the resolved client IP, and pick one deliberate webhook policy (PSP callbacks are bursty — a dedicatedwebhookpolicy keyed per-provider is safer thansensitive). - Effort: S · Risk: low–medium (limiter behavior changes) · Deps: deployment topology decision.
post-phase-2 — Money-path correctness completion
The ledger invariants verified clean (balanced groups throw at server/src/Core/Baya.Domain/Entities/Payments/LedgerPosting.cs:26,65,178,204; the four DB CHECKs exist — CK_Bookings_AmountSplit, CK_NursePayouts_NetSplit, CK_Refunds_LegSplit, CK_BnplTransactions_SettleSplit — per the EF configs and Migrations/ApplicationDbContextModelSnapshot.cs:345,3543,3842,206). This bucket closes the holes around those invariants.
2.1 Wire the unreachable BNPL/manual refund settlement (dead-end money state) — top code fix
- Why: a card refund posts its
refund_payable ↔ escrow_heldclearing immediately. A BNPL-revert or manual-bank refund is left inprocessingwith the clearing "deferred to reconciliation" — but no reconciliation path exists anywhere:Refund.MarkSucceededAsynchas zero call sites, no admin endpoint/webhook/job performsprocessing → succeeded, andLedgerPosting.RefundPayableClearinghas exactly one call site (the immediate card path). Every BNPL/manual refund permanently overstatesescrow_heldand strandsrefund_payable— the ledger will never reconcile with the bank. - Current state:
server/src/Core/Baya.Domain/Entities/Refunds/Refund.cs:105(uncalled);server/src/Core/Baya.Application/Features/Refunds/Commands/CreateRefund/CreateRefundCommand.Handler.cs:139-143(clearing only when alreadySucceeded);:226-231(BNPL/manual →MarkProcessing, never succeeded); theProcessing → Succeedededge exists unused inserver/src/Core/Baya.Domain/Entities/Refunds/RefundTransitions.cs:16; admin surface is create+list only (Controllers/V1/AdminRefundsController.cs:33). The b11 handoff promised at least a manual trigger (dev/shared-working-context/backend/handoff/after-backend-phase-11.md:37). - Change: a
ConfirmRefundSettlementCommand(adminPOST admin_refunds/{id}/confirm_settlement, plus a BNPL-callback branch when the provider confirms customer cash-back) that transitionsprocessing → succeeded, stampssettled_at, and postsLedgerPosting.RefundPayableClearingin the same commit; amark_failedcounterpart. Tests for both channels. - Files/layers: Application
Features/Refunds/, APIAdminRefundsController, Domain (renameMarkSucceededAsync— it's not async), tests. - Effort: M · Risk: medium (money path — but additive) · Deps: none; do before real BNPL (6.2).
2.2 Add the promised-but-missing FKs on the forward-dep columns
- Why: b11 created
refunds.ticket_id,nurse_clawbacks.original_payout_id/recovered_in_payout_id, andinvoices.partner_center_idas FK-less nullable columns "until the target table ships". The targets all shipped (b13nurse_payouts, b15tickets/partner_centers) and the values are wired, but no phase added the constraints — referential integrity rests on application discipline, and the config comments are now false. - Current state: stale comment "no FK yet (tickets does not exist)" at
server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/RefundsConfig/RefundConfig.cs:41;NurseClawbackConfig.cs:12(comment says b13 "wires the FKs" — it didn't; b13'sNursePayoutEnginemigration touches no clawback FK);InvoicesConfig/InvoiceConfig.cs:11(partner_center_idhas no FK and no index); the b15 migration adds onlyFK_NurseProfiles_PartnerCenters_PartnerCenterId(Migrations/20260709232741_MessagingAndPartnerCenters.cs:293). Value-side wiring confirmed:CreateRefundCommand.Handler.cs:53-61(auto-ticket),Refunds/NurseClawback.cs:50(MarkRecovered),IssueInvoiceCommand.Handler.cs:63-64(issuer/center). - Change: one additive migration adding the three FK sets (
ON DELETE NO ACTION) + an index oninvoices.partner_center_id; update the three config comments. - Effort: S · Risk: low (data is young; verify no orphans first) · Deps: none.
2.3 Extend IAuditable to the admin-decided money & trust entities
- Why: the append-only
audit_logsdiff interceptor covers exactly three entities —PlatformConfig,PartnerCenter,Review. Admin decisions on refunds (approve/reject), payouts (process/retry/fail), and nurse verification (the trust-criticalis_verifiedflip / 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,LedgerEntryisIEntity-only atserver/src/Core/Baya.Domain/Entities/Payments/LedgerEntry.cs:13.) - Current state: the three implementors —
Domain/Entities/Configuration/PlatformConfig.cs:12,Domain/Entities/PartnerCenters/PartnerCenter.cs:16,Domain/Entities/Reviews/Review.cs:15.Refund(Domain/Entities/Refunds/Refund.cs:77) andNursePayout(Domain/Entities/Payouts/NursePayout.cs:62) are notIAuditable;NurseVerificationisn't either. - Change: add
IAuditabletoRefund,NurseClawback,NursePayout,NursePayoutBatch,NurseVerification(the interceptor atPersistence/Interceptors/AuditFieldInterceptoralready handles anyIAuditable); confirm[AuditRedacted]coversiban_snapshotbefore enabling. - Effort: S–M · Risk: low (write-volume growth on
audit_logs; see 7.4 archival) · Deps: none.
2.4 Close the refund channel-execute-before-commit crash window
- Why:
CreateRefundCommandexecutes the external channel call (PSP refund / BNPL revert) before the first DB commit — a crash between provider success and commit loses the record of an executed refund. The idempotency key means a retry won't double-refund, but nothing retries automatically and no record exists to reconcile against. - Current state:
Features/Refunds/Commands/CreateRefund/CreateRefundCommand.Handler.cs:106-111(channel executes), commit later in the same handler; idempotency key at:85. - Change: persist the refund row in
pendingstate (commit) before the channel call, then execute and update — the standard two-phase intent/confirm shape the webhook handler already uses (claim-key-first atFeatures/Payments/Commands/HandlePaymentWebhook/HandlePaymentWebhookCommand.Handler.cs:76-88). - Effort: M · Risk: medium (touches the refund state machine; full test pass required) · Deps: do together with 2.1.
2.5 Test the untested admin money paths
- Why:
WriteOffClawbackCommand— an admin action that posts abad_debtledger group — has zero tests (grep acrossserver/src/Testsfinds noWriteOffmatch). The webhook duplicate-race path (DbUpdateExceptionon a concurrent same-key insert) is only exercised sequentially. Messaging has no Foundation-level handler tests (theis_internalboundary is covered only end-to-end inTests/Baya.Test.Api/MessagingApiTests.cs:33). - Current state: handler at
Features/Refunds/Commands/WriteOffClawback/WriteOffClawbackCommand.Handler.cs:26; coverage inventory: 49 Foundation + 33 Api test files; payout retry / webhook replay / clawback fork / rebuild convergence are tested (Tests/Baya.Test.Foundation/Payouts/PayoutHandlerTests.cs:211,Payments/PaymentWebhookTests.cs:64,Refunds/RefundHandlerTests.cs:81,Search/SearchIndexTests.cs:184). - Change: add write-off unit + API tests (balanced group, idempotency, 404/409 paths), a true racing webhook-insert test, and Foundation tests for
PostMessage/GetTicketThreadinternal-flag handling. - Effort: S–M · Risk: none · Deps: none.
2.6 Retire the orphaned refund_ticket_required config key
- Why: 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.
- Current state: seed row at
Persistence/Configuration/ConfigurationConfig/PlatformConfigConfig.cs:49; auto-open atCreateRefundCommand.Handler.cs:53-61; only remaining reference is a test stub (Tests/Baya.Test.Foundation/Refunds/RefundsTestHost.cs:174). - Change: delete the seed row (migration) or repurpose it to gate whether a customer-visible ticket is required; update the description either way.
- Effort: S · Risk: low · Deps: none.
post-phase-3 — Frontend-unblock contract batch
Full field-level detail, evidence, and priority ordering live in frontend-backend-gaps.md. Summary: of the 15 filed REQs, REQ-001 and REQ-015 are effectively done, REQ-010 is a doc fix, and the other 12 are undelivered; 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:
- 3.1 Booking-surface fields (S):
variantPriceonBookingRequestDto,variantLabel(+ optionalpatientAge) onBookingRequestListItemDto(REQ-013/014). - 3.2 Identity/profile fields (S–M): patient
relation+conditions(REQ-005); customer name/preferred-language upsert (REQ-007); avatar upload endpoint +avatarUrl(REQ-006 — the only item needing multipart +IObjectStorage). - 3.3 Address fields (S): accept the client map pin on create/update (REQ-008 — matters for EVV accuracy later) +
provinceIdonCustomerAddressDto(REQ-009). - 3.4 Search & public profile (M — the single highest-leverage item): enrich
NurseSearchResultDtowithnurseName/avatarUrl(+ optionaldistanceKm) and add the aggregated publicGET nurses/{id}/profile(REQ-012). Unblocks the discovery funnel (C2/C3). - 3.5 Verification details (S–M): nurse-facing
credential_detailscommand +isRequiredonVerificationStepDto(REQ-011 — without it the INO number/specialties are silently dropped). - 3.6 Auth polish (S):
codeLength/expiresInSecondsonRequestOtpResult(REQ-002); machine-readablecode(+retryAfterSeconds) on OTP failures (REQ-003 — needs a smallOperationResult/envelope extension, the only cross-cutting piece). - 3.7 Zero-code confirmations & doc fixes (S): answer REQ-001/004/015 in the tracker; fix the stale
page_sizeoccurrences indev/contracts/domains/*.md(REQ-010 — the server verifiably binds camelCasepageSize); mark every delivered REQdelivered in …(all 15 currently readStatus: open).
Effort: one M-sized phase overall · Risk: low (additive DTO fields; regenerate dev/contracts/openapi/swagger.v1.json after) · Deps: none — can run in parallel with post-phase-1/2.
post-phase-4 — Scheduling, locking & multi-instance readiness
4.1 Real job scheduler + register the four deferred crons
- Why: 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 weekly payout batch, 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 nurses are not paid unless an operator clicks.
- Current state: the two hosted services at
Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs:63,67(intervals hardcoded atServices/Notifications/NotificationRetentionHostedService.cs:19andServices/Booking/BookingRequestExpiryHostedService.cs:21). Manual triggers:Controllers/V1/AdminVerificationsController.cs:49(scan_expiring),AdminEvvController.cs:37(detect_no_shows),AdminPayoutsController.cs:44(batch generate). Unconsumed cadence keys:verification_expiry_scan_cadence_hours,no_show_scan_cadence_hours,nurse_payout_interval_days(PlatformConfigConfig.cs:46,48,36). Note: noIJobSchedulerinterface exists — the registry row is aspirational naming; there is nothing to swap behind, only hosted services to re-home. No Hangfire/Quartz package is referenced (server/Directory.Packages.props— verified absent). - Change: 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 generation, keep
processmanual until trust is earned. - Effort: M–L · Risk: medium (new infra dependency in-app; jobs must stay idempotent — they already are by design) · Deps: none hard; pairs with 4.2 for multi-instance.
4.2 Redis for ICacheService + IDistributedLock
- Why: 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 (
booking:{id}:payment/:refund) is a per-keySemaphoreSlim— no cross-instance protection (DB uniques remain the correctness backstop, as designed, but the lock is doing nothing across nodes). - Current state:
CrossCutting/Seams/MemoryCacheService.cs:11andInProcessDistributedLock.cs:14-22, registered atCrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs:27,61. No Redis package referenced. - Change: add
StackExchange.Redis;RedisCacheService(same key/TTL scheme) +RedisDistributedLock(SET NX PX + token-checked release, lease ≥ the longest money handler); config-selected registration per the registry's make-it-real steps (rowsICacheService,IDistributedLock). - Effort: M · Risk: medium (lock semantics under expiry; keep DB uniques authoritative) · Deps: Redis service (see runtime-services.md).
4.3 Separate migrations from boot (multi-instance + least privilege)
- Why: every non-Testing boot runs
MigrateAsync+ three seeders — concurrent instance start-ups race on DDL (no distributed lock exists at boot), and the app login needs permanent DDL rights. - Current state:
server/src/API/Baya.Web.Api/Program.cs:99-104;Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs:92(MigrateAsyncunconditional). - Change: a deploy-time migration step (
dotnet ef database updatein CI, or a--migrateone-shot mode) and boot-time schema check instead of apply; seeders become idempotent deploy steps. - Effort: S–M · Risk: low · Deps: CI/CD pipeline exists.
post-phase-5 — Identity & trust rails go real
Ordered by user impact: nobody can log in without 5.1.
5.1 Real SMS gateway behind ISmsSender — launch-critical
- Why: 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.
- Current state:
CrossCutting/Seams/LoggingSmsSender.cs:12-16(logs the code, phone last-4), registered atServiceCollectionExtension.cs:32. NoSeams:Smsoptions exist yet (verified:SeamOptions.cshas no Sms group). - Change: per registry row
ISmsSender: pick Kavenegar/Ghasedak/SMS.ir, addSeams:Sms:{ApiKey, SenderLine,BaseUrl}options + package, implement pattern/template OTP send, config-selected registration. Keep the per-phone resend window andotprate policy untouched. - Effort: M · Risk: low (isolated seam) · Deps: vendor account.
5.2 Real Shahkar + e-KYC vendors (IShahkarVerifier, IIdentityKycProvider)
- Why: 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.
- Current state:
CrossCutting/Seams/MockShahkarVerifier.cs:17-37,MockIdentityKycProvider.cs:17-25; registrations:45-46; config defaultsSeamOptions.cs:157-184. The pipeline already persistsexternal_response_jsonand handles shared-SIM/mismatch as explicit states — the handler side is ready. - Change: 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 (
ShahkarVerifiedAtreset on phone change — b2). - Effort: M–L (vendor onboarding dominates) · Risk: medium (real-world failure modes) · Deps: vendor contract; 5.1 not required but sensible first.
5.3 Real استعلام شبا (IBankAccountOwnershipVerifier)
- Why: the b13 first-payout gate (
matched_national_id = 1) is currently satisfied by a mock that matches every IBAN except one magic value — real money would flow against unverified account ownership. - Current state:
CrossCutting/Seams/MockBankAccountOwnershipVerifier.cs:17-26(nurseNationalId deliberately ignored, comment:21-22); registration:36; defaultsSeamOptions.cs:204. - Change: per registry row; verify the payout gate end-to-end (
is_primary=1 AND is_verified=1 AND matched_national_id=1skip-with-reason path already exists in b13). - Effort: M · Risk: medium (money gate) · Deps: same vendor family as 5.2 — bundle them.
5.4 Real geocoder (IGeocoder) behind Neshan
- Why: 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.
- Current state:
CrossCutting/Seams/MockGeocoder.cs:15-52; registration:40;Seams:Geocodingis one of only three seam sections present inappsettings.json:22-26. - Change: per registry row (Neshan client, rate-limit/retry, keep the null-coordinate path).
- Effort: M · Risk: low · Deps: REQ-008 first (pin > geocode for EVV).
5.5 Real object storage (IObjectStorage) — MinIO/S3/ArvanCloud
- Why: verification documents (and future avatars/invoice PDFs) live on the API host's local disk under a temp root — non-durable, non-shared, and
GetUrlreturns afile://URI rather than a presigned URL, so the b6 "short-lived signed URL" contract is only shape-deep. - Current state:
CrossCutting/Seams/LocalDiskObjectStorage.cs:12-56(temp-dir fallback:20,file://URL);Seams:ObjectStorage:RootPathempty inappsettings.json:19-21. - Change: per registry row: S3-compatible client, presigned PUT/GET with expiry, bucket + creds from config; migrate any existing dev files or reset.
- Effort: M · Risk: low · Deps: storage service; REQ-006 (avatar) builds on this.
5.6 Accept manual as the real path for MoH/INO credentials and partner licensing (document, don't build)
- Why:
ICredentialVerifierandILicenseVerificationServicereturn "needs manual review" by design — there is no public MoH/INO/eNamad B2B API today. The admin review flows are the real mechanism; the seams exist so a portal API can slot in if one appears. - Current state:
CrossCutting/Seams/MockCredentialVerifier.cs:18,MockLicenseVerificationService.cs:26; registry rows agree ("no public B2B API today"). - Change: none in code. Mark these 🟡 rows as "manual = intended MVP state" in the registry so they stop reading as debt.
- Effort: S (docs) · Risk: none.
post-phase-6 — Money rails go real
The seam shapes are faithful (idempotency keys, server-side re-verify, upsert-first webhooks are already the handler behavior — verified at HandlePaymentWebhookCommand.Handler.cs:31-88), so each swap is an adapter, not a redesign. Do 2.1 first so the BNPL refund path is complete before real money uses it.
6.1 Real PSP/IPG + webhook signatures + تسهیم (IPaymentProvider, IWebhookVerifier, ISettlementSplitProvider)
- Why: card capture, callback authenticity, and the settlement split are all deterministic mocks (
VerifyAsyncalways 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. - Current state:
CrossCutting/Seams/MockPaymentProvider.cs:14-24,MockWebhookVerifier.cs:15-21,MockSettlementSplitProvider.cs:13; registrations:58-60; merchant creds intended to come from the encryptedpayment_gateways.config_json(seed atPersistence/ServiceCollectionExtensions.cs:109-117). - Change: per registry rows 39–41: one acquirer-with-تسهیم (ZarinPal/Sadad/Vandar/Jibit); real
InitPaymentAsync/VerifyAsync/RefundAsync; per-provider HMAC verification of the raw body; a provider registry/factory selected per gateway row; persist full gateway responses. - Effort: L · Risk: high (real money; Shaparak certification lead time) · Deps: merchant registration; 1.4; 2.1; 4.2 (real lock) strongly recommended.
6.2 Real BNPL adapters (IBnplProvider / IBnplProviderResolver)
- Why: the full BNPL state machine runs against one mock provider; per-contract commission and non-instant settlement are simulated by config.
- Current state:
CrossCutting/Seams/MockBnplProvider.cs:17-53,MockBnplProviderResolver.cs:14-17; registrations:72-74;Seams:Bnpl:*defaultsSeamOptions.cs:98-120.bnpl_settlement_entries(tranched settlement) is modeled-but-not-built by design (dev/shared-working-context/backend/handoff/after-backend-phase-12.md:40). - Change: per registry row 46: SnappPay (OAuth verb set) and/or Digipay adapters, creds from encrypted gateway config, Toman↔IRR only via
ICurrencyNormalizer, per-contract commission read from the settle response, resolver registered perprovider_code. - Effort: L · Risk: high · Deps: 2.1 (revert clearing), 6.1 patterns, provider contracts.
6.3 Real PAYA/SATNA payout rail (IBankTransferProvider) + async reconciliation
- Why: payouts "settle" instantly in the mock, collapsing the real
submitted → paid/failedasync reconciliation; no money reaches nurses. - Current state:
CrossCutting/Seams/MockBankTransferProvider.cs:18-31; registration:82;Seams:BankTransferdefaultsSeamOptions.cs:61-72. The status machine, batch idempotency key,nurse_payout_booking_linksUNIQUE (Persistence/Configuration/PayoutsConfig/NursePayoutBookingLinkConfig.cs:22), and PAYA/SATNA threshold selection already exist. - Change: per registry row 23: Jibit/Vandar/Sadad payout API, source settlement account config, the async callback that flips
submitted → paid/failed, batch caps/minimums. Keep whole-batch/single-row failure + retry semantics (already tested atTests/.../Payouts/PayoutHandlerTests.cs:211). - Effort: L · Risk: high (irreversible transfers — the UNIQUE link + ledger-exists guard are the backstops, and they're in place) · Deps: 5.3 (real ownership check) before real runs; 4.1 for the weekly trigger.
6.4 Remove IPaymentCaptureSimulator (the registry's own end-state)
- Why: 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.
- Current state: registered at
CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs:52;Seams:PaymentCaptureoptionsSeamOptions.cs:142. - Change: 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.
- Effort: S · Risk: low (b9 Convert tests must keep a path) · Deps: none.
6.5 Real Moadian submission + reconciliation (IMoadianClient)
- Why: e-invoicing to سامانه مودیان is a legal obligation; today every invoice stays
moadian_status = pendingforever (mock leaves it pending; no reconciliation job or endpoint exists — the onlyApplyMoadianResultcaller is issue-time,Features/Invoices/Commands/IssueInvoice/IssueInvoiceCommand.Handler.cs:72). - Current state:
CrossCutting/Seams/MockMoadianClient.cs:15-25; registration:65;Seams:Moadian:ForceRegistereddefault false. - Change: enrollment (memory/economic code + signing cert), real
SubmitAsync, and thepending → submitted → registered/failedreconciliation job (register it under 4.1). The invoice number sequence and VAT-on-commission math are already correct and tested. - Effort: M–L (enrollment dominates) · Risk: medium · Deps: 4.1 (scheduler) for the poll.
6.6 Decide the partner-center settlement rail (currently: resolver without money)
- Why: b15 resolves merchant-of-record per booking and stores each center's encrypted
settlement_iban+commission_rate, but no money path pays a center or applies its rate — the b15 report itself lists the settlement rail as a follow-up. - Current state: resolver + invoice wiring real (
Persistence/Repositories/PartnerCenterRepository.cs:118,IssueInvoiceCommand.Handler.cs:63);IBankTransferProviderconsumed only by nurse payouts (Controllers/V1/AdminPayoutsController.cs:44); follow-up noted atdev/shared-working-context/reports/backend-phase-15-report.md:83. - Change: 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.
- Effort: M–L (if built) · Risk: medium · Deps: product decision; 6.3.
post-phase-7 — Observability, audit & ops hardening
7.1 Add tracing and consolidate the two metric stacks
- Why: OTel is metrics-only (no
WithTracing, no OTLP exporter) — cross-service money flows (webhook → confirm → ledger) can't be traced in production. Two overlapping Prometheus stacks run simultaneously (OTel'sAddPrometheusExporter+ prometheus-net'sUseMetricServer/UseHttpMetrics). - Current state:
Monitoring/Configurations/OpenTelemetryConfigurations.cs:11-21;PrometheusMetricsConfigurations.cs:11; W3C activity format set but unexported (Program.cs:32); Serilog already enriches with span ids (LoggingConfiguration.cs:24). - Change: add
WithTracing(AspNetCore + EF instrumentation) exporting OTLP; pick one metrics stack; wire trace-id into theApiResult.requestIdfor support correlation. - Effort: S–M · Risk: low · Deps: an OTLP-capable collector (optional at MVP; Prometheus alone is acceptable — see runtime-services.md).
7.2 Broaden health checks and split readiness/liveness
- Why: 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.
- Current state:
Monitoring/Configurations/HealthCheckConfigurations.cs:17(SQL Server only),/HealthCheckendpoint:28, deadcurrentUrlvariable:20. - Change: add checks for
logDb, object storage (write probe), Redis when 4.2 lands; tag checks and expose/healthz/live(process) vs/healthz/ready(dependencies); remove the dead line. - Effort: S · Risk: low · Deps: tracks new infra as it arrives.
7.3 Revisit production log levels and the notification-channel plan
- Why: deployed environments write only Warning+ 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 (
LoggingSmsSender) — that must not survive 5.1. - Current state:
CrossCutting/Logging/LoggingConfiguration.cs:40-53; Elasticsearch sink referenced but commented out (:58-70; package still pinned atserver/Directory.Packages.props:51). - Change: 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).
- Effort: S · Risk: low · Deps: none.
7.4 Audit-log growth & archival
- Why:
audit_logsis 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. - Current state: deferral recorded at
dev/phases/backend/backend-phase-1.md:124; no purge/archive job exists forops.AuditLogs(onlyNotificationRetentionHostedService). - Change: a retention/archival policy (cold table or export) as a 4.1 job; define legal retention for money/verification audit rows first.
- Effort: S–M · Risk: low · Deps: 4.1.
7.5 Decide TicketMessage.Body encryption and the gRPC plugin's fate
- Why (tickets): ticket messages are the refund/dispute paper trail — users will type phone numbers, addresses, and clinical details.
TicketMessage.Bodyis plaintext with no documented decision, unlikeBookingRequest.CustomerNoteswhich carries an explicit "deliberately plaintext" comment (Domain/Entities/Booking/BookingRequest.cs:45). - Why (gRPC): 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).
- Current state:
Domain/Entities/Messaging/TicketMessage.cs:20;Plugins/Baya.Web.Plugins.Grpc/GrpcPluginStartup.cs:14-23,Services/UserGrpcServices.cs:18-53. - Change: (a) either encrypt
Bodyvia 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. - Effort: S–M · Risk: low · Deps: 1.5 pairs with (b).
7.6 Keep the docs honest (registry + tracker + map)
- Why: the mocks-registry contains stale duplicate rows — the early block still says 🔴 "not built" for
IDistributedLock/INurseSearch/IPaymentProvider/ISettlementSplitProvider/IWebhookVerifier/IMoadianClient/ILicenseVerificationServicewhile later rows correct all seven (e.g. rows 15/16 vs 38/42).IJobScheduleris listed as a seam but no such interface exists. All 15 REQs readStatus: open. Stale instructions are worse than none (root CLAUDE.md rule 7). - Current state:
dev/shared-working-context/reports/mocks-registry.md:15-19,32,36(stale block) vs:38-50(corrected rows);dev/shared-working-context/frontend/requests/for-backend.md(all open). - Change: prune the stale registry block, rename the
IJobSchedulerrow to "recurring jobs (hosted services)", mark delivered/answered REQs, and noteIPaymentCaptureSimulator's intended removal (6.4). - Effort: S · Risk: none · Deps: post-phase-3 outcomes.
post-phase-8 — Scale & later (explicitly not MVP)
- 8.1 Elasticsearch read backend + outbox feeder —
SqlNurseSearchis real and correct (Persistence/Services/Search/SqlNurseSearch.cs:18);Search:Backendfails fast on any non-sqlvalue (ServiceCollectionExtensions.cs:74-78). BuildElasticNurseSearch+ the outbox/CDC feeder per registry rows 38/43 only when SQL search shows strain. Effort: L. - 8.2 Analytics pipeline —
IAnalyticsSinkwritesops.SystemEventsrows fire-and-forget (Persistence/Services/Analytics/AnalyticsSink.cs:15-35); pipe to a warehouse/stream when product needs it. Effort: M. - 8.3 Holiday-calendar feed — the table is manually maintained; a lunar-Hijri drift shifts payout dates (
Persistence/Services/Holidays/HolidayCalendarService.cs:25-45). A yearly ops checklist item is an acceptable alternative to a feed. Effort: S. - 8.4 Push/SMS notification channels —
InAppNotificationDispatchersilently drops non-InApp channels (Persistence/Services/Notifications/InAppNotificationDispatcher.cs:17); add channel fan-out (SMS via 5.1's sender, FCM push) when the mobile/notification UX demands it. Effort: M. - 8.5 Deferred product tables —
organizations,organization_nurses,fraud_flags,recurring_booking_schedules(b15),bnpl_settlement_entries(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:dev/phases/backend/backend-phase-15.md:333-336,after-backend-phase-12.md:40,backend-phase-3.md:188,backend-phase-4.md:194; model snapshot clean).
Suggested sequencing
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)
The two items that should not wait for their bucket: 1.1 (rotate the committed sa credentials — today) and 2.1 (the unreachable refund clearing — before any real BNPL/manual refund exists).