Files
baya-monorepo/archive/post-phase/server/post-phase-backend-plan.md
T
2026-08-02 18:48:32 +03:30

602 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Post-development backend plan — fixes & improvements
**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](frontend-backend-gaps.md) (the REQ-by-REQ contract
reconciliation) and [runtime-services.md](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.json` and `appsettings.Development.json` both commit a real connection string —
public IP `87.107.152.16`, login `sa`, 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 root `CLAUDE.md` working agreement #6 ("Never commit secrets").
- **Current state:** `server/src/API/Baya.Web.Api/appsettings.json:3-4` and
`appsettings.Development.json:3-4` (byte-identical files); consumed at
`server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs:41`
and by the Serilog sink at
`server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Logging/LoggingConfiguration.cs:44`.
- **Change:** rotate the `sa` 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 `git filter-repo` history scrubbing, and add a secret-scanning pre-commit hook.
- **Files/layers:** `appsettings*.json` only (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`/`Encryptkey` are starter-template placeholders and the PII
field-encryption keys are committed `local-dev-…-change-me` 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 (`ExpirationMinutes: 10000`) and `RequireHttpsMetadata = false`.
- **Current state:** `server/src/API/Baya.Web.Api/appsettings.json:7-8` (keys), `:12` (expiry);
`Seams:FieldEncryption` at `appsettings.json:15-18`; the JWE decryption key is wired at
`server/src/Infrastructure/Baya.Infrastructure.Identity/ServiceConfiguration/ServiceCollectionExtension.cs:135`
and `RequireHttpsMetadata = false` at `:139`. `SymmetricFieldEncryptor` derives 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 row `IFieldEncryptor` 🟡 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 = true` outside
Development, real `Issuer`/`Audience` values (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 from `server/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:** `SeedPaymentGatewaysAsync` idempotently 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_json` encrypted via the DbContext converter).
- **Change:** seed only in Development/Testing, or seed `is_active = false` and require an admin to
activate a real gateway (`payment_gateways` is 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 because `https://localhost:5002` negotiates
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 own `Http2` endpoint 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 `RemoteIpAddress` and no `ForwardedHeaders` 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 `sensitive` policy 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.cs` has no `UseForwardedHeaders` (checked `server/src/API/Baya.Web.Api/Program.cs`).
Webhooks: `Controllers/V1/WebhooksBnplController.cs:28` (`sensitive`) vs `Controllers/V1/WebhooksController.cs:25`
(`[AllowAnonymous]`, global only).
- **Change:** add `ForwardedHeaders` middleware (trusting only the known proxy), partition on the resolved
client IP, and pick one deliberate webhook policy (PSP callbacks are bursty — a dedicated `webhook`
policy keyed per-provider is safer than `sensitive`).
- **Effort:** S · **Risk:** lowmedium (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_held` clearing immediately. A BNPL-revert or
manual-bank refund is left in `processing` with the clearing "deferred to reconciliation" — but **no
reconciliation path exists anywhere**: `Refund.MarkSucceededAsync` has zero call sites, no admin
endpoint/webhook/job performs `processing → succeeded`, and `LedgerPosting.RefundPayableClearing` has
exactly one call site (the immediate card path). Every BNPL/manual refund permanently overstates
`escrow_held` and strands `refund_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 already `Succeeded`); `:226-231` (BNPL/manual → `MarkProcessing`, never succeeded);
the `Processing → Succeeded` edge exists unused in
`server/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` (admin `POST admin_refunds/{id}/confirm_settlement`, plus a
BNPL-callback branch when the provider confirms customer cash-back) that transitions
`processing → succeeded`, stamps `settled_at`, and posts `LedgerPosting.RefundPayableClearing` in the same
commit; a `mark_failed` counterpart. Tests for both channels.
- **Files/layers:** Application `Features/Refunds/`, API `AdminRefundsController`, Domain (rename
`MarkSucceededAsync` — 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`, and `invoices.partner_center_id` as FK-less nullable columns "until the target
table ships". The targets all shipped (b13 `nurse_payouts`, b15 `tickets`/`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's `NursePayoutEngine`
migration touches no clawback FK); `InvoicesConfig/InvoiceConfig.cs:11` (`partner_center_id` has **no FK
and no index**); the b15 migration adds only `FK_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 on
`invoices.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_logs` diff interceptor covers exactly three entities — `PlatformConfig`,
`PartnerCenter`, `Review`. Admin decisions on refunds (approve/reject), payouts (process/retry/fail), and
**nurse verification** (the trust-critical `is_verified` 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, `LedgerEntry` is `IEntity`-only at
`server/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`) and `NursePayout` (`Domain/Entities/Payouts/NursePayout.cs:62`)
are not `IAuditable`; `NurseVerification` isn't either.
- **Change:** add `IAuditable` to `Refund`, `NurseClawback`, `NursePayout`, `NursePayoutBatch`,
`NurseVerification` (the interceptor at `Persistence/Interceptors/AuditFieldInterceptor` already handles
any `IAuditable`); confirm `[AuditRedacted]` covers `iban_snapshot` before enabling.
- **Effort:** SM · **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:** `CreateRefundCommand` executes 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 `pending` state (commit) *before* the channel call, then execute and
update — the standard two-phase intent/confirm shape the webhook handler already uses
(claim-key-first at `Features/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 a `bad_debt` ledger group — has **zero
tests** (grep across `server/src/Tests` finds no `WriteOff` match). The webhook duplicate-race path
(`DbUpdateException` on a concurrent same-key insert) is only exercised sequentially. Messaging has no
Foundation-level handler tests (the `is_internal` boundary is covered only end-to-end in
`Tests/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`/`GetTicketThread` internal-flag handling.
- **Effort:** SM · **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 at
`CreateRefundCommand.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](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):** `variantPrice` on `BookingRequestDto`, `variantLabel` (+ optional
`patientAge`) on `BookingRequestListItemDto` (REQ-013/014).
- **3.2 Identity/profile fields (SM):** 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) + `provinceId` on `CustomerAddressDto` (REQ-009).
- **3.4 Search & public profile (M — the single highest-leverage item):** enrich `NurseSearchResultDto`
with `nurseName`/`avatarUrl` (+ optional `distanceKm`) and add the aggregated public
`GET nurses/{id}/profile` (REQ-012). Unblocks the discovery funnel (C2/C3).
- **3.5 Verification details (SM):** nurse-facing `credential_details` command + `isRequired` on
`VerificationStepDto` (REQ-011 — without it the INO number/specialties are silently dropped).
- **3.6 Auth polish (S):** `codeLength`/`expiresInSeconds` on `RequestOtpResult` (REQ-002); machine-readable
`code` (+ `retryAfterSeconds`) on OTP failures (REQ-003 — needs a small `OperationResult`/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_size` occurrences in `dev/contracts/domains/*.md` (REQ-010 — the server verifiably binds camelCase
`pageSize`); mark every delivered REQ `delivered in …` (all 15 currently read `Status: 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 at
`Services/Notifications/NotificationRetentionHostedService.cs:19` and
`Services/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: **no `IJobScheduler` interface 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 `process` manual until
trust is earned.
- **Effort:** ML · **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-key `SemaphoreSlim` — 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:11` and `InProcessDistributedLock.cs:14-22`,
registered at `CrossCutting/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 (rows `ICacheService`,
`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` (`MigrateAsync` unconditional).
- **Change:** a deploy-time migration step (`dotnet ef database update` in CI, or a `--migrate` one-shot
mode) and boot-time schema *check* instead of apply; seeders become idempotent deploy steps.
- **Effort:** SM · **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 at `ServiceCollectionExtension.cs:32`. No `Seams:Sms` options exist yet (verified:
`SeamOptions.cs` has no Sms group).
- **Change:** per registry row `ISmsSender`: pick Kavenegar/Ghasedak/SMS.ir, add `Seams:Sms:{ApiKey,
SenderLine,BaseUrl}` options + package, implement pattern/template OTP send, config-selected
registration. Keep the per-phone resend window and `otp` rate 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 defaults `SeamOptions.cs:157-184`. The pipeline already persists
`external_response_json` and 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
(`ShahkarVerifiedAt` reset on phone change — b2).
- **Effort:** ML (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`; defaults `SeamOptions.cs:204`.
- **Change:** per registry row; verify the payout gate end-to-end (`is_primary=1 AND is_verified=1 AND
matched_national_id=1` skip-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:Geocoding` is
one of only three seam sections present in `appsettings.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 `GetUrl` returns a `file://` 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:RootPath` empty in `appsettings.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:** `ICredentialVerifier` and `ILicenseVerificationService` return "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
(`VerifyAsync` 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.
- **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
encrypted `payment_gateways.config_json` (seed at `Persistence/ServiceCollectionExtensions.cs:109-117`).
- **Change:** per registry rows 3941: 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:*` defaults `SeamOptions.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 per `provider_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/failed` async
reconciliation; no money reaches nurses.
- **Current state:** `CrossCutting/Seams/MockBankTransferProvider.cs:18-31`; registration `:82`;
`Seams:BankTransfer` defaults `SeamOptions.cs:61-72`. The status machine, batch idempotency key,
`nurse_payout_booking_links` UNIQUE (`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 at `Tests/.../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:PaymentCapture` options `SeamOptions.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 = pending` forever (mock leaves it pending; **no reconciliation job or endpoint exists** —
the only `ApplyMoadianResult` caller is issue-time, `Features/Invoices/Commands/IssueInvoice/IssueInvoiceCommand.Handler.cs:72`).
- **Current state:** `CrossCutting/Seams/MockMoadianClient.cs:15-25`; registration `:65`;
`Seams:Moadian:ForceRegistered` default false.
- **Change:** enrollment (memory/economic code + signing cert), real `SubmitAsync`, and the
`pending → submitted → registered/failed` reconciliation job (register it under 4.1). The invoice
number sequence and VAT-on-commission math are already correct and tested.
- **Effort:** ML (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`);
`IBankTransferProvider` consumed only by nurse payouts (`Controllers/V1/AdminPayoutsController.cs:44`);
follow-up noted at `dev/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:** ML (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's `AddPrometheusExporter` + prometheus-net's `UseMetricServer`/`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 the `ApiResult.requestId` for support correlation.
- **Effort:** SM · **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),
`/HealthCheck` endpoint `:28`, dead `currentUrl` variable `: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 at `server/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_logs` 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.
- **Current state:** deferral recorded at `dev/phases/backend/backend-phase-1.md:124`; no purge/archive job
exists for `ops.AuditLogs` (only `NotificationRetentionHostedService`).
- **Change:** a retention/archival policy (cold table or export) as a 4.1 job; define legal retention for
money/verification audit rows first.
- **Effort:** SM · **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.Body` is plaintext with **no documented decision**,
unlike `BookingRequest.CustomerNotes` which 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 `Body` 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.
- **Effort:** SM · **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`/`ILicenseVerificationService` while later rows correct all seven (e.g. rows 15/16 vs
38/42). `IJobScheduler` is listed as a seam but no such interface exists. All 15 REQs read `Status: 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 `IJobScheduler` row to "recurring jobs (hosted
services)", mark delivered/answered REQs, and note `IPaymentCaptureSimulator`'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** — `SqlNurseSearch` is real and correct
(`Persistence/Services/Search/SqlNurseSearch.cs:18`); `Search:Backend` fails fast on any non-`sql` value
(`ServiceCollectionExtensions.cs:74-78`). Build `ElasticNurseSearch` + the outbox/CDC feeder per registry
rows 38/43 only when SQL search shows strain. **Effort:** L.
- **8.2 Analytics pipeline** — `IAnalyticsSink` writes `ops.SystemEvents` rows 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** — `InAppNotificationDispatcher` silently 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 f9f15)
post-phase-4 (scheduler/Redis) ──► needed by 6.3/6.5 triggers
post-phase-5 (trust rails; 5.1 SMS is launch-critical, schedule early)
post-phase-7 (observability — start anytime, finish before launch)
post-phase-8 (later)
```
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)**.