# Server persistence

EF Core rules, money, state machines, snapshots, the scheduler, and the domain invariants that live in the
database.

> Last verified: 2026-07-30 against commit `d3ec723`.

---

## 1. EF Core basics

```csharp
// ✅ project to a DTO in the query
var dto = await _db.Orders
    .AsNoTracking()
    .Where(o => o.UserId == userId)
    .Select(o => new OrderResult(o.Id, o.Status, o.CreatedAt))
    .ToListAsync(ct);

// ❌ loads the entity graph then maps in memory — N+1 risk
var orders = await _db.Orders.Include(o => o.Lines).ToListAsync();
var dtos = _mapper.Map<List<OrderResult>>(orders);
```

- **Always `AsNoTracking()`** on a read-only query.
- **Always project with `.Select()`** in a query — never hydrate full entities just to map them, and **never
  return an entity from a handler**.
- **Pagination is mandatory** on any unbounded list (`Skip`/`Take`). No unbounded `ToListAsync()`.
- Use `Include` **only** in a command handler that needs navigation properties loaded to mutate the aggregate.
- **Access the DB through `IUnitOfWork`** in Application handlers. `ApplicationDbContext` is referenced
  directly only inside Infrastructure.
- **Commit once per command**, at the end: `await unitOfWork.CommitAsync(ct)`.
- **One `IEntityTypeConfiguration<T>` per entity**, in `Persistence/Configuration/<Area>Config/`.
- **Mapster maps in the handler after the query**, never in the repository. Only write a custom
  `TypeAdapterConfig` when shapes genuinely diverge; register scans in `Program.cs`.
- **Never concatenate raw SQL.** EF parameterizes automatically. If you must drop to SQL, use
  `FromSqlInterpolated`, never `FromSqlRaw` with user data.

**Migrations:**

```bash
dotnet ef migrations add <Name> --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api
dotnet ef database update --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api
```

### Migrations are split from boot

`dotnet run -- migrate` (the deploy-time one-shot, or a CI `dotnet ef database update`) applies migrations
plus the idempotent seeders, then exits — so multi-instance boots never race on DDL and the runtime login
needs no permanent DDL rights.

| Environment | What boot does |
| --- | --- |
| Development | Migrates + seeds (roles always; a bootstrap admin **only if** `Seed:AdminUsername`/`Seed:AdminPassword` are configured), plus the Development-only gateway, demo-world and demo-lifecycle seeders |
| Deployed | Only **checks** the schema is current (`EnsureSchemaUpToDateAsync` — fail fast on a pending migration) and seeds roles / the break-glass admin, idempotently |

A reachable SQL Server is required to start.

### Soft delete

Every soft-deletable entity **must** declare a global query filter in its configuration:

```csharp
builder.HasQueryFilter(o => !o.IsDeleted);
```

Without it, soft-deleted rows appear in every query that doesn't explicitly exclude them — a silent data
leak. **Never add `Where(x => !x.IsDeleted)` per query**; the filter makes it automatic and auditable.

**Deactivate, never hard-delete.** `user_roles.revoked_at` has the same treatment, so a revoked grant
disappears from every role read automatically.

---

## 2. Audit

| Field | Type | Set by |
| --- | --- | --- |
| `CreatedAt` / `ModifiedAt` | `DateTimeOffset` | `AuditFieldInterceptor` |
| `CreatedById` / `ModifiedById` | `int?` | `AuditFieldInterceptor`, via `ICurrentUser` |

The base type is `BaseEntity` / `IAuditableEntity` (`Baya.Domain/Common/`). Stamping happens in
`AuditFieldInterceptor` (a `SaveChangesInterceptor` in `Persistence/Interceptors/`) which reads time from
`IDateTimeProvider` and the user from `ICurrentUser` — **not** in the `DbContext`, and **not** in a handler.

Audit fields cannot be backfilled retroactively, so design them in from the start.

### The append-only audit trail

Mark a compliance-sensitive entity with **`IAuditable`** and the interceptor writes an old/new diff row into
`ops.AuditLogs` **in the same transaction as the change**. Annotate any encrypted or PII property with
**`[AuditRedacted]`** so the diff records a marker, never plaintext.

`audit_logs` is **immutable — there is no update or delete path in app code.** Current `IAuditable` entities:
`PlatformConfig`, `PartnerCenter`, `Review`, and the admin-decided money and trust entities `Refund`,
`NurseClawback`, `NursePayout`, `NursePayoutBatch`, `NurseVerification`.

Retention is a two-tier sweep via `IAuditLogger.PurgeExpiredAsync`: financial and verification entity types
keep `audit_retention_financial_days` (default 2555 ≈ 7 years), everyday rows `audit_retention_general_days`
(default 730 ≈ 2 years). Oldest-first, capped, id-keyed delete, idempotent.

---

## 3. Config is rows, read at compute time

Money-critical constants — commission percentage, VAT, deadlines, EVV tolerance, cancellation tiers, job
cadences — live in `ops.PlatformConfigs` and are read via **`IPlatformConfig.GetConfig<T>`** (cached, parsed by
the row's `data_type`). **Never hardcode one.**

And the corollary, which is the part that actually matters:

> **Changing a rate must never retroactively alter an already-computed amount.** A rate is **snapshotted onto
> the booking or invoice at compute time**. Do not live-re-read a rate for an already-priced row.

The DB-backed platform facades — `IPlatformConfig`, `IHolidayCalendar`, `IAnalyticsSink`, `IAuditLogger`,
`INotificationService`, `ISupportAlertService` — live in `Persistence/Services/` and are the contracts other
domains reuse. **Don't re-query those tables directly.** `IAnalyticsSink` is fire-and-forget and never fails
the caller; `INotificationService` is always tenant-scoped to `ICurrentUser`; `support_alerts` are admin-only
and must never appear on a user-facing route.

### Self-committing facades come *after* the atomic commit

`ISupportAlertService.RaiseAsync`, `INotificationDispatcher.DispatchAsync`, `IAuditLogger.WriteAsync` and
`IPlatformConfig.SetConfig` each call `SaveChanges` on the **shared scoped** `DbContext`. Calling one
mid-build flushes your partial tracked changes. **Invoke them only after `unitOfWork.CommitAsync()`.**

In a batch loop that commits per item: load and guard **every** dependency *before* mutating tracked state, or
an early `continue` leaks a dirty entity that a later iteration's commit will flush.

---

## 4. Money

**Money has its own file: [money.md](money.md).** IRR `BIGINT` integers, the append-only balanced ledger, the
three-amount reconciliation, webhook idempotency, and the refund / BNPL / payout / invoice invariants all live
there. Read it before touching anything under `Features/{Payments,Refunds,Invoices,Bnpl,Payouts}`.

The one line to carry in your head meanwhile: **money is an integer number of IRR Rials, and there is no float
path on it anywhere.**

---

## 5. Forward-only status machines

When an entity has a lifecycle `status` with a fixed set of allowed transitions, model the machine as a
**static allowed-edges table** and route **every** write through it. Never assign `status` ad hoc.

- **Statuses are `const string` codes**, persisted as the stable snake_case string — no C# enum, no value
  converter needed.
- **Edges live in a static `CanTransition(from, to)`** built from a
  `Dictionary<string, IReadOnlyCollection<string>>`; a terminal state maps to an empty set.
- **The entity owns the transition.** `status` has a **private setter**, and the only mutators are cohesive
  domain methods (`Accept`/`Reject`/`Cancel…`) calling a private `Transition(target)` that asserts the edge is
  legal — throwing on an illegal edge, because that is a programming error, since the handler pre-checks.
  Side-effect fields are set in the same method.
- **The handler pre-checks and returns a clean 409**:
  `if (!entity.CanTransitionTo(target)) return OperationResult.ConflictResult(...)`. Never throw for the
  expected "already moved / terminal" case.
- **A replayed transition that is already complete is an idempotent no-op**, not a failure.

Machines in the codebase: `BookingRequestTransitions`, the `bookings` machine, `BnplTransitions`,
`PayoutBatchStatus`/`PayoutStatus` transitions, `VerificationStatus`, `ReviewModerationStatus`.

### When the enum is a C# enum

Persist it as its **stable snake_case code** via `HasConversion(e => e.ToCode(), s => Parse(s))` (see
`VerificationCodes`) so the DB and the wire carry `in_review`, not `InReview`. Enum→code mapping in a
projected read happens **in memory after materialization** — `.ToCode()` is not LINQ-translatable. DTOs expose
the code string.

### Guarded cross-aggregate flips

When one write must atomically change a header row's state **and** a derived boolean on a *different*
aggregate (`nurse_verifications.status` → `nurse_profiles.is_verified`): load **both** as tracked entities,
mutate them through a single pure domain helper (`VerificationAggregator.Finalize`), then `CommitAsync`
**once**. Never flip the derived flag from a controller, a partial write, or an out-of-band update, and never
leave an in-between state.

`NurseProfile.is_verified` has **no public setter** for this reason.

### Two SQL Server / SQLite portability rules

- **A deadline column that is compared or sorted uses `DateTime` (UTC `datetime2`), not `DateTimeOffset`** —
  the SQLite test provider cannot translate `DateTimeOffset` comparison or `ORDER BY`. Order lists and sweeps
  by `Id` for the same reason.
- **Sequential numbers come from a counter row, not a DB sequence** (`InvoiceNumberSequences`), locked and
  committed with the row it numbers, so it is portable and gap-free.

---

## 6. Uniqueness patterns

| Need | Pattern |
| --- | --- |
| A nullable column must participate in uniqueness | **The filtered-index pair.** SQL Server treats NULLs as distinct, so `district_id = NULL` needs `UNIQUE(nurse_id, city_id) WHERE district_id IS NULL` **plus** `UNIQUE(nurse_id, city_id, district_id) WHERE district_id IS NOT NULL`, both `AND deleted_at IS NULL` |
| "No two rows may share the same *set* of child rows" | **A deterministic set-hash.** `Baya.Application.Common.OptionSetHash.Compute(pairs)` sorts the `(long, long)` pairs and SHA-256s them into a stable, **order-independent** 64-char hex hash. Persist `NVARCHAR(64)` and back it with a filtered unique index as the race-safe backstop, plus a handler pre-check for a friendly 409. **Do not reuse `IFieldEncryptor.Hash`** — that is for PII equality lookups |
| One-per-parent, forever | An **unconditional** UNIQUE, not filtered on soft-delete — `nurse_payout_booking_links.booking_id` |
| One flagged row per parent | A filtered UNIQUE plus clear-then-set in one transaction — `UNIQUE(customer_id) WHERE is_primary=1 AND deleted_at IS NULL` |
| PII equality lookup | A deterministic hash column, UNIQUE, synced on `SaveChanges` — `users.PhoneHash`. See [identity.md](identity.md) |

A duplicate returns **409** through `OperationResult.ConflictResult` → `BaseController`'s 409 mapping.

---

## 7. Snapshots freeze history

A row that represents a past agreement must not change when its sources are edited later. Frozen at their
moment, and never mutated afterwards:

- `variant_snapshot_json` (via `IVariantSnapshotSerializer`) and the **encrypted** `address_snapshot_json`
- `platform_fee_rate` on the booking
- The resolved cancellation policy `code` + `refund_percentage`
- `iban_snapshot` on a payout (**encrypted**, `[AuditRedacted]`), frozen from the verified primary account
- Deadlines: `nurse_response_deadline_at` = `now + config`, `payment_deadline_at` = `now + config` — both
  stored as **absolute UTC**, so a later config change cannot move them

A later edit to the source variant, address, or policy **never** mutates an existing booking.

---

## 8. The search projection

`search.NurseSearchIndices` is **one flat row per (bookable variant × covered service area)** — a fan-out
denormalization carrying the variant's category/price/unit, the covered `city_id`/`district_id`, the nurse's
gender and rating aggregates, and one visibility gate. It is a **read-only projection**, written only by
`ISearchIndexMaintainer`.

Three invariants:

- **`is_searchable = 1` only when** the nurse `is_verified = 1` **AND** `nurse_verifications.status !=
  'suspended'` **AND** `is_accepting_bookings = 1` **AND** the variant `is_active = 1` — recomputed on **every**
  relevant source write. An unverified, paused, suspended, or deactivated nurse or variant must **never**
  surface.
- **`district_id = NULL` means whole city, both directions.** A city search matches every row in the city; a
  district search matches that district's rows **plus** the NULL-district rows.
- **Incremental maintenance and a full rebuild must converge.** The index is fully re-derivable from source;
  `RebuildAsync` is idempotent.

The maintainer keeps the index consistent **inline, inside the source write's own unit of work** — it shares
the request-scoped `DbContext`, so it only *stages* changes and the handler's single `CommitAsync` flushes
source and projection atomically. It reads the facts a trigger does not change from the DB, and takes the
facts it *does* change as **tracked arguments**, so it never reads a stale pre-commit value. It resurrects a
soft-deleted row on re-upsert, so each (variant × area) has exactly one live row.

`INurseSearch` (read) reads **only `is_searchable = 1`** rows. Callers depend on the interface, so a later
Elasticsearch backend is a config-selected drop-in.

**Coverage is named districts, not GPS radii.** Address lat/lng exists only for the EVV distance check; it is
never used for coverage matching.

---

## 9. Reference-data caching

Public and reference reads are cached through `ICacheService` behind a **generation-token key scheme** —
`GeoCache`, `CatalogCache`, `ReviewCache`. Any admin write to that area **bumps the token**, which invalidates
the whole namespace at once rather than enumerating keys.

The catalog is **EAV/data, not code**: an admin adds a category or a pricing dimension as *rows*, never a
migration. The only closed code enum in the area is `PriceUnits`. A `ServiceOptionGroups.ServiceCategoryId =
NULL` marks a **cross-category** dimension that applies to every category — and "applicable groups" means the
category's own groups **plus** every NULL group, everywhere: public browse, required-group validation, and the
duplicate guard. All required groups must be answered; one value per dimension.

**The bookable unit is the variant, not the nurse.** Keep it a clean projectable source. The engagement total
is `price` + `price_unit` + `session_count` — never `price` alone.

---

## 10. Domain invariants that live here

The rules a change in these areas must not break. Each is enforced in code *and* by a constraint.

**Bookings and EVV**

- A `bookings` row exists **only** when the nurse accepted **and** payment was captured. So a payment is
  initiated against the `accepted_awaiting_payment` *request*, and `payment_transactions.booking_id` is
  **nullable**, bound only when the confirm creates or loads the booking.
- Conversion goes through the shared `BookingFactory` / `BookingConversion` helper — the card confirm and the
  BNPL settle both call it rather than re-implementing the split.
- A booking request carries **no money and no `bookings` row**; accept only opens the payment window.
- **EVV is per session, and a mismatch is advisory.** Check-in computes the distance to the *frozen* booking
  address against `evv_location_tolerance_meters`; a mismatch raises a `location_mismatch` support alert and
  notifies **without blocking**. GPS-denied still checks in, flagged null.
- **`SetDisputeWindow` is the only payout-eligibility trigger.** Completion sets
  `dispute_window_ends_at = completed_at + config(dispute_window_hours, 72)` and each completed session's
  `payout_eligible_at`.
- **Cancellation refunds only un-started sessions**; the applicable policy tier is resolved by
  `(actor, lead-time bucket)` and frozen onto the booking.

**Refunds, clawbacks, invoices, BNPL, payouts** → all in [money.md](money.md).

**Reviews**

- Reviews are for **completed/closed bookings only, owned by the caller, 1:1** (`UNIQUE(booking_id)` is the
  backstop; the handler pre-checks for a clean 409). A cross-tenant booking is a **404**, never a leak.
- **Recompute the nurse aggregate from source on EVERY transition — not a delta.** Read
  `COUNT`/`SUM(rating)` over the nurse's currently-`published` reviews *excluding* the transitioning review,
  fold that review's *new* status in memory, set the guarded aggregates, and stage the reindex — all in the
  **same transaction** as the status change. The exclude-and-fold avoids a stale pre-commit re-query. This is
  the fix for inflated-rating-after-hide drift.
- **`pending_moderation` is never public** — list and aggregate filter to `published` at the query layer.
- `rating <= min_rating_for_support_alert` (config, default 2) raises a support alert **reliably** — after the
  main commit, never silently swallowed.

**Partner centres**

- Merchant-of-record resolution follows `partner_centers` through the single resolver, **not a hardcoded
  platform**: booking → nurse → `partner_center_id`, and the issuer/settlement target is the centre **only**
  when it `is_merchant_of_record`, else `platform`.
- `partner_centers` (the licensing *sponsor*) **≠** `organizations` (the future *employer*, deferred).
  `settlement_iban` is encrypted, `[AuditRedacted]`, and **masked to the last 4 in every read**. The centre's
  `commission_rate` is separate from `platform_fee_rate`.

**Deferred by design — do not create these tables:** `bnpl_settlement_entries`, `organizations`,
`organization_nurses`, `fraud_flags`, `recurring_booking_schedules`.

---

## 11. The recurring-job scheduler

A single in-process scheduler, `Persistence/Services/Scheduling/RecurringJobSchedulerHostedService`, drives
every registered `IRecurringJob` on its own cadence — using **no new infrastructure**, so SQL Server stays the
only external dependency.

| Job | Cadence |
| --- | --- |
| `booking_request_expiry` | 1 min (const) |
| `notification_retention` | 24 h (const) — the predicate is exactly `is_read = 1 AND age > 90d`; **unread is never auto-deleted** |
| `verification_expiry_scan` | `verification_expiry_scan_cadence_hours` |
| `no_show_sweep` | `no_show_scan_cadence_hours` |
| `weekly_payout_generation` | `nurse_payout_interval_days` |
| `MoadianReconciliationJob` | 6 h |
| `audit_log_retention` | `audit_retention_scan_cadence_hours` |

- **Adding a cron = implement `IRecurringJob` + one `AddSingleton<IRecurringJob, …>()`** in
  `AddPersistenceServices`. 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 and state machines are the backstop. Each tick runs under
  `IDistributedLock("scheduler:{name}")`, which is in-process today and is **the >1-instance scale-out gate**:
  swap the seam to Redis to serialize ticks across nodes. A 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 (`InitiatedByAdminId` nullable = "no human initiator"). The irreversible `process`
  step remains an explicit admin action, and `AdminPayoutsController` **neutralizes any request-supplied
  `SystemInitiated` value**.
- **Admin manual triggers are overrides**, running the same idempotent commands.
- **The scheduler is dormant under the `Testing` environment**, so integration tests stay deterministic. Each
  job and command is unit-tested directly.
- A time-sensitive command **self-guards** against a passed deadline via `IDateTimeProvider` rather than
  trusting that a sweep has run; a sweep's re-queried `WHERE status = …` predicate **is** the concurrency guard
  — a row a racing action moved is simply not reloaded.

---

## 12. Development seeders

Both are **Development-only** and idempotent.

- **`DemoWorldSeeder`** — a coherent demo marketplace on top of the reference `HasData` seeds: 3 nurses (2
  verified with variants, Tehran coverage, `approved` verification, credentials and a `matched_national_id`
  bank account; 1 unverified), 2 customers with patients and addresses, **2 phone-OTP admins** (a `super_admin`
  plus a scoped `finance` operator, so the console is reachable through the normal login and capability gating
  is demonstrable), and one cross-category required option group.
- **`DemoLifecycleSeeder`** (+ `.Money.cs` / `.Social.cs` partials) — a full lifecycle world layered on those
  personas so every flow is manually testable: booking requests in every status, 8 bookings across every
  reachable state, the balanced payment ledger behind each, refunds on all three forks, a paid and a draft
  payout batch, moderated reviews with recomputed aggregates, tickets (including an `is_internal` note),
  notifications, patient care records, a merchant-of-record partner centre, and a mid-pipeline verification
  case.

Three rules they establish:

1. **Write through the real entities and commands** — the guarded transition methods, `BookingFactory`,
   `GeneratePayoutBatch`/`ExecutePayoutBatch`, `LedgerPosting`, `OpenTicketCommand`. Business timestamps are
   backdated explicitly. (Application grants `InternalsVisibleTo` to Persistence for this.)
2. **Drive the search projection through `ISearchIndexMaintainer.RebuildAsync`** — never hand-insert index
   rows.
3. **Never guard idempotency on a Persian string.** The `ApplicationDbContext` save hook normalizes Persian
   digits and ZWNJ in every stored string, so a Persian literal **never round-trips equal**. Guard on a phone
   number, a code, or another natural key.
