refinement phase 7

This commit is contained in:
hamid
2026-07-13 17:48:50 +03:30
parent 70268ecc06
commit 7edadadea1
30 changed files with 6971 additions and 154 deletions
+1 -1
View File
@@ -81,7 +81,7 @@ The response envelope is the standard `{ data, … }`; the shapes below are the
## Shared shapes
- `EligibleNurseEarningsDto`: `nurseId` (long), `nurseName` (string?), `bookingCount` (int), `grossEarningsIrr` (string), `clawbackAppliedIrr` (string), `netAmountIrr` (string), `hasVerifiedPrimaryIban` (bool).
- `PayoutBatchDto`: `id` (long), `periodStart`/`periodEnd`/`processingDate` (date), `totalAmount` (string), `payoutCount` (int), `status` (`PayoutBatchStatus`), `initiatedByAdminId` (int), `processedAt` (datetime?), `failureNotes` (string?), `createdAt` (datetime).
- `PayoutBatchDto`: `id` (long), `periodStart`/`periodEnd`/`processingDate` (date), `totalAmount` (string), `payoutCount` (int), `status` (`PayoutBatchStatus`), `initiatedByAdminId` (int?, **null = system-initiated / scheduled batch** — refinement-phase-7), `processedAt` (datetime?), `failureNotes` (string?), `createdAt` (datetime).
- `PayoutDto`: `id` (long), `nurseId` (long), `nurseName` (string?), `maskedIban` (string, last-4 only), `grossEarningsIrr`/`clawbackAppliedIrr`/`netAmountIrr`/`amount` (string), `bookingCount` (int), `status` (`PayoutStatus`), `transferReference` (string?), `paidAt` (datetime?), `failureReason` (string?), `bookings` (`PayoutBookingLinkDto[]`).
- `PayoutBookingLinkDto`: `bookingId` (long), `sessionId` (long?), `payoutAmountIrr` (string).
- `PayoutBatchDetailDto`: `batch` (`PayoutBatchDto`), `payouts` (`PayoutDto[]`), `total` (int), `page` (int), `pageSize` (int).
+2 -1
View File
@@ -16853,7 +16853,8 @@
},
"initiatedByAdminId": {
"type": "integer",
"format": "int32"
"format": "int32",
"nullable": true
},
"processedAt": {
"type": "string",
+12 -8
View File
@@ -19,7 +19,7 @@ is invented; "not needed" claims are backed by the absence of the package/code.
| 4 | **Prometheus** (+ Grafana) | Scrapes `/metrics`; health forwarded to gauges | `UseMetricServer` + OTel exporter | **Recommended now** | — |
| 5 | **Redis** | `ICacheService` + `IDistributedLock` (money-path mutex) | `Seams:*` (keys TBD; none today) | Before >1 API instance | rows 14, 42 |
| 6 | **MinIO / S3 / ArvanCloud** | `IObjectStorage` — verification docs, avatars (REQ-006), invoice PDFs | `Seams:ObjectStorage:*` | Before real verification | row 13 |
| 7 | **Job scheduler** (Hangfire/Quartz, in-app on SQL) | The deferred crons: payout batch, expiry scan, no-show, Moadian poll | hosted services (no interface exists) | Before unattended ops | row 26 |
| 7 | **Job scheduler** — in-process, SQL only (refinement-phase-7 **done**) | The recurring crons: booking-expiry, notification-retention, credential-expiry scan, no-show sweep, weekly payout-batch generation (Moadian/refund-settlement poll = Phase 8) | `RecurringJobSchedulerHostedService` + `IRecurringJob`s | **Done for single instance** (no new infra) | row 26 |
| 8 | **SMS gateway** (Kavenegar·Ghasedak·SMS.ir) | `ISmsSender` — OTP delivery (login is impossible without it) | `Seams:Sms:*` (to be added) | **Launch-critical** | row 12 |
| 9 | **PSP / IPG + Shaparak** (ZarinPal·Sadad·Vandar·Jibit) | `IPaymentProvider` + `IWebhookVerifier` + `ISettlementSplitProvider` (تسهیم) | encrypted `payment_gateways.config_json` | Real payments | rows 3941 |
| 10 | **BNPL providers** (SnappPay·Digipay) | `IBnplProvider` / `IBnplProviderResolver` / `ICurrencyNormalizer` | `Seams:Bnpl:*`, `Seams:Currency:*`, gateway config | Optional at launch | rows 4647 |
@@ -262,18 +262,22 @@ flowchart LR
## Deployment notes (from the code, not aspiration)
1. **Boot = migrate + seed.** Every non-Testing start applies EF migrations and seeds roles, the
`admin`/`qw123321` user, and an **active sandbox ZarinPal gateway** (`Program.cs:99-104`). Until plan
§1.3/§1.4/§4.3 land: single-instance start-up, DDL-privileged login, and clean up the seeded credentials
per environment.
1. **Boot migrate (refinement-phase-7).** DDL is a separate deploy step — `dotnet run -- migrate` applies
migrations + idempotent seeders then exits. **Development** boot still migrates + seeds (incl. the
Development-only sandbox gateway + demo world) for convenience; **deployed** boot only *checks* the schema is
current (`EnsureSchemaUpToDateAsync`, fail-fast on a pending migration) and seeds roles/break-glass admin. So
multi-instance boots no longer race on DDL and the runtime login needs no permanent DDL rights (plan §1.3/§1.4
also landed — no committed `admin`/`qw123321`; sandbox gateway is Development-only).
2. **Environment files:** `appsettings.json``appsettings.Development.json` (byte-identical); **no
Production/Staging file exists.** All non-secret env differences ride on ~14 `Seams:*` groups whose
defaults live in code (`SeamOptions.cs`), not in config files.
3. **HTTP posture:** HTTP/2-only Kestrel default (plan §1.5), gRPC plugin + reflection always on
(plan §7.5), TLS required for JWE sanity.
4. **Single-instance constraints today:** in-memory cache, in-proc money lock, in-proc sweeps, per-instance
rate-limit buckets. Scaling past one instance requires plan §4.2 (Redis) + §4.3 (migrations) first — the
DB uniques keep money *correct* either way, but locks/cache/limits silently degrade.
4. **Single-instance constraints today:** in-memory cache, in-proc money lock, the in-proc recurring-job
scheduler (refinement-phase-7 — its per-tick lock is that same in-proc seam), per-instance rate-limit buckets.
§4.3 (migrations split from boot) **landed**; scaling past one instance still requires §4.2 (Redis for the
shared cache + the cross-instance lock the scheduler/money path use) first — the DB uniques keep money
*correct* either way, but locks/cache/limits/scheduler-de-dup silently degrade.
5. **Logs:** deployed envs write Warning+ to `Baya_Logs` only (Information dropped — plan §7.3); dev writes
console + `logs/log.json`.
6. **Client:** the Next.js app needs `NEXT_PUBLIC_API_URL` pointing at the proxy; wire casing camelCase;
@@ -12,6 +12,24 @@ One block per completed backend phase. Newest at the top. Backend lane writes he
- **Notes for frontend:** <anything load-bearing>
-->
## refinement-phase-7 — Unattended ops: scheduler, locking & multi-instance readiness — 2026-07-13
- **Shipped:** one in-process **`RecurringJobSchedulerHostedService`** + the **`IRecurringJob`** seam
(`Persistence/Services/Scheduling/`) replacing the two `PeriodicTimer` hosted services and scheduling the
previously admin-manual crons — `booking_request_expiry`, `notification_retention`, `verification_expiry_scan`,
`no_show_sweep`, `weekly_payout_generation` — each reading its seeded cadence key; admin triggers stay overrides.
Payout **generation** is scheduled (system-initiated `draft`); **processing** stays admin-only
(`NursePayoutBatch.InitiatedByAdminId` now nullable = system; `SystemInitiated` flag is scheduler-only,
controller-neutralized). **Migrations split from boot:** `dotnet run -- migrate` one-shot + deployed-boot schema
*check* (`EnsureSchemaUpToDateAsync`); Dev keeps migrate-on-boot. **No Redis/Hangfire added** — documented as the
>1-instance scale-out gate; the scheduler's per-tick `IDistributedLock` is that swap point.
- **Contracts:** `PayoutBatchDto.initiatedByAdminId` nullable — `dev/contracts/domains/payouts.md` + openapi
snapshot refreshed (yes).
- **Mocked:** none new. Redis = scale gate; Moadian/refund-settlement poll = Phase 8 jobs (see mocks-registry).
- **Gate:** build clean (0 new warnings) / **402 tests pass** (396 prior + 6 new scheduling tests).
- **Handoff:** backend/handoff/after-refinement-phase-7.md
- **Notes for frontend:** admin payout batch `initiatedByAdminId` can be `null` (system/scheduled batch) — render
a "system"/"scheduled" label rather than assuming an admin id.
## refinement-phase-1 — Database: local-dev story, demo seed & migration hygiene — 2026-07-13
- **Shipped (no migration, no endpoint, no contract change):** Development-gated **demo-world seeder**
`Persistence/Services/Seeding/DemoWorldSeeder.cs` + `DemoWorldDefinitions.cs`, scoped-registered in
@@ -0,0 +1,38 @@
# After refinement-phase-7 — Unattended operation (scheduler, locking, migrations-from-boot)
**For the frontend / next backend phase. Backend-owned; frontend reads.**
## What changed for a client
Almost nothing user-facing — this is infrastructure. One wire delta:
- **`PayoutBatchDto.initiatedByAdminId` is now nullable.** `null` = a **system-initiated / scheduled** payout
batch (the weekly cron generated it, no human initiator). Admin payout UIs should render a "system"/"scheduled"
label instead of assuming an admin id. Contract + `swagger.v1.json` updated.
## What the platform now does on its own
The four previously admin-click-only sweeps run on schedule (each reading its `platform_configs` cadence key):
credential-expiry scan, EVV no-show sweep, and **weekly payout-batch generation** — plus the two re-homed sweeps
(booking-request expiry, notification retention). **Admin manual triggers are unchanged and remain overrides.**
- **Payout generation only.** The cron opens a `draft` batch; **processing (money movement) is still an explicit
admin action** (`POST admin_payouts/batches/{id}/process`). Do not build a client flow that auto-processes.
## For the next backend phase (Phase 8 — external rails)
- **Register new crons via the seam, not a new host.** Implement `IRecurringJob`
(`Persistence/Services/Scheduling/`) + one `services.AddSingleton<IRecurringJob, YourJob>()` in
`AddPersistenceServices`. Phase 8 owns the **Moadian reconciliation poll** and the **refund-settlement
reconciliation** this way (each reads/adds its own cadence key). The scheduler already provides the per-tick
scope, the `scheduler:{name}` lock, and error isolation.
- **Jobs must stay idempotent** — a retry (or a second instance once the lock is Redis-backed) must never
double-pay/double-post; the DB uniques/state-machines are the backstop.
## Ops / deployment
- **DDL is a deploy step now:** run `dotnet run -- migrate` (applies migrations + idempotent seeders, then exits)
before starting the API in a deployed environment. A normal deployed boot only *checks* the schema and **fails
fast** if a migration is pending. Development still migrates + seeds on boot.
- **Redis is the >1-instance gate** (shared cache + the cross-instance scheduler/money lock). Single-instance MVP
does not need it; the in-proc seams are correct for one instance. Elasticsearch is never MVP.
@@ -11,7 +11,7 @@ Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢
| --- | --- | --- | --- | --- | --- |
| `ISmsSender` | backend-phase-2 | OTP/SMS delivery — `LoggingSmsSender` (`Baya.Infrastructure.CrossCutting/Seams/`) logs the OTP code (phone shown as last-4 only) and returns success; registered singleton in `AddCrossCuttingSeams`. **refinement-phase-0:** in **Development only**, `DevCapturingSmsSender` decorates it (via `AddDevelopmentOtpCapture`, called from `Program.cs` inside `IsDevelopment()`) to also capture the code in `DevOtpStore` for the `GET /api/v1/dev/last_otp/{phone}` bring-up helper — not wired / 404 outside Development | none today; real client will need `Seams:Sms:ApiKey` + `Seams:Sms:SenderLine` (+ gateway base URL) | 1) pick a gateway (Kavenegar/Ghasedak/SMS.ir), add its client package to `Directory.Packages.props`; 2) implement `ISmsSender.SendOtpAsync`/`SendAsync` against it (template/pattern-based OTP send); 3) bind the new `Seams:Sms` options; 4) swap the registration in `AddCrossCuttingSeams` (config-selected) — handlers unchanged; 5) keep the per-phone resend window + `otp` rate-limit policy exactly as-is; test with a real SIM | 🟡 |
| `IObjectStorage` | backend-phase-0/6 | File storage — local-disk store under a scratch root (`LocalDiskObjectStorage`, `Baya.Infrastructure.CrossCutting/Seams/`) | `Seams:ObjectStorage:RootPath` (default: temp dir) | Point at MinIO/S3/ArvanCloud; presigned upload/download; bucket + creds | 🟡 |
| `ICacheService` | backend-phase-0 | Caching — in-memory `IMemoryCache` (`MemoryCacheService`, `Baya.Infrastructure.CrossCutting/Seams/`) | _none_ | Swap to Redis (`StackExchange.Redis`); keep key/TTL scheme | 🟡 |
| `ICacheService` | backend-phase-0 | Caching — in-memory `IMemoryCache` (`MemoryCacheService`, `Baya.Infrastructure.CrossCutting/Seams/`) | _none_ | Swap to Redis (`StackExchange.Redis`); keep key/TTL scheme. **refinement-phase-7: this is the >1-instance scale-out gate** — a single-instance MVP intentionally keeps the in-proc cache (its generation-token invalidation is process-local); add Redis only when a second API instance runs | 🟡 (in-proc is correct single-instance) |
| `IDistributedLock` | backend-phase-10 | Money-path locks — no-op/in-proc | _tbd_ | Redis lock (RedLock); DB constraint remains the backstop | 🔴 |
| `INurseSearch` | backend-phase-7 | Search — SQL over `nurse_search_index` | _tbd_ | Elasticsearch index + feeder; reimplement the interface | 🔴 |
| `IPaymentProvider` | backend-phase-10 | Card PSP/IPG — deterministic success | _tbd_ | ZarinPal/Sadad/Vandar/Jibit + Shaparak; merchant/terminal/تسهیم | 🔴 |
@@ -23,7 +23,7 @@ Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢
| `IBankTransferProvider` | backend-phase-13 | PAYA/SATNA payout rail — `MockBankTransferProvider` (`Baya.Infrastructure.CrossCutting/Seams/`), **no external call, no money moves**: `SubmitPayoutBatchAsync(batchId, instructions, idempotencyKey)` returns a deterministic `externalBatchRef` + a per-instruction `transfer_reference` and settles every row `Paid` (collapsing the real `submitted → paid` reconciliation); it **honours** the PAYA/SATNA `method` the handler chose by the `payout_satna_threshold_irr` config and echoes it. A config switch forces deterministic failures so `partially_failed`/retry are testable: `ForceFailure` fails the whole batch, `FailIban` fails one destination. `GetPayoutStatusAsync` echoes `Paid`. Registered singleton in `AddCrossCuttingSeams` | `Seams:BankTransfer:ForceFailure` (default `false`), `Seams:BankTransfer:FailIban` (default empty) | 1) pick a transferor (Jibit/Vandar/Sadad payout API), add its client package to `Directory.Packages.props`; 2) add `Seams:BankTransfer:{ApiKey,BaseUrl,SourceSettlementAccount}`; 3) implement `SubmitPayoutBatchAsync` to register the batch against the registered **source settlement account** and route each transfer PAYA (batch, low-value) vs SATNA (real-time, above the threshold) to each nurse's **verified Sheba** (the b3 `matched_national_id` gate), honouring batch caps/minimums; 4) implement the async **reconciliation callback** that flips a payout `submitted → paid/failed` (the mock collapses this — the real rail is async); 5) swap the registration (config-selected) — the payout status machine + `nurse_payout_booking_links` UNIQUE remain the irreversible-transfer backstop; 6) test PAYA/SATNA selection, whole-batch + single-row failure → retry | 🟡 |
| `IHolidayCalendar` | backend-phase-1 | Bank holidays — reads the seeded `ops.IranianHolidays` table; lookups cached (`HolidayCalendarService`, `Persistence/Services/Holidays/`); Iranian banking weekend = Friday | _none_ | Add a sync job/feed that maintains the (partly lunar-Hijri) calendar table; the read interface stays | 🟡 |
| `IAnalyticsSink` | backend-phase-1 | Behavioural events — inserts an `ops.SystemEvents` row, fire-and-forget (`AnalyticsSink`, `Persistence/Services/Analytics/`) | _none_ | Pipe to a warehouse/stream (e.g. Kafka→ClickHouse); keep fire-and-forget semantics | 🟡 |
| `IJobScheduler` (retention + booking expiry) | backend-phase-1 | Scheduling — in-process interval `BackgroundService`s: `PurgeOldReadNotifications` daily (`NotificationRetentionHostedService`, `Persistence/Services/Notifications/`) and **b8** `BookingRequestExpiryHostedService` (`Persistence/Services/Booking/`) running the idempotent booking-request expiry sweep every minute | _none_ | Swap to Hangfire/Quartz; register **both** jobs there; keep the purge predicate (`is_read=1 AND age>90d`) and the booking-expiry command | 🟡 |
| Recurring jobs (in-process scheduler) | backend-phase-1 · **re-homed refinement-phase-7** | Scheduling — **REAL** single in-process scheduler `RecurringJobSchedulerHostedService` (`Persistence/Services/Scheduling/`) drives every `IRecurringJob` on its own (config-read) cadence: `notification_retention` (24 h) + `booking_request_expiry` (1 min) + `verification_expiry_scan` (`verification_expiry_scan_cadence_hours`) + `no_show_sweep` (`no_show_scan_cadence_hours`) + `weekly_payout_generation` (`nurse_payout_interval_days`, system-initiated draft only — processing stays admin). Each tick runs under `IDistributedLock(scheduler:{name})`; dormant under `Testing`. Admin manual triggers remain overrides. **No new infra** (SQL Server only). | the cadence keys above (via `IPlatformConfig`) | This is the intended MVP shape — **not** debt. Hangfire/Quartz only buys durable/cross-restart scheduling and is the >1-instance option alongside Redis (§7.2) — swap by re-registering the jobs behind it. **Phase 8** adds the Moadian reconciliation + refund-settlement poll as new `IRecurringJob`s here. | 🟢 real (single-instance) |
| `IShahkarVerifier` | backend-phase-6 | شاهکار phone↔national-id binding — `MockShahkarVerifier` (`Baya.Infrastructure.CrossCutting/Seams/`) returns a deterministic result + fake vendor ref + `external_response_json`: matches every pair except the configured shared-SIM phone (→ the explicit shared-SIM failure state, which the handler turns into a `shared_sim` support alert) and the mismatch national id (→ plain mismatch); registered singleton in `AddCrossCuttingSeams`. No real Shahkar call | `Seams:Shahkar:SharedSimPhone` (default `09120000000`), `Seams:Shahkar:MismatchNationalId` (default `1111111111`) | 1) pick a Finnotech / KYC Shahkar-bridge vendor, add its client package to `Directory.Packages.props`; 2) add `Seams:Shahkar:{ApiKey,BaseUrl}` options; 3) implement `MatchAsync(phone, nationalId)` against the real استعلام شاهکار, mapping to `ShahkarMatchResult` and persisting the raw response into the step's `external_response_json`; 4) keep shared-SIM as the explicit handled failure (`IsSharedSim=true`); 5) swap the registration in `AddCrossCuttingSeams` (config-selected) — handlers unchanged; 6) test match / shared-SIM / mismatch + that a phone change re-runs it (`shahkar_verified_at` resets upstream on phone change) | 🟡 |
| `IIdentityKycProvider` | backend-phase-6 | Identity KYC (national-id validity + name match + liveness) — `MockIdentityKycProvider` (`.../Seams/`) passes any well-formed 10-digit national id except the configured fail id, returning a matched name + fake vendor ref + `external_response_json`; on pass the handler populates `users.national_id` + `national_id_verified_at`. No real OCR/liveness; registered singleton | `Seams:IdentityKyc:FailNationalId` (default `0000000000`), `Seams:IdentityKyc:MatchedName` (default `Verified Nurse`) | 1) pick an Iranian e-KYC vendor (Finnotech / U-ID / Jibbit / Farashensa / Verify / Kavoshak), add its client package to `Directory.Packages.props`; 2) add `Seams:IdentityKyc:{ApiKey,BaseUrl}` options; 3) implement `VerifyAsync(nationalId, livenessPayload)` → national-id validity + name match + photo/video liveness against ثبت احوال, mapping to `IdentityKycResult` and persisting `external_response_json`; 4) swap the registration (config-selected) — handlers unchanged; 5) test pass/fail by national id + that `national_id` is populated **only** on pass | 🟡 |
| `ICredentialVerifier` | backend-phase-6 | MoH پروانه صلاحیت حرفه‌ای / INO / عدم سوء پیشینه verification — `MockCredentialVerifier` (`.../Seams/`) **is** the manual-admin default: every call returns `RequiresManualReview` with `verification_method=manual` (an admin verifies the uploaded document against the official portal in `AdminReviewStep`). No portal call; registered singleton. There is **no public B2B API** for MoH/INO, so this stays manual until one appears | _none_ | 1) when an MoH/INO portal or API becomes available, implement `VerifyAsync(credentialType, credentialNumber)` to return `Verified`/`Failed` with `verification_method=portal|api` (+ `external_response_json`); 2) swap the registration (config-selected) for those credential types — the manual path stays the fallback; 3) the structured `nurse_credentials` registry already stores number/authority/expiry so cross-check + renewal survive the swap. **MoH/INO have no public B2B API today** | 🟡 |
@@ -39,7 +39,7 @@ Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢
| `IPaymentProvider` | backend-phase-10 | Card PSP acquirer — `MockPaymentProvider` (`Baya.Infrastructure.CrossCutting/Seams/`), **no external call**: `InitPaymentAsync` → a deterministic `gatewayReferenceCode` (`mock-ref-{requestId}-{key}`) + a fake redirect URL; `VerifyAsync` → instant `Succeeded` echoing the expected amount (the server-side re-check); `RefundAsync(ref, amount, idempotencyKey, ct)` → always `Succeeded`, echoes a deterministic refund ref (b11 refunds carry the booking+refund idempotency key so a retry never double-refunds). Registered singleton in `AddCrossCuttingSeams` | none today; real client needs merchant id + terminal/IBAN registration + sandbox flag from `payment_gateways.config_json` (encrypted), not appsettings | 1) pick ZarinPal/Sadad/Vandar/Jibit as an acquirer-with-تسهیم, add its client package to `Directory.Packages.props`; 2) implement `InitPaymentAsync` (open the IPG session, return the Shaparak-routed redirect + reference), `VerifyAsync` (the mandatory server-side `verify` re-check of amount + reference — **never trust the callback alone**), `RefundAsync`; 3) read merchant id/terminal from the encrypted `payment_gateways.config_json`; 4) a config-driven `IProviderRegistry`/factory selects the concrete provider per gateway so a cut-off provider swaps without code change; 5) persist the full gateway response into `gateway_response_json`; 6) swap the registration (config-selected) — handlers unchanged | 🟡 |
| `ISettlementSplitProvider` | backend-phase-10 | تسهیم settlement-sharing — `MockSettlementSplitProvider` (`.../Seams/`) records the split intent and returns `Settled` for any legs whose sum is positive; the platform never moves money. Registered singleton in `AddCrossCuttingSeams` | none today; real client needs each beneficiary's registered SHEBA + split-by-ratio config | 1) pick the acquirer's تسهیم API, implement `RegisterSplitAsync(bookingId, legs)` to register the split-by-ratio to each beneficiary's **registered IBAN** (nurse payout + platform commission), honouring the ~100,000 IRR min-amount caveat; 2) resolve each nurse's SHEBA from `nurse_bank_accounts` (the b3 `matched_national_id` gate) and the platform SHEBA from config; 3) `GetSplitStatusAsync` polls the provider; the provider credits IBANs directly — the ledger only mirrors it; 4) swap the registration (config-selected) | 🟡 |
| `IWebhookVerifier` | backend-phase-10 | PSP callback signature verify — `MockWebhookVerifier` (`.../Seams/`) treats the signature as valid unless the body carries `Seams:Payments:InvalidSignatureMarker`, and extracts `external_event_id`/`event_type`/`gateway_reference_code` from a small JSON body (so tests can replay duplicates + exercise the invalid-signature path). Registered singleton in `AddCrossCuttingSeams` | `Seams:Payments:InvalidSignatureMarker` (default `INVALID_SIGNATURE`) | 1) implement the per-provider HMAC/signature scheme (verify the raw body against the provider's signing key from the gateway config); 2) where a provider offers no signature, fall back to the mandatory server-side `verify` re-check (amount + reference) via `IPaymentProvider.VerifyAsync`; 3) parse the real provider event shape into `WebhookVerification`; 4) swap the registration (config-selected) — the `HandlePaymentWebhook` upsert-first/no-op-on-duplicate ordering is unchanged | 🟡 |
| `IDistributedLock` | backend-phase-10 | Money-path mutex — `InProcessDistributedLock` (`.../Seams/`): a per-key `SemaphoreSlim` so the capture path runs the same acquire/release shape it will with real Redis, **within one process only**. **Not** a cross-instance correctness guarantee — the DB uniques/state-machine are the authoritative backstop. Registered singleton in `AddCrossCuttingSeams` | none today; real client needs a Redis connection string | 1) add `StackExchange.Redis` to `Directory.Packages.props`; 2) implement `AcquireAsync(key)` with a lease/expiry (RedLock-style SET NX PX + a token-checked release), key convention `booking:{id}:payment`; 3) bind `Seams:Payments:Redis` (or reuse the `ICacheService` Redis swap); 4) swap the registration (config-selected) — handlers unchanged, and correctness still rests on the DB uniques if Redis is down/expired | 🟡 |
| `IDistributedLock` | backend-phase-10 | Money-path mutex — `InProcessDistributedLock` (`.../Seams/`): a per-key `SemaphoreSlim` so the capture path runs the same acquire/release shape it will with real Redis, **within one process only**. **Not** a cross-instance correctness guarantee — the DB uniques/state-machine are the authoritative backstop. Registered singleton in `AddCrossCuttingSeams` | none today; real client needs a Redis connection string | 1) add `StackExchange.Redis` to `Directory.Packages.props`; 2) implement `AcquireAsync(key)` with a lease/expiry (RedLock-style SET NX PX + a token-checked release), key convention `booking:{id}:payment`; 3) bind `Seams:Payments:Redis` (or reuse the `ICacheService` Redis swap); 4) swap the registration (config-selected) — handlers unchanged, and correctness still rests on the DB uniques if Redis is down/expired. **refinement-phase-7: also the scheduler's per-tick lock** (`scheduler:{job}`) uses this seam — once Redis-backed it serializes recurring-job ticks across instances (idempotency + DB uniques cover a double-run either way). Required only for >1 instance. | 🟡 (in-proc is correct single-instance) |
| `ISearchIndexMaintainer` (the "`ISearchIndexWriter`" event shape) | backend-phase-7 | The index-maintenance seam (write side). **The inline SQL path is REAL**`SearchIndexMaintainer` (`Persistence/Services/Search/`) re-derives `nurse_search_index` from source and **stages** it inside the owning source write's unit of work (single `CommitAsync`), invoked from the b3/b4/b5/b6 handlers (`ReindexVariantAsync`/`ReindexNurseAsync`/`FanOutServiceAreaAsync`/`RemoveServiceAreaRowsAsync`/`RebuildAsync`). Only the **outbox/queue routing** for an async Elastic feeder is deferred — the seam is shaped so the same change events can later be emitted to an outbox instead of an inline upsert | _none_ | 1) introduce an `outbox` table + a SaveChanges interceptor that captures each maintainer change as an event row in the same transaction; 2) a background feeder (Hangfire/Quartz or a hosted service) reads the outbox and applies to `ElasticNurseSearch`; 3) keep the inline SQL upsert as the projection/fallback so `RebuildAsync` stays the reconciliation path; 4) test that an outbox replay converges to the same rows as the inline path | 🟡 outbox deferred (inline real) |
| `IMoadianClient` | backend-phase-11 | سامانه مودیان e-invoicing — `MockMoadianClient` (`Baya.Infrastructure.CrossCutting/Seams/`), **no external call**: `SubmitAsync` leaves a new invoice `moadian_status = pending` with `moadian_reference_number = null`; a config switch forces a deterministic `registered` result with a fake 22-digit reference so the reconciliation/registered path is testable. Registered singleton in `AddCrossCuttingSeams` | `Seams:Moadian:ForceRegistered` (default `false`) | 1) enroll the platform in سامانه مودیان (memory/economic code + signing certificate); 2) implement `SubmitAsync` to POST the معاملات/invoice (`صورتحساب`) to the مودیان API, sign the payload, map the 22-digit `reference_number`; 3) walk the async `pending → submitted → registered`/`failed` states via a reconciliation callback/poll (**cron deferred/manual today** — a job flips `moadian_status` + fills the ref); 4) swap the registration (config-selected) — the `IssueInvoice` handler is unchanged | 🟡 |
@@ -0,0 +1,92 @@
# Refinement Phase 7 — Unattended operation: scheduler, locking & multi-instance readiness — Report (2026-07-13)
**Track:** backend (infra) · **Depends on:** phase 6 (its settlement reconciliation is a future job here) ·
**Gate:** `dotnet build` 0 new warnings · `dotnet test` **402 pass** (396 prior + 6 new scheduling tests).
## The headline (7.1) — the platform now runs itself
Before this phase only two hand-written `PeriodicTimer` hosted services existed; the credential-expiry scan, EVV
no-show sweep, and **weekly payout-batch generation** were admin-click-only while their seeded cadence keys sat
unread — so **nurses were paid only when an operator clicked**. Now a single in-process scheduler drives every job
on its own cadence.
**`RecurringJobSchedulerHostedService`** (`Persistence/Services/Scheduling/`) + the **`IRecurringJob`** seam:
- The scheduler owns one independent loop per job (their cadences don't couple; a crash in one never stops the
others), the per-tick DI scope, error isolation (a throwing tick logs and the next tick retries on schedule),
and a per-tick `IDistributedLock("scheduler:{name}")`. A job says only *how often* (usually a `platform_configs`
cadence key, re-read each tick so an admin change applies without a restart) and *what one idempotent run does*.
- **No new infrastructure.** SQL Server stays the only external dependency — a single-instance MVP needs neither
Hangfire/Quartz (durable/cross-restart scheduling is the only thing they add for idempotent periodic sweeps) nor
Redis. Adding a cron = implement `IRecurringJob` + one `AddSingleton<IRecurringJob, …>()`.
Jobs registered (`Services/Scheduling/Jobs/`), each dispatching the **same idempotent command the admin trigger
sends** (the admin endpoints are unchanged and remain overrides):
| Job (`Name`) | Cadence source | Re-homed / new |
| --- | --- | --- |
| `booking_request_expiry` | 1 min const | re-homed from `BookingRequestExpiryHostedService` (deleted) |
| `notification_retention` | 24 h const | re-homed from `NotificationRetentionHostedService` (deleted) |
| `verification_expiry_scan` | `verification_expiry_scan_cadence_hours` (24) | **new**`ScanExpiringCredentialsCommand` |
| `no_show_sweep` | `no_show_scan_cadence_hours` (1) | **new**`DetectNoShowSessionsCommand` |
| `weekly_payout_generation` | `nurse_payout_interval_days` (7) | **new**`GeneratePayoutBatchCommand` |
## Money movement stays human-approved (critical rule)
The payout job schedules **generation only** — it opens a `draft` batch over the trailing window; the irreversible
`process` (money-moving) step remains an explicit admin action until trust is earned. To let an unattended run
record a batch with no human initiator, `NursePayoutBatch.InitiatedByAdminId` is now **nullable** (`null` =
system-initiated) — migration `RefinementPhase7SystemPayoutBatch` (alters the column + FK to nullable; the FK, the
`PayoutBatchDto` projection, and `swagger.v1.json` were updated to match). The command's `SystemInitiated` flag is
**scheduler-only**: `AdminPayoutsController.Generate` neutralizes any request-supplied value (`command with {
SystemInitiated = false }`), so an API caller can never bypass the authenticated-admin requirement. A quiet week
(no eligible bookings) is a benign no-op; a re-run over an overlapping window is safe — the
`nurse_payout_booking_links.booking_id` UNIQUE prevents re-selecting an already-paid booking.
## 7.2 — Redis is the scale-out gate, NOT added
Per the phase's "don't add Redis because", the in-process `ICacheService`/`IDistributedLock` stay. They are the
documented **>1-instance scale-out gate**: the moment a second API instance runs, swap the lock seam to Redis and
the scheduler's per-tick lock serializes ticks across nodes (idempotency + the DB uniques cover a double-run
either way). Nothing speaks Redis today; no package added. (Registry rows `ICacheService`/`IDistributedLock`
updated with the framing.)
## 7.3 — Migrations split from boot
`dotnet run -- migrate` is a deploy-time one-shot: it applies EF migrations + the idempotent seeders, then exits —
so concurrent multi-instance start-ups never race on DDL and the runtime login needs no permanent DDL rights.
**Development** boot still migrates + seeds (incl. the Development-only sandbox gateway + demo world) for
convenience; **deployed** boot only *checks* the schema is current (`EnsureSchemaUpToDateAsync` — fail-fast on a
pending migration) and seeds roles/break-glass admin. Env-gated in `Program.cs`.
## What is now testable and exactly how
- **6 new Foundation tests** (`Tests/Baya.Test.Foundation/Scheduling/`): each cadence job reads the right config
key and dispatches the right command (incl. the payout job asserting `SystemInitiated=true` + the trailing
window); the scheduler runs a job at startup under `scheduler:{name}`, keeps siblings alive when one throws, and
stays **dormant under the `Testing` environment**.
- **Live cadence check:** set a short `no_show_scan_cadence_hours` / `verification_expiry_scan_cadence_hours` (or
a short interval) in `platform_configs`, run the API (Development), and watch the job fire on schedule in the
logs, producing the same result as the admin manual trigger.
- **Migration path:** `dotnet run -- migrate` applies + seeds and exits; a deployed-env boot with a pending
migration fails fast with the list of pending migrations.
## What is mocked / deferred (follow-ups)
- **Moadian reconciliation + refund-settlement poll** are **Phase 8's** jobs — they have no command/cadence key
today and Phase 8 explicitly owns registering them. They slot in as new `IRecurringJob`s with one `AddSingleton`
— no scheduler change. Documented in the mocks-registry row.
- **Redis** — the scale-out gate above (only when >1 instance).
## Contracts produced/consumed
- `PayoutBatchDto.initiatedByAdminId` is now nullable (`null` = system/scheduled batch). Updated
`dev/contracts/domains/payouts.md` + `dev/contracts/openapi/swagger.v1.json`. No other wire change.
## Files
New: `Services/Scheduling/{IRecurringJob, RecurringJobSchedulerHostedService}.cs` +
`Services/Scheduling/Jobs/{BookingRequestExpiry, NotificationRetention, CredentialExpiryScan, NoShowSweep,
WeeklyPayoutGeneration}Job.cs`; migration `RefinementPhase7SystemPayoutBatch`; 2 test files. Deleted: the two old
hosted services. Changed: `AddPersistenceServices` (registration) + `EnsureSchemaUpToDateAsync`; `Program.cs`
(migrate one-shot + env-gated boot); `NursePayoutBatch`/`NursePayoutBatchConfig`/`PayoutBatchDto` (nullable
initiator); `GeneratePayoutBatchCommand`(+Handler)/`AdminPayoutsController` (system-initiated path).
+38 -11
View File
@@ -56,10 +56,15 @@ You are a **senior .NET software engineer** working on this codebase. That means
| Update DB | `dotnet ef database update --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api` |
**Default URL:** `https://localhost:5002` — Swagger at `/swagger`.
On boot (non-Testing), `Program.cs` calls `ApplyMigrationsAsync()` + `SeedDefaultUsersAsync()` (roles always;
a bootstrap admin **only if `Seed:AdminUsername`/`Seed:AdminPassword` are configured** — never a committed
credential), and **only in Development** `SeedPaymentGatewaysAsync()` (the sandbox gateway) + `SeedDemoWorldAsync()`
(the demo marketplace, see Persistence below). A reachable SQL Server is required to start. Startup **fails fast**
**Migrations are split from boot (refinement-phase-7).** `dotnet run -- migrate` (the deploy-time one-shot / a CI
`dotnet ef database update`) applies migrations + the idempotent seeders, then exits — so multi-instance boots never
race on DDL and the runtime login needs no permanent DDL rights. **In Development**, boot still migrates + seeds for
convenience: `Program.cs` calls `ApplyMigrationsAsync()` + `SeedDefaultUsersAsync()` (roles always; a bootstrap admin
**only if `Seed:AdminUsername`/`Seed:AdminPassword` are configured** — never a committed credential) + the
Development-only `SeedPaymentGatewaysAsync()` (sandbox gateway) + `SeedDemoWorldAsync()` (demo marketplace, see
Persistence below). **In deployed environments**, boot instead only *checks* the schema is current
(`EnsureSchemaUpToDateAsync` — fail fast on a pending migration) and seeds roles/break-glass admin (idempotent). A
reachable SQL Server is required to start. Startup **fails fast**
(`StartupSecretsGuard`) if a load-bearing secret — the DB connection strings, and in deployed environments the
JWE + field-encryption keys — is missing or left at its committed `SET_VIA_USER_SECRETS_OR_ENV` placeholder
(refinement-phase-5). Development supplies working dev-only crypto keys via `appsettings.Development.json`; only
@@ -90,7 +95,7 @@ src/
│ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, Identity/ (NurseProfile, CustomerProfile, Patient, NurseBankAccount, CustomerAddress), Geography/ (Province, City, District, NurseServiceArea), Catalog/ (ServiceCategory, ServiceOptionGroup, ServiceOptionValue, NurseServiceVariant, NurseServiceVariantOption, PriceUnits), Verification/ (NurseVerification, VerificationStepType, VerificationStep, VerificationDocument, NurseCredential + VerificationStatus/VerificationStepStatus enums), Search/ (NurseSearchIndex — the denormalized search projection), Booking/ (BookingRequest — the money-free pre-payment intent + BookingRequestStatus/BookingRequestTransitions forward-only status guard + CaregiverGender codes; b9 adds Booking/BookingSession/BookingCareInstruction/VisitVerification/CancellationPolicy + their status/transition tables + BookingAmounts money split), Payments/ (b10 ledger/txn/webhook/gateway + LedgerPosting; b11 adds Refunds/ + Invoices/), Bnpl/ (b12 BnplTransaction + BnplStatus/BnplTransitions/BnplEligibilityStatus/BnplProviderCodes — the net-of-fee card-payment model), Payouts/ (b13 NursePayoutBatch/NursePayout/NursePayoutBookingLink + PayoutBatchStatus/PayoutStatus/*Transitions — the weekly payout run), Reviews/ (b14 Review (IAuditable) + ReviewModerationStatus/ReviewModerationAction codes + ReviewTagMaster/ReviewTagLink + PatientCareRecord — moderated reviews, tag vocab & patient-scoped encrypted clinical notes), + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts), BaseEntity, IEntity, ITimeModification, IAuditableEntity, IAuditable (audit-row marker)
│ └── Baya.Application Features/ (Commands & Queries; Identity area = auth + profiles/patients/nurse-bank-accounts; Geography/ServiceAreas/Addresses areas = geo hierarchy + nurse service areas + customer addresses; Catalog/Variants areas = admin catalog skeleton + nurse pricing variants; Verification area = the b6 nurse-verification pipeline (submit/status/uploads/automated runs + admin review/suspend/scan + public trust badge); Search area = the b7 discovery query + admin index-rebuild; Booking area = the b8 booking-request lifecycle (create/accept/reject/cancel + role-scoped inbox/detail + the expiry sweep command); Bookings area = the b9 booking engine (convert/detail/list/transition, care-instructions submit+gated read, EVV check-in/out + today's sessions + admin EVV queue, cancel booking/session, no-show sweep, cancellation-policy CRUD); Payments area = the b10 money core (initiate/webhook/confirm-post-ledger/nurse-payable-balance); Refunds + Invoices areas = the b11 reversal leg (create refund/confirm-settlement/mark-failed [refinement-phase-6: the BNPL/manual `processing → succeeded` clearing]/write-off clawback/list/refund-status; issue invoice/get invoice); Bnpl area = the b12 provider-financed-installment checkout (eligibility/initiate/verify/settle/revert/callback/status + BookingConversion shared with b10); Payouts area = the b13 weekly payout engine (compute-eligible/generate-batch/process/retry/mark-failed + admin batch detail/list + nurse history; PayoutSettlement shared ledger+clawback-netting step); Reviews area = the b14 reviews & ratings (submit/moderate/attach-tags + public list/tag-aggregates + admin moderation-queue; RecomputeNurseRating from-source helper + ReviewCache); PatientCareRecords area = the b14 encrypted patient-scoped clinical notes (write/history under strict clinical access); + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + IGeocoder + IVariantSnapshotSerializer + IShahkarVerifier + IIdentityKycProvider + ICredentialVerifier + Contracts/Reviews IReviewModerationService (AI review pre-screen seam) + the platform-signal facade contracts + Contracts/Search (INurseSearch read seam + ISearchIndexMaintainer write seam) + Contracts/Persistence per-domain repositories on IUnitOfWork incl. IVerificationRepository + IReviewRepository + IPatientCareRecordRepository), Models/, pipeline behaviors (Common/ — validators auto-registered from this assembly; VerificationAggregator + IdentityNameMatch helpers)
├── Infrastructure/
│ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII value converters & phone-hash sync), ValueConversion/, Repositories/, Configuration/ (per-area EF config incl. SearchConfig/ + BookingConfig/ — b8 BookingRequest + b9 bookings/sessions/care/EVV/cancellation-policy configs & seed + ReviewsConfig/ — b14 reviews/tags-master (seeded)/tag-links/patient-care-records configs), Repositories/ (incl. b9 BookingRepository + CancellationPolicyRepository + b14 ReviewRepository + PatientCareRecordRepository), Migrations/, Interceptors/ (AuditFieldInterceptor — audit-fields + audit-log rows), Services/ (DB-backed platform-signal facades + notification-retention hosted service + Search/ = SearchIndexMaintainer + SqlNurseSearch + Booking/ = BookingRequestExpiryHostedService)
│ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII value converters & phone-hash sync), ValueConversion/, Repositories/, Configuration/ (per-area EF config incl. SearchConfig/ + BookingConfig/ — b8 BookingRequest + b9 bookings/sessions/care/EVV/cancellation-policy configs & seed + ReviewsConfig/ — b14 reviews/tags-master (seeded)/tag-links/patient-care-records configs), Repositories/ (incl. b9 BookingRepository + CancellationPolicyRepository + b14 ReviewRepository + PatientCareRecordRepository), Migrations/, Interceptors/ (AuditFieldInterceptor — audit-fields + audit-log rows), Services/ (DB-backed platform-signal facades + Scheduling/ = RecurringJobSchedulerHostedService + Jobs/ (the IRecurringJob crons — refinement-phase-7) + Search/ = SearchIndexMaintainer + SqlNurseSearch)
│ ├── Baya.Infrastructure.Identity Jwt/, Identity/ (Managers, Stores, PermissionManager, Seed, CurrentUser/)
│ ├── Baya.Infrastructure.CrossCutting Serilog wiring + Seams/ (mock impls of the cross-cutting seams incl. LoggingSmsSender + MockBankAccountOwnershipVerifier + MockShahkarVerifier + MockIdentityKycProvider + MockCredentialVerifier + MockPaymentCaptureSimulator + MockBankTransferProvider + MockReviewModerationService) + AddCrossCuttingSeams
│ └── Baya.Infrastructure.Monitoring HealthChecks, OpenTelemetry, prometheus-net
@@ -127,9 +132,8 @@ contracts — `IPlatformConfig` (typed cached config), `IHolidayCalendar` (bank-
trail), `INotificationService` (per-user notification reads/commands), `ISupportAlertService` (internal
worklist) — are implemented in **`Baya.Infrastructure.Persistence/Services/`** and registered by
`AddPersistenceServices`, *not* in CrossCutting. The real `INotificationDispatcher` (in-app
`notifications` write) also lives there and **supersedes** the b0 log stub. The
`NotificationRetentionHostedService` (the retention/`IJobScheduler` seam) is registered as a hosted
service there too. Other domains call these contracts; they never re-create the tables. The
`notifications` write) also lives there and **supersedes** the b0 log stub. Other domains call these
contracts; they never re-create the tables. The
`AuditFieldInterceptor` additionally writes an append-only `audit_logs` row for any `IAuditable` entity
(`PlatformConfig`, `PartnerCenter`, `Review`, and — refinement-phase-6 — the admin-decided money & trust
entities `Refund`, `NurseClawback`, `NursePayout`, `NursePayoutBatch`, `NurseVerification`; encrypted columns
@@ -241,8 +245,8 @@ b9/b10). One customer requests one nurse for a patient/variant/address/date; the
30-minute payment window) or rejects before a frozen response deadline; unanswered/unpaid requests auto-expire.
Features under `Baya.Application/Features/Booking/{Commands|Queries}/`; config in
`Persistence/Configuration/BookingConfig/`; per-domain repo (`IBookingRequestRepository`) on `IUnitOfWork`;
the recurring sweep is `Persistence/Services/Booking/BookingRequestExpiryHostedService` (reuses the b1
`IJobScheduler`/`BackgroundService` seam). Load-bearing rules:
the recurring expiry sweep is the `booking_request_expiry` `IRecurringJob` run by the scheduler (see
"Unattended operation" below — refinement-phase-7 re-homed it from a standalone hosted service). Load-bearing rules:
- **No money, ever, and no `bookings` row.** A request carries no price/total; accept only opens the payment
window. b9 consumes an `accepted_awaiting_payment` request → creates the booking → sets it `converted`.
- **Two-stage clinical disclosure (stage 1).** The nurse sees **only** the unencrypted, limited `customer_notes`
@@ -512,6 +516,29 @@ controllers `TicketsController` / `AdminTicketsController` / `AdminPartnerCenter
manual-approve at MVP; `VerifyPartnerCenter` records the human decision. There is **no** telephony/VoIP seam
(the emergency call is an out-of-platform `tel:` link by design). This is the last backend phase.
**Unattended operation — the recurring-job scheduler (refinement-phase-7).** A single in-process scheduler,
`Persistence/Services/Scheduling/RecurringJobSchedulerHostedService`, drives every registered `IRecurringJob`
(`Services/Scheduling/Jobs/`) on its own cadence — replacing the two stand-alone `PeriodicTimer` hosted services
and giving the previously admin-manual sweeps a schedule, **using no new infrastructure** (SQL Server stays the
only external dependency). Jobs, each reading its seeded `platform_configs` cadence key via `IPlatformConfig`:
`booking_request_expiry` (1 min const) · `notification_retention` (24 h const) · `verification_expiry_scan`
(`verification_expiry_scan_cadence_hours`) · `no_show_sweep` (`no_show_scan_cadence_hours`) ·
`weekly_payout_generation` (`nurse_payout_interval_days`). Load-bearing rules:
- **Add a cron = implement `IRecurringJob` + one `AddSingleton<IRecurringJob, …>()`** in `AddPersistenceServices`.
Phase 8 registers the Moadian reconciliation + refund-settlement poll exactly this way. The scheduler owns the
per-tick DI scope, error isolation (a throwing tick never kills the loop), and the lock; a job says only *how
often* and *what one idempotent run does*.
- **Jobs must be idempotent** — a retry (or a second instance once the lock is Redis-backed) must never double-pay
or double-post; the DB uniques/state-machines are the backstop. Each tick runs under
`IDistributedLock("scheduler:{name}")` — in-proc today, the **>1-instance scale-out gate** (swap the seam to
Redis to serialize ticks across nodes; single-instance MVP needs neither Redis nor Hangfire/Quartz).
- **Money movement stays human-approved.** The payout job schedules *generation* only (a `draft` batch, recorded
system-initiated — `NursePayoutBatch.InitiatedByAdminId` is nullable = "no human initiator"); the irreversible
`process` step remains an explicit admin action. The command's `SystemInitiated` flag is scheduler-only —
`AdminPayoutsController` neutralizes any request-supplied value.
- **Admin manual triggers remain overrides** (the same idempotent commands). The scheduler is **dormant under the
`Testing` environment** so integration tests stay deterministic; each job/command is unit-tested directly.
**Keeping the Project map current.** When a change touches the architecture — adds, removes, or
renames a project/assembly, a Clean-Architecture layer, or a major folder, or changes a cross-layer
dependency — you **must** update this Project map (and the dependency rule above, if affected) in the
@@ -529,7 +556,7 @@ builder.ValidateRequiredSecrets() // refinement-phase-5: fail fast on missing/p
ConfigureHealthChecks() · SetupOpenTelemetry()
AddApplicationServices() // Mediator + pipeline behaviors (Logging → Metrics → Validate)
RegisterIdentityServices(…, requireHttpsMetadata) // Identity, JWT/JWE (RequireHttpsMetadata on outside Dev/Testing), ICurrentUser
AddPersistenceServices(...) // DbContext (+ AuditFieldInterceptor), UnitOfWork, repositories
AddPersistenceServices(...) // DbContext (+ AuditFieldInterceptor), UnitOfWork, repositories, the IRecurringJob crons + RecurringJobSchedulerHostedService (refinement-phase-7)
AddCrossCuttingSeams(config) // IDateTimeProvider, IFieldEncryptor, ICacheService, IObjectStorage, INotificationDispatcher (mocks)
AddWebFrameworkServices() // API versioning + snake_case routing
AddCorsPolicies(config) // browser CORS policy from Cors:AllowedOrigins (refinement-phase-0; default http://localhost:3000 in Dev)
@@ -42,7 +42,9 @@ public sealed class AdminPayoutsController(ISender sender) : BaseController
[HttpPost("batches")]
[ProducesOkApiResponseType<GeneratePayoutBatchResult>]
public async Task<IActionResult> Generate(GeneratePayoutBatchCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
// SystemInitiated is scheduler-only — neutralize any request-supplied value so an API caller can never
// record a batch without an authenticated admin initiator (refinement-phase-7).
=> OperationResult(await sender.Send(command with { SystemInitiated = false }, cancellationToken));
[HttpPost("batches/{id}/process")]
[ProducesOkApiResponseType<ExecutePayoutBatchResult>]
+29 -8
View File
@@ -110,22 +110,43 @@ builder.Services.ConfigureGrpcPluginServices();
var app = builder.Build();
// Integration tests (WebApplicationFactory, env "Testing") run on in-memory SQLite — the SQL Server
// migrations can't apply there; the test factory does EnsureCreated + seeding itself.
if (!app.Environment.IsEnvironment("Testing"))
// Deploy-time migration one-shot (refinement-phase-7): `dotnet run -- migrate` (or `<binary> migrate`) applies
// EF migrations + the idempotent seeders, then exits. Running DDL as a separate deploy step means normal boots —
// especially concurrent multi-instance start-ups — never race on schema, and the runtime login needs no
// permanent DDL rights.
if (args.Any(a => string.Equals(a, "migrate", StringComparison.OrdinalIgnoreCase)))
{
await app.ApplyMigrationsAsync();
await app.SeedDefaultUsersAsync();
// Development-only: a sandbox payment gateway (all-zeros merchant id) and a demo marketplace
// (nurses/variants/search rows, customers/patients) so the real-path screens aren't empty. Neither
// belongs in a deployed DB — a production gateway is an admin action, so this never runs in
// Production/Staging. Both are idempotent.
if (app.Environment.IsDevelopment())
{
await app.SeedPaymentGatewaysAsync();
await app.SeedDemoWorldAsync();
}
return;
}
// Integration tests (WebApplicationFactory, env "Testing") run on in-memory SQLite — the SQL Server
// migrations can't apply there; the test factory does EnsureCreated + seeding itself.
if (!app.Environment.IsEnvironment("Testing"))
{
if (app.Environment.IsDevelopment())
{
// Local convenience: apply migrations + seed on boot. Development-only: a sandbox payment gateway
// (all-zeros merchant id) and a demo marketplace (nurses/variants/search rows, customers/patients) so the
// real-path screens aren't empty. Neither belongs in a deployed DB — both are idempotent.
await app.ApplyMigrationsAsync();
await app.SeedDefaultUsersAsync();
await app.SeedPaymentGatewaysAsync();
await app.SeedDemoWorldAsync();
}
else
{
// Deployed: DDL is the separate `migrate` step above. Boot only *checks* the schema is current (fail fast
// on a pending migration) and seeds idempotent runtime data (roles + any configured break-glass admin).
await app.EnsureSchemaUpToDateAsync();
await app.SeedDefaultUsersAsync();
}
}
if (app.Environment.IsDevelopment())
@@ -33,7 +33,14 @@ internal sealed class GeneratePayoutBatchCommandHandler(
public async ValueTask<OperationResult<GeneratePayoutBatchResult>> Handle(
GeneratePayoutBatchCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } adminId)
// A scheduled (system-initiated) run has no human initiator; the HTTP path still requires an authenticated
// admin and records their id. SystemInitiated can only be set in-process by the scheduler.
int? initiatedByAdminId;
if (request.SystemInitiated)
initiatedByAdminId = null;
else if (currentUser.UserId is { } adminId)
initiatedByAdminId = adminId;
else
return OperationResult<GeneratePayoutBatchResult>.UnauthorizedResult("Not authenticated.");
var now = dateTimeProvider.UtcNow.UtcDateTime;
@@ -57,7 +64,7 @@ internal sealed class GeneratePayoutBatchCommandHandler(
PeriodStart = request.PeriodStart,
PeriodEnd = periodEnd,
ProcessingDate = processingDate,
InitiatedByAdminId = adminId
InitiatedByAdminId = initiatedByAdminId
};
var nurseIds = eligible.Select(e => e.NurseId).Distinct().ToList();
@@ -9,4 +9,13 @@ namespace Baya.Application.Features.Payouts.Commands.GeneratePayoutBatch;
/// clawbacks, snapshotting the verified primary IBAN, linking each booking under the UNIQUE guard). Returns the
/// draft batch + payouts for admin preview; no money moves until <c>process</c>.</summary>
public record GeneratePayoutBatchCommand(DateOnly PeriodStart, DateOnly PeriodEnd)
: IRequest<OperationResult<GeneratePayoutBatchResult>>;
: IRequest<OperationResult<GeneratePayoutBatchResult>>
{
/// <summary>
/// True only when the in-process scheduler runs the weekly generation unattended (refinement-phase-7): the
/// batch is recorded with a null initiator instead of requiring an authenticated admin. The HTTP path always
/// leaves this <c>false</c> — <c>AdminPayoutsController</c> neutralizes any request-supplied value — so it can
/// never be set by an API caller.
/// </summary>
public bool SystemInitiated { get; init; }
}
@@ -27,7 +27,7 @@ public record PayoutBatchDto(
string TotalAmount,
int PayoutCount,
string Status,
int InitiatedByAdminId,
int? InitiatedByAdminId,
DateTime? ProcessedAt,
string? FailureNotes,
DateTimeOffset CreatedAt);
@@ -35,8 +35,9 @@ public class NursePayoutBatch : BaseEntity<long>, IAuditable
/// <summary>Guarded — mutated only through <see cref="TransitionTo"/> so every write goes through the machine.</summary>
public string Status { get; private set; } = PayoutBatchStatus.Draft;
/// <summary>The admin who initiated the run (FK <c>users</c>). A future cron sets its own service id.</summary>
public int InitiatedByAdminId { get; set; }
/// <summary>The admin who initiated the run (FK <c>users</c>), or <c>null</c> for a system-initiated
/// (scheduled/unattended) batch — the weekly cron has no human initiator (refinement-phase-7).</summary>
public int? InitiatedByAdminId { get; set; }
public DateTime? ProcessedAt { get; private set; }
@@ -25,7 +25,8 @@ internal sealed class NursePayoutBatchConfig : IEntityTypeConfiguration<NursePay
builder.HasMany(b => b.Payouts).WithOne(p => p.Batch).HasForeignKey(p => p.BatchId).IsRequired();
builder.HasOne<User>().WithMany().HasForeignKey(b => b.InitiatedByAdminId).IsRequired();
// Nullable: a system-initiated (scheduled) batch has no admin initiator (refinement-phase-7).
builder.HasOne<User>().WithMany().HasForeignKey(b => b.InitiatedByAdminId).IsRequired(false);
builder.HasQueryFilter(b => b.DeletedAt == null);
}
@@ -0,0 +1,67 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Baya.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class RefinementPhase7SystemPayoutBatch : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_NursePayoutBatches_Users_InitiatedByAdminId",
schema: "payouts",
table: "NursePayoutBatches");
migrationBuilder.AlterColumn<int>(
name: "InitiatedByAdminId",
schema: "payouts",
table: "NursePayoutBatches",
type: "int",
nullable: true,
oldClrType: typeof(int),
oldType: "int");
migrationBuilder.AddForeignKey(
name: "FK_NursePayoutBatches_Users_InitiatedByAdminId",
schema: "payouts",
table: "NursePayoutBatches",
column: "InitiatedByAdminId",
principalSchema: "usr",
principalTable: "Users",
principalColumn: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_NursePayoutBatches_Users_InitiatedByAdminId",
schema: "payouts",
table: "NursePayoutBatches");
migrationBuilder.AlterColumn<int>(
name: "InitiatedByAdminId",
schema: "payouts",
table: "NursePayoutBatches",
type: "int",
nullable: false,
defaultValue: 0,
oldClrType: typeof(int),
oldType: "int",
oldNullable: true);
migrationBuilder.AddForeignKey(
name: "FK_NursePayoutBatches_Users_InitiatedByAdminId",
schema: "payouts",
table: "NursePayoutBatches",
column: "InitiatedByAdminId",
principalSchema: "usr",
principalTable: "Users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
}
}
}
@@ -3638,7 +3638,7 @@ namespace Baya.Infrastructure.Persistence.Migrations
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<int>("InitiatedByAdminId")
b.Property<int?>("InitiatedByAdminId")
.HasColumnType("int");
b.Property<DateTimeOffset?>("ModifiedAt")
@@ -5610,9 +5610,7 @@ namespace Baya.Infrastructure.Persistence.Migrations
{
b.HasOne("Baya.Domain.Entities.User.User", null)
.WithMany()
.HasForeignKey("InitiatedByAdminId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
.HasForeignKey("InitiatedByAdminId");
});
modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayoutBookingLink", b =>
@@ -13,11 +13,12 @@ using Baya.Infrastructure.Persistence.Interceptors;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Baya.Infrastructure.Persistence.Services.Analytics;
using Baya.Infrastructure.Persistence.Services.Audit;
using Baya.Infrastructure.Persistence.Services.Booking;
using Baya.Infrastructure.Persistence.Services.Configuration;
using Baya.Infrastructure.Persistence.Services.Holidays;
using Baya.Infrastructure.Persistence.Services.Notifications;
using Baya.Infrastructure.Persistence.Services.Payments;
using Baya.Infrastructure.Persistence.Services.Scheduling;
using Baya.Infrastructure.Persistence.Services.Scheduling.Jobs;
using Baya.Infrastructure.Persistence.Services.Search;
using Baya.Infrastructure.Persistence.Services.Seeding;
using Baya.Infrastructure.Persistence.Services.SupportAlerts;
@@ -60,12 +61,16 @@ public static class ServiceCollectionExtensions
// supersedes the interim NursePayoutStatusService (dispute-window derivation); the refund fork is unchanged.
services.AddScoped<INursePayoutStatus, NursePayoutLinkStatusService>();
// Retention job seam (mock = in-process interval runner; real Hangfire/Quartz deferred).
services.AddHostedService<NotificationRetentionHostedService>();
// Booking-request expiry sweep (same in-process interval-runner seam): auto-expires stale
// pending/awaiting-payment requests. Also reachable via the admin manual-trigger endpoint.
services.AddHostedService<BookingRequestExpiryHostedService>();
// Unattended operation (refinement-phase-7). One in-process scheduler drives every IRecurringJob on its
// own cadence — the two re-homed sweeps plus the previously admin-manual crons, each reading its seeded
// cadence key. No new infra: SQL Server stays the only external dependency (Hangfire/Quartz + Redis are the
// documented scale-out gate for >1 instance). Phase 8 adds the Moadian reconciliation job the same way.
services.AddSingleton<IRecurringJob, BookingRequestExpiryJob>();
services.AddSingleton<IRecurringJob, NotificationRetentionJob>();
services.AddSingleton<IRecurringJob, CredentialExpiryScanJob>();
services.AddSingleton<IRecurringJob, NoShowSweepJob>();
services.AddSingleton<IRecurringJob, WeeklyPayoutGenerationJob>();
services.AddHostedService<RecurringJobSchedulerHostedService>();
// Search (backend-phase-7). The index maintainer keeps nurse_search_index consistent inline inside
// each source write's unit of work. The INurseSearch backend is config-selected — SQL is the real
@@ -97,6 +102,25 @@ public static class ServiceCollectionExtensions
await context.Database.MigrateAsync();
}
/// <summary>
/// Boot-time schema <b>check</b> (refinement-phase-7) for deployed environments: applying migrations is a
/// separate deploy step (the <c>migrate</c> one-shot / a CI <c>dotnet ef database update</c>), so concurrent
/// multi-instance start-ups never race on DDL and the app login needs no permanent DDL rights. If any
/// migration is pending, fail fast with a clear message rather than starting against a stale schema.
/// </summary>
public static async Task EnsureSchemaUpToDateAsync(this WebApplication app)
{
await using var scope = app.Services.CreateAsyncScope();
var context = scope.ServiceProvider.GetService<ApplicationDbContext>()
?? throw new Exception("Database Context Not Found");
var pending = (await context.Database.GetPendingMigrationsAsync()).ToList();
if (pending.Count > 0)
throw new InvalidOperationException(
$"Database schema is not up to date — {pending.Count} migration(s) pending: {string.Join(", ", pending)}. " +
"Run the deploy-time migration step (`dotnet run -- migrate`, or `dotnet ef database update` in CI) before starting the API.");
}
/// <summary>
/// Idempotently seeds one active <c>standard</c> payment gateway so the b10 card rail has a selectable
/// provider out of the box. <c>config_json</c> is encrypted at rest by the EF converter on save (so it
@@ -1,53 +0,0 @@
#nullable enable
using Baya.Application.Features.Booking.Commands.ExpireBookingRequests;
using Mediator;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Baya.Infrastructure.Persistence.Services.Booking;
/// <summary>
/// The recurring expiry sweep for <c>booking_requests</c> (reuses the b1 in-process interval-runner seam;
/// real Hangfire/Quartz is deferred). Each tick sends <see cref="ExpireBookingRequestsCommand"/>, which
/// transitions stale rows (<c>expired_no_response</c> / <c>payment_deadline_expired</c>) in bounded,
/// idempotent batches. The interval is short because the payment window is only 30 minutes; there is no b1
/// interval config key for booking expiry, so it is a documented constant.
/// </summary>
internal sealed class BookingRequestExpiryHostedService(
IServiceScopeFactory scopeFactory,
ILogger<BookingRequestExpiryHostedService> logger) : BackgroundService
{
private static readonly TimeSpan Interval = TimeSpan.FromMinutes(1);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await SweepSafely(stoppingToken);
using var timer = new PeriodicTimer(Interval);
while (await timer.WaitForNextTickAsync(stoppingToken))
await SweepSafely(stoppingToken);
}
private async Task SweepSafely(CancellationToken cancellationToken)
{
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var sender = scope.ServiceProvider.GetRequiredService<ISender>();
var result = await sender.Send(new ExpireBookingRequestsCommand(), cancellationToken);
if (result.IsSuccess && result.Result is { } counts && (counts.ExpiredNoResponse > 0 || counts.PaymentDeadlineExpired > 0))
logger.LogInformation(
"Booking-request expiry swept {NoResponse} expired-no-response and {PaymentExpired} payment-window-expired requests",
counts.ExpiredNoResponse, counts.PaymentDeadlineExpired);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Host is shutting down — expected, don't log as an error.
}
catch (Exception ex)
{
logger.LogError(ex, "Booking-request expiry sweep failed");
}
}
}
@@ -1,50 +0,0 @@
#nullable enable
using Baya.Application.Contracts.Notifications;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Baya.Infrastructure.Persistence.Services.Notifications;
/// <summary>
/// The scheduling seam for the notification retention job (mock = an in-process interval runner). It
/// periodically hard-deletes read notifications older than the retention window; unread notifications are
/// never deleted. Real Hangfire/Quartz is deferred — swapping it in is a registration change here.
/// </summary>
internal sealed class NotificationRetentionHostedService(
IServiceScopeFactory scopeFactory,
ILogger<NotificationRetentionHostedService> logger) : BackgroundService
{
private const int RetentionDays = 90;
private static readonly TimeSpan Interval = TimeSpan.FromHours(24);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Run once at startup, then on the interval.
await PurgeSafely(stoppingToken);
using var timer = new PeriodicTimer(Interval);
while (await timer.WaitForNextTickAsync(stoppingToken))
await PurgeSafely(stoppingToken);
}
private async Task PurgeSafely(CancellationToken cancellationToken)
{
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var notifications = scope.ServiceProvider.GetRequiredService<INotificationService>();
var removed = await notifications.PurgeOldReadAsync(RetentionDays, cancellationToken);
if (removed > 0)
logger.LogInformation("Notification retention purged {Count} read notifications older than {Days}d", removed, RetentionDays);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Host is shutting down — expected, don't log as an error.
}
catch (Exception ex)
{
logger.LogError(ex, "Notification retention purge failed");
}
}
}
@@ -0,0 +1,30 @@
#nullable enable
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Baya.Infrastructure.Persistence.Services.Scheduling;
/// <summary>
/// One recurring background job driven by <see cref="RecurringJobSchedulerHostedService"/>. Each
/// implementation re-homes a previously admin-manual (or hardcoded-interval) sweep behind the same seam so
/// the platform runs itself: the scheduler owns the loop, the per-tick DI scope, the distributed lock, and
/// the error handling, while the job only says <em>how often</em> it runs and <em>what</em> one idempotent
/// run does. Every run must be idempotent — a scheduler retry (or a second instance once the lock is
/// Redis-backed) must never double-pay or double-post; the DB uniques/state-machines are the backstop.
/// </summary>
internal interface IRecurringJob
{
/// <summary>Stable identifier — used for the distributed-lock key (<c>scheduler:{Name}</c>) and log scope.</summary>
string Name { get; }
/// <summary>
/// Resolves this job's run interval, read fresh each tick (usually from a <c>platform_configs</c> cadence
/// key via <see cref="Baya.Application.Contracts.Configuration.IPlatformConfig"/>) so an admin cadence
/// change takes effect without a restart. <paramref name="services"/> is the per-tick scoped provider.
/// </summary>
ValueTask<TimeSpan> GetIntervalAsync(IServiceProvider services, CancellationToken cancellationToken);
/// <summary>Executes one idempotent run within the provided per-tick DI scope.</summary>
ValueTask RunAsync(IServiceProvider services, CancellationToken cancellationToken);
}
@@ -0,0 +1,36 @@
#nullable enable
using System;
using System.Threading;
using System.Threading.Tasks;
using Baya.Application.Features.Booking.Commands.ExpireBookingRequests;
using Mediator;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Baya.Infrastructure.Persistence.Services.Scheduling.Jobs;
/// <summary>
/// Auto-expires stale <c>booking_requests</c> (re-homed from the b8 <c>BookingRequestExpiryHostedService</c>).
/// Each run sends <see cref="ExpireBookingRequestsCommand"/>, which transitions stale rows
/// (<c>expired_no_response</c> / <c>payment_deadline_expired</c>) in bounded, idempotent batches. The interval
/// is a short constant because the payment window is only 30 minutes; there is no cadence config key for it.
/// Also reachable via the admin manual trigger.
/// </summary>
internal sealed class BookingRequestExpiryJob(ILogger<BookingRequestExpiryJob> logger) : IRecurringJob
{
public string Name => "booking_request_expiry";
public ValueTask<TimeSpan> GetIntervalAsync(IServiceProvider services, CancellationToken cancellationToken)
=> ValueTask.FromResult(TimeSpan.FromMinutes(1));
public async ValueTask RunAsync(IServiceProvider services, CancellationToken cancellationToken)
{
var sender = services.GetRequiredService<ISender>();
var result = await sender.Send(new ExpireBookingRequestsCommand(), cancellationToken);
if (result.IsSuccess && result.Result is { } counts && (counts.ExpiredNoResponse > 0 || counts.PaymentDeadlineExpired > 0))
logger.LogInformation(
"Booking-request expiry swept {NoResponse} expired-no-response and {PaymentExpired} payment-window-expired requests",
counts.ExpiredNoResponse, counts.PaymentDeadlineExpired);
}
}
@@ -0,0 +1,40 @@
#nullable enable
using System;
using System.Threading;
using System.Threading.Tasks;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Features.Verification.Commands.ScanExpiringCredentials;
using Mediator;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Baya.Infrastructure.Persistence.Services.Scheduling.Jobs;
/// <summary>
/// Scans for lapsed time-limited verification steps (criminal-record especially), reverting each to expired,
/// raising a renewal alert/notification, and re-gating bookability. Previously admin-manual
/// (<c>admin_verifications/scan_expiring</c>) — now scheduled on the <c>verification_expiry_scan_cadence_hours</c>
/// cadence; the admin trigger remains an override. Sends <see cref="ScanExpiringCredentialsCommand"/> (idempotent).
/// </summary>
internal sealed class CredentialExpiryScanJob(ILogger<CredentialExpiryScanJob> logger) : IRecurringJob
{
public string Name => "verification_expiry_scan";
public async ValueTask<TimeSpan> GetIntervalAsync(IServiceProvider services, CancellationToken cancellationToken)
{
var config = services.GetRequiredService<IPlatformConfig>();
var hours = await config.GetConfig<int>("verification_expiry_scan_cadence_hours", cancellationToken);
return TimeSpan.FromHours(hours);
}
public async ValueTask RunAsync(IServiceProvider services, CancellationToken cancellationToken)
{
var sender = services.GetRequiredService<ISender>();
var result = await sender.Send(new ScanExpiringCredentialsCommand(), cancellationToken);
if (result.IsSuccess && result.Result is { } scan && scan.RevertedNurses > 0)
logger.LogInformation(
"Credential-expiry scan reverted {Nurses} nurse(s) across {Steps} expired step(s)",
scan.RevertedNurses, scan.ScannedSteps);
}
}
@@ -0,0 +1,38 @@
#nullable enable
using System;
using System.Threading;
using System.Threading.Tasks;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Features.Bookings.Commands.DetectNoShowSessions;
using Mediator;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Baya.Infrastructure.Persistence.Services.Scheduling.Jobs;
/// <summary>
/// Flags booking sessions whose scheduled start passed with no EVV check-in as no-shows. Previously admin-manual
/// (<c>admin_evv/detect_no_shows</c>) — now scheduled on the <c>no_show_scan_cadence_hours</c> cadence; the admin
/// trigger remains an override. Sends <see cref="DetectNoShowSessionsCommand"/> (idempotent — an already-flagged
/// session is not re-flagged).
/// </summary>
internal sealed class NoShowSweepJob(ILogger<NoShowSweepJob> logger) : IRecurringJob
{
public string Name => "no_show_sweep";
public async ValueTask<TimeSpan> GetIntervalAsync(IServiceProvider services, CancellationToken cancellationToken)
{
var config = services.GetRequiredService<IPlatformConfig>();
var hours = await config.GetConfig<int>("no_show_scan_cadence_hours", cancellationToken);
return TimeSpan.FromHours(hours);
}
public async ValueTask RunAsync(IServiceProvider services, CancellationToken cancellationToken)
{
var sender = services.GetRequiredService<ISender>();
var result = await sender.Send(new DetectNoShowSessionsCommand(), cancellationToken);
if (result.IsSuccess && result.Result is { Missed: > 0 } sweep)
logger.LogInformation("No-show sweep flagged {Missed} missed session(s)", sweep.Missed);
}
}
@@ -0,0 +1,33 @@
#nullable enable
using System;
using System.Threading;
using System.Threading.Tasks;
using Baya.Application.Contracts.Notifications;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Baya.Infrastructure.Persistence.Services.Scheduling.Jobs;
/// <summary>
/// Hard-deletes read notifications older than the retention window (re-homed from the b1
/// <c>NotificationRetentionHostedService</c>). Unread notifications are never deleted. Retention window and
/// cadence are documented constants (no config key).
/// </summary>
internal sealed class NotificationRetentionJob(ILogger<NotificationRetentionJob> logger) : IRecurringJob
{
private const int RetentionDays = 90;
public string Name => "notification_retention";
public ValueTask<TimeSpan> GetIntervalAsync(IServiceProvider services, CancellationToken cancellationToken)
=> ValueTask.FromResult(TimeSpan.FromHours(24));
public async ValueTask RunAsync(IServiceProvider services, CancellationToken cancellationToken)
{
var notifications = services.GetRequiredService<INotificationService>();
var removed = await notifications.PurgeOldReadAsync(RetentionDays, cancellationToken);
if (removed > 0)
logger.LogInformation("Notification retention purged {Count} read notifications older than {Days}d", removed, RetentionDays);
}
}
@@ -0,0 +1,58 @@
#nullable enable
using System;
using System.Threading;
using System.Threading.Tasks;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Features.Payouts.Commands.GeneratePayoutBatch;
using Mediator;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Baya.Infrastructure.Persistence.Services.Scheduling.Jobs;
/// <summary>
/// Generates the weekly nurse-payout batch (previously admin-manual — <c>nurse_payout_interval_days</c> was seeded
/// but nothing read it, so nurses were paid only when an operator clicked). Scheduled on that cadence, it opens a
/// <c>draft</c> batch over the trailing window; the admin generate trigger remains an override.
///
/// <para><b>Generation only — never money movement.</b> Per the phase's "keep processing human-approved until trust
/// is earned", this schedules <em>batch generation</em>; the irreversible <c>process</c> step stays an explicit
/// admin action. The batch is <b>system-initiated</b> (<see cref="GeneratePayoutBatchCommand.SystemInitiated"/> →
/// null initiator). A quiet week (no eligible bookings) returns a benign failure, logged at debug — not an error.
/// Re-running over an overlapping window is safe: the <c>nurse_payout_booking_links.booking_id</c> UNIQUE prevents
/// re-selecting an already-paid booking.</para>
/// </summary>
internal sealed class WeeklyPayoutGenerationJob(ILogger<WeeklyPayoutGenerationJob> logger) : IRecurringJob
{
public string Name => "weekly_payout_generation";
public async ValueTask<TimeSpan> GetIntervalAsync(IServiceProvider services, CancellationToken cancellationToken)
{
var config = services.GetRequiredService<IPlatformConfig>();
var days = await config.GetConfig<int>("nurse_payout_interval_days", cancellationToken);
return TimeSpan.FromDays(days);
}
public async ValueTask RunAsync(IServiceProvider services, CancellationToken cancellationToken)
{
var clock = services.GetRequiredService<IDateTimeProvider>();
var config = services.GetRequiredService<IPlatformConfig>();
var sender = services.GetRequiredService<ISender>();
var intervalDays = await config.GetConfig<int>("nurse_payout_interval_days", cancellationToken);
var periodEnd = DateOnly.FromDateTime(clock.UtcNow.UtcDateTime);
var periodStart = periodEnd.AddDays(-Math.Max(intervalDays, 1));
var result = await sender.Send(
new GeneratePayoutBatchCommand(periodStart, periodEnd) { SystemInitiated = true }, cancellationToken);
if (result.IsSuccess && result.Result is { } generated)
logger.LogInformation(
"Weekly payout generation opened a draft batch of {Count} payout(s) totalling {Total} IRR (awaiting admin process)",
generated.Batch.PayoutCount, generated.Batch.TotalAmount);
else
// Expected on a quiet week (no eligible bookings) — not an operational error.
logger.LogDebug("Weekly payout generation produced no batch this run (no payout-eligible bookings).");
}
}
@@ -0,0 +1,113 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Baya.Application.Contracts.Payments;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Baya.Infrastructure.Persistence.Services.Scheduling;
/// <summary>
/// The single in-process job scheduler (refinement-phase-7): it drives every registered <see cref="IRecurringJob"/>
/// on its own cadence, replacing the two hand-written <c>PeriodicTimer</c> hosted services and giving the four
/// previously admin-manual sweeps (credential-expiry scan, EVV no-show sweep, weekly payout-batch generation,
/// and — Phase 8 — the Moadian reconciliation poll) a schedule. It intentionally uses <b>no new infrastructure</b>:
/// SQL Server stays the only external dependency, so a single-instance MVP needs neither Hangfire/Quartz nor Redis.
///
/// <para><b>Multi-instance readiness.</b> Each tick runs under <see cref="IDistributedLock"/>
/// (<c>scheduler:{Name}</c>). Today that lock is in-process (a no-op across instances); the moment a second API
/// instance runs it becomes the scale-out gate — swapping the lock seam to Redis serializes ticks across nodes.
/// Because every job is idempotent and the DB uniques/state-machines are authoritative, even a double-run is safe.</para>
///
/// <para><b>Not started under the "Testing" environment</b> so integration tests (WebApplicationFactory over
/// in-memory SQLite) stay deterministic — no background sweep mutates rows mid-assertion. Each job's underlying
/// command/service is unit-tested directly instead.</para>
/// </summary>
internal sealed class RecurringJobSchedulerHostedService(
IServiceScopeFactory scopeFactory,
IEnumerable<IRecurringJob> jobs,
IDistributedLock distributedLock,
IHostEnvironment environment,
ILogger<RecurringJobSchedulerHostedService> logger) : BackgroundService
{
// Floor so a mis-set cadence key (0 or negative) can never turn a loop into a hot spin.
private static readonly TimeSpan MinInterval = TimeSpan.FromSeconds(10);
// Used when a job's interval resolution throws (e.g. the config store is briefly unreachable).
private static readonly TimeSpan FallbackInterval = TimeSpan.FromMinutes(5);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (environment.IsEnvironment("Testing"))
return;
var jobList = jobs.ToArray();
logger.LogInformation("Recurring job scheduler starting with {Count} job(s): {Jobs}",
jobList.Length, string.Join(", ", jobList.Select(j => j.Name)));
// One independent loop per job so their cadences don't couple. A crash in one loop never stops the others.
await Task.WhenAll(jobList.Select(job => RunJobLoopAsync(job, stoppingToken)));
}
private async Task RunJobLoopAsync(IRecurringJob job, CancellationToken stoppingToken)
{
// Run once at startup, then on the job's own (re-read) cadence.
while (!stoppingToken.IsCancellationRequested)
{
var interval = await TickAndResolveIntervalAsync(job, stoppingToken);
try
{
await Task.Delay(interval, stoppingToken);
}
catch (OperationCanceledException)
{
return; // Host is shutting down.
}
}
}
private async Task<TimeSpan> TickAndResolveIntervalAsync(IRecurringJob job, CancellationToken stoppingToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var services = scope.ServiceProvider;
await RunTickAsync(job, services, stoppingToken);
return await ResolveIntervalAsync(job, services, stoppingToken);
}
private async Task RunTickAsync(IRecurringJob job, IServiceProvider services, CancellationToken stoppingToken)
{
try
{
await using var handle = await distributedLock.AcquireAsync($"scheduler:{job.Name}", stoppingToken);
await job.RunAsync(services, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// Host is shutting down — expected, don't log as an error.
}
catch (Exception ex)
{
// A failing tick must never kill the loop — the next tick retries on schedule.
logger.LogError(ex, "Recurring job {Job} failed", job.Name);
}
}
private async Task<TimeSpan> ResolveIntervalAsync(IRecurringJob job, IServiceProvider services, CancellationToken stoppingToken)
{
try
{
var interval = await job.GetIntervalAsync(services, stoppingToken);
return interval < MinInterval ? MinInterval : interval;
}
catch (Exception ex)
{
logger.LogError(ex, "Recurring job {Job} interval resolution failed; using fallback {Fallback}", job.Name, FallbackInterval);
return FallbackInterval;
}
}
}
@@ -0,0 +1,114 @@
using Baya.Application.Contracts.Payments;
using Baya.Infrastructure.Persistence.Services.Scheduling;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
namespace Baya.Test.Foundation.Scheduling;
/// <summary>
/// Orchestration tests for <c>RecurringJobSchedulerHostedService</c> (refinement-phase-7): it runs each job at
/// startup under the distributed lock, keeps sibling loops alive when one job throws, and stays dormant under the
/// Testing environment so integration tests are deterministic.
/// </summary>
public sealed class RecurringJobSchedulerTests
{
private sealed class FakeEnvironment(string environmentName) : IHostEnvironment
{
public string EnvironmentName { get; set; } = environmentName;
public string ApplicationName { get; set; } = "Baya.Test";
public string ContentRootPath { get; set; } = ".";
public Microsoft.Extensions.FileProviders.IFileProvider ContentRootFileProvider { get; set; } = null!;
}
private sealed class CountingJob(string name, bool throws = false) : IRecurringJob
{
private int _runCount;
public int RunCount => Volatile.Read(ref _runCount);
public string Name => name;
// Long interval: run once at startup, then idle for the whole test window.
public ValueTask<TimeSpan> GetIntervalAsync(IServiceProvider services, CancellationToken cancellationToken)
=> ValueTask.FromResult(TimeSpan.FromHours(1));
public ValueTask RunAsync(IServiceProvider services, CancellationToken cancellationToken)
{
Interlocked.Increment(ref _runCount);
if (throws)
throw new InvalidOperationException("boom");
return ValueTask.CompletedTask;
}
}
private static (IServiceScopeFactory scopeFactory, IDistributedLock @lock) Fakes(out IDistributedLock recordingLock)
{
var scopeFactory = new ServiceCollection().BuildServiceProvider().GetRequiredService<IServiceScopeFactory>();
recordingLock = Substitute.For<IDistributedLock>();
recordingLock.AcquireAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ValueTask<IAsyncDisposable>(Substitute.For<IAsyncDisposable>()));
return (scopeFactory, recordingLock);
}
private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (condition())
return;
await Task.Delay(20);
}
}
[Fact]
public async Task RunsJobAtStartup_UnderPerJobLock()
{
var (scopeFactory, @lock) = Fakes(out var recordingLock);
var job = new CountingJob("fake_job");
var scheduler = new RecurringJobSchedulerHostedService(
scopeFactory, [job], @lock, new FakeEnvironment("Development"),
NullLogger<RecurringJobSchedulerHostedService>.Instance);
await scheduler.StartAsync(default);
await WaitUntilAsync(() => job.RunCount >= 1, TimeSpan.FromSeconds(3));
await scheduler.StopAsync(default);
Assert.True(job.RunCount >= 1);
await recordingLock.Received().AcquireAsync("scheduler:fake_job", Arg.Any<CancellationToken>());
}
[Fact]
public async Task OneJobThrowing_DoesNotStopSiblingJobs()
{
var (scopeFactory, @lock) = Fakes(out _);
var throwing = new CountingJob("throwing", throws: true);
var healthy = new CountingJob("healthy");
var scheduler = new RecurringJobSchedulerHostedService(
scopeFactory, [throwing, healthy], @lock, new FakeEnvironment("Development"),
NullLogger<RecurringJobSchedulerHostedService>.Instance);
await scheduler.StartAsync(default);
await WaitUntilAsync(() => healthy.RunCount >= 1, TimeSpan.FromSeconds(3));
await scheduler.StopAsync(default);
Assert.True(throwing.RunCount >= 1);
Assert.True(healthy.RunCount >= 1);
}
[Fact]
public async Task TestingEnvironment_KeepsSchedulerDormant()
{
var (scopeFactory, @lock) = Fakes(out _);
var job = new CountingJob("fake_job");
var scheduler = new RecurringJobSchedulerHostedService(
scopeFactory, [job], @lock, new FakeEnvironment("Testing"),
NullLogger<RecurringJobSchedulerHostedService>.Instance);
await scheduler.StartAsync(default);
await Task.Delay(200);
await scheduler.StopAsync(default);
Assert.Equal(0, job.RunCount);
}
}
@@ -0,0 +1,102 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Features.Bookings.Commands.DetectNoShowSessions;
using Baya.Application.Features.Payouts.Commands.GeneratePayoutBatch;
using Baya.Application.Features.Verification.Commands.ScanExpiringCredentials;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Baya.Application.Models.Payouts;
using Baya.Application.Models.Verification;
using Baya.Infrastructure.Persistence.Services.Scheduling.Jobs;
using Mediator;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
namespace Baya.Test.Foundation.Scheduling;
/// <summary>
/// Unit tests for the recurring jobs (refinement-phase-7): each reads its seeded cadence key and dispatches the
/// same idempotent command the admin manual trigger sends. No timers involved — pure job behaviour.
/// </summary>
public sealed class RecurringJobTests
{
private static IServiceProvider ProviderWith(params (Type Type, object Impl)[] services)
{
var provider = Substitute.For<IServiceProvider>();
foreach (var (type, impl) in services)
provider.GetService(type).Returns(impl);
return provider;
}
[Fact]
public async Task CredentialExpiryScan_ReadsCadenceKey_AndSendsScanCommand()
{
var config = Substitute.For<IPlatformConfig>();
config.GetConfig<int>("verification_expiry_scan_cadence_hours", Arg.Any<CancellationToken>())
.Returns(new ValueTask<int>(6));
var sender = Substitute.For<ISender>();
sender.Send(Arg.Any<ScanExpiringCredentialsCommand>(), Arg.Any<CancellationToken>())
.Returns(OperationResult<ScanExpiringResult>.SuccessResult(new ScanExpiringResult(0, 0)));
var job = new CredentialExpiryScanJob(NullLogger<CredentialExpiryScanJob>.Instance);
var provider = ProviderWith((typeof(IPlatformConfig), config), (typeof(ISender), sender));
var interval = await job.GetIntervalAsync(provider, default);
await job.RunAsync(provider, default);
Assert.Equal(TimeSpan.FromHours(6), interval);
await sender.Received(1).Send(Arg.Any<ScanExpiringCredentialsCommand>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task NoShowSweep_ReadsCadenceKey_AndSendsDetectCommand()
{
var config = Substitute.For<IPlatformConfig>();
config.GetConfig<int>("no_show_scan_cadence_hours", Arg.Any<CancellationToken>())
.Returns(new ValueTask<int>(1));
var sender = Substitute.For<ISender>();
sender.Send(Arg.Any<DetectNoShowSessionsCommand>(), Arg.Any<CancellationToken>())
.Returns(OperationResult<NoShowSweepResult>.SuccessResult(new NoShowSweepResult(0)));
var job = new NoShowSweepJob(NullLogger<NoShowSweepJob>.Instance);
var provider = ProviderWith((typeof(IPlatformConfig), config), (typeof(ISender), sender));
var interval = await job.GetIntervalAsync(provider, default);
await job.RunAsync(provider, default);
Assert.Equal(TimeSpan.FromHours(1), interval);
await sender.Received(1).Send(Arg.Any<DetectNoShowSessionsCommand>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task WeeklyPayoutGeneration_ReadsInterval_AndSendsSystemInitiatedBatchOverTrailingWindow()
{
var config = Substitute.For<IPlatformConfig>();
config.GetConfig<int>("nurse_payout_interval_days", Arg.Any<CancellationToken>())
.Returns(new ValueTask<int>(7));
var clock = Substitute.For<IDateTimeProvider>();
clock.UtcNow.Returns(new DateTimeOffset(2026, 7, 13, 9, 0, 0, TimeSpan.Zero));
var sender = Substitute.For<ISender>();
sender.Send(Arg.Any<GeneratePayoutBatchCommand>(), Arg.Any<CancellationToken>())
.Returns(OperationResult<GeneratePayoutBatchResult>.SuccessResult(
new GeneratePayoutBatchResult(
new PayoutBatchDto(1, new DateOnly(2026, 7, 6), new DateOnly(2026, 7, 13),
new DateOnly(2026, 7, 13), "0", 0, "draft", null, null, null, DateTimeOffset.UtcNow),
[], [])));
var job = new WeeklyPayoutGenerationJob(NullLogger<WeeklyPayoutGenerationJob>.Instance);
var provider = ProviderWith(
(typeof(IPlatformConfig), config), (typeof(IDateTimeProvider), clock), (typeof(ISender), sender));
var interval = await job.GetIntervalAsync(provider, default);
await job.RunAsync(provider, default);
Assert.Equal(TimeSpan.FromDays(7), interval);
await sender.Received(1).Send(
Arg.Is<GeneratePayoutBatchCommand>(c =>
c.SystemInitiated
&& c.PeriodEnd == new DateOnly(2026, 7, 13)
&& c.PeriodStart == new DateOnly(2026, 7, 6)),
Arg.Any<CancellationToken>());
}
}