# Balinyaar Server — Claude Code Guidelines

The backend API of **Balinyaar**, a trust-first home-nursing marketplace in Iran.

- **Coding rules** (the full rule set you must follow) → [CONVENTIONS.md](CONVENTIONS.md). Read it
  before writing any server code.
- Repo-wide context and the frontend → root [CLAUDE.md](../CLAUDE.md).
- Product/domain rules (business logic, schema, payments, escrow, verification) → [`product/`](../product/).
  Read the relevant doc before designing an entity, feature, or endpoint — don't infer business rules
  from code.

---

## Role

You are a **senior .NET software engineer** working on this codebase. That means:

- You write production-quality code, not demo code. Every file you touch should look like it was
  written by someone who has shipped .NET APIs at scale.
- You understand the architecture and work _with_ it, not around it. Clean Architecture boundaries
  are non-negotiable.
- You think before you write. If a task is ambiguous, reason through the design first. If it touches a
  contract other layers depend on, think about downstream impact.
- You prefer simplicity and clarity over cleverness. The next engineer (or agent) should read your
  code without a guide.
- You never leave the codebase in a worse state than you found it.

---

## Stack

- **ASP.NET Core / .NET 10** (`net10.0`), Web API
- **Clean Architecture** (Domain → Application → Infrastructure → API)
- **CQRS** with **Mediator** (`martinothamar/Mediator` — source-generator based, **not** MediatR)
- **EF Core 10** + **SQL Server** (Repository + Unit of Work pattern)
- **ASP.NET Core Identity** with **JWE** (signed + AES-128-encrypted JWT), OTP, and dynamic permission authorization
- **Mapster** for mapping, **FluentValidation** for validation, **Serilog** for structured logging
- **OpenTelemetry** (metrics + tracing; Prometheus-scrape at `/metrics`, opt-in OTLP export) for observability, **NSwag** for OpenAPI, **Asp.Versioning** for versioning
- **xUnit** + **NSubstitute** for tests
- All NuGet versions are centrally pinned in `Directory.Packages.props`

> Note: some prose elsewhere may say "MediatR" — the actual dispatcher is `martinothamar/Mediator`.
> Use `ISender`/`ICommand`/`IQuery` from that package, not MediatR types.

---

## Commands (run from `server/`)

| Task | Command |
| ----------------- | ------- |
| Restore | `dotnet restore Baya.sln` |
| Build | `dotnet build Baya.sln` |
| Run API | `dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj` |
| Test | `dotnet test Baya.sln` |
| Add migration | `dotnet ef migrations add <Name> --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api` |
| 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`.
**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
the connection string must come from user-secrets (see [RUNBOOK](../dev/post-phase/refinement/RUNBOOK.md)).

---

## Quality gates — run before declaring work done

1. `dotnet build Baya.sln` — zero new warnings introduced. Unused `using`s, locals, parameters,
   private fields, or members count as failures — delete them, don't suppress them
   ([CONVENTIONS.md](CONVENTIONS.md) §2 "No unused code").
2. `dotnet test Baya.sln` — all tests pass.
3. Read your own diff as if reviewing a PR: would a senior engineer approve it without comment?
4. If the change alters the architecture, update the **Project map** below in the same change
   (see "Keeping the Project map current").

---

## Project map

This tree is the **canonical description of the server's architecture** — the authoritative list of
projects/assemblies, Clean-Architecture layers, and cross-layer dependencies.

```
src/
├── Core/
│   ├── 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 + 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 (live/ready split + IObjectStorage write-probe → refs Baya.Application), OpenTelemetry (one stack: metrics + tracing, opt-in OTLP)
├── API/
│   ├── Baya.Web.Api         Program.cs, Controllers/V1/ (Ping + Development-only Dev (dev/last_otp OTP helper, 404 outside Development) + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications + public Geo + admin AdminGeo + nurse NurseServiceAreas + customer CustomerAddresses + public Catalog + admin AdminCatalog + nurse NurseVariants + nurse NurseVerification + admin AdminVerificationStepTypes/AdminVerifications + public Nurses (trust badge) + public Search + admin AdminSearch + customer/nurse BookingRequests + admin AdminBookingRequests + customer/nurse/admin Bookings + nurse/admin BookingSessions + admin AdminEvv + admin AdminCancellationPolicies + customer PaymentsController + public WebhooksController + admin AdminRefunds/AdminClawbacks/AdminInvoices + customer Refunds/Invoices + customer CheckoutBnpl + public WebhooksBnpl + admin AdminBnpl + admin AdminPayouts + nurse NursePayouts + customer BookingReviews (submit) + owner/admin Reviews (tags + moderate status) + admin AdminReviews (moderation queue) + public Nurses (reviews + review_tags) + nurse/owner/admin PatientCareRecords), appsettings*.json
│   ├── Baya.WebFramework    BaseController (incl. 401/403 OperationResult mapping), Filters/, Middlewares/, Swagger/, Routing/, ServiceConfiguration/ (rate limiting)
│   └── Plugins/Baya.Web.Plugins.Grpc   gRPC services + .proto models (User only)
├── Shared/Baya.SharedKernel   Extensions + validation base
└── Tests/
    ├── Baya.Tests.Setup                   Shared test infrastructure (SQLite, NSubstitute setup, TestFieldEncryptor)
    ├── Baya.Test.Infrastructure.Identity  xUnit identity tests
    ├── Baya.Test.Foundation               xUnit tests for cross-cutting plumbing + identity handler unit tests
    └── Baya.Test.Api                      WebApplicationFactory integration tests (full HTTP pipeline over in-memory SQLite, env "Testing")
```

**Dependency direction points inward.** Domain has no dependencies. Application depends only on
Domain. Infrastructure and API implement/consume Application contracts. Never make Domain or
Application reference Infrastructure or the API — this is a hard rule.

**Cross-cutting seams.** Application defines mock-able external dependencies as interfaces in
`Contracts/Common/` (`IDateTimeProvider`, `IFieldEncryptor`, `ICacheService`, `IObjectStorage`,
`INotificationDispatcher`, `IGeocoder`, `IShahkarVerifier`, `IIdentityKycProvider`, `ICredentialVerifier`,
`IPaymentCaptureSimulator`, plus `ICurrentUser`). Their in-memory/local mock implementations live in
`Baya.Infrastructure.CrossCutting/Seams/` and are registered by `AddCrossCuttingSeams(configuration)`
(config section `Seams`); `ICurrentUser` is registered in the Identity layer. Swapping a mock for a
real provider is a registration change — handlers depend only on the contract. Audit fields are
stamped by `AuditFieldInterceptor` (Persistence), not in handlers.

**External rails go real — config-selected vendor adapters (refinement-phase-8).** Every vendor rail now has a
**real HTTP adapter** in `Baya.Infrastructure.CrossCutting/Seams/Real/`, **config-selected** by a per-rail
`Seams:*:Provider` selector in `AddCrossCuttingSeams` (default = the mock, so an unconfigured env is unchanged;
a typo falls closed to the mock). Real adapters use `HttpClient` (typed via `IHttpClientFactory`) +
`System.Text.Json` + BCL crypto — **no new NuGet packages**; credentials come from `Seams:*` (user-secrets/env,
never committed). Swapping is a registration change; **no handler is touched**. The adapters:
`KavenegarSmsSender` (`Sms:Provider=kavenegar` — **launch-critical**; when a real provider is selected the
Development OTP-in-logs bridge is **disabled**, so the OTP is never logged), `Finnotech{Shahkar,IdentityKyc,
BankAccountOwnership}` (`{Shahkar,IdentityKyc,BankOwnership}:Provider=finnotech`, shared `Seams:Finnotech`
creds), `NeshanGeocoder` (`Geocoding:Provider=neshan`), `S3ObjectStorage` (`ObjectStorage:Provider=s3` — MinIO/
S3/ArvanCloud via **manual AWS SigV4**, presigned GET = the real b6 signed-URL contract), `ZarinPalPaymentProvider`
+ `HmacWebhookVerifier` (per-provider HMAC over the raw body) + `ProviderSettlementSplitProvider`
(`Payments:Provider=zarinpal`), `SnappPayBnplProvider`/`DigipayBnplProvider` + `ConfiguredBnplProviderResolver`
(`Bnpl:Provider=real`; **`balinyaar` = in-house, resolves to the net-of-fee model, no external API**),
`JibitBankTransferProvider` (`BankTransfer:Provider=jibit` — **async rail**: accepts as `submitted`, the
reconciliation callback `POST webhooks/payouts/{provider}` → `ReconcilePayoutBatchCommand` [HMAC-verified] flips
`submitted → paid/failed`), and `MoadianClient` (`Moadian:Provider=moadian`) with the `MoadianReconciliationJob`
`IRecurringJob` (6 h, walks `pending/submitted → registered`). **6.4:** `IPaymentCaptureSimulator` is out of the
production registration — prod gets the fail-closed `DisabledPaymentCaptureSimulator`; Dev/Testing re-register the
succeeding `MockPaymentCaptureSimulator` (the `bookings/convert` path is a Dev/Testing affordance — prod converts
via the b10 webhook confirm). **5.6:** `ICredentialVerifier`/`ILicenseVerificationService` stay mock —
**manual MoH/INO/eNamad review is the intended MVP** (no public B2B API). `ICurrencyNormalizer` is already
config-driven (the real impl). See the mocks-registry for the per-rail config keys.

**Platform-signal facades (backend-phase-1).** The cross-cutting marketplace tables live in a dedicated
**`ops` schema** (mirroring how Identity uses `usr`): `PlatformConfigs`, `AuditLogs`, `SystemEvents`,
`IranianHolidays`, `Notifications`, `SupportAlerts`. Because they are DB-backed, their Application
contracts — `IPlatformConfig` (typed cached config), `IHolidayCalendar` (bank-closure calendar),
`IAnalyticsSink` (fire-and-forget `system_events`), `IAuditLogger` (explicit append-only writes +
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. 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
like `NursePayout.IbanSnapshot` carry `[AuditRedacted]` so the diff records a marker, never plaintext) in the
same transaction as the change.

**Identity profiles, patients & nurse bank accounts (backend-phase-3).** On top of the b2 auth spine,
the `usr` schema gains four role-attached tables: `NurseProfiles` (1:1 with `Users`; guarded
`is_verified` with **no public setter** — flipped only by b6; read-only aggregates), `CustomerProfiles`
(thin payer extension; encrypted emergency contact), `Patients` (care recipient, tenancy-scoped to its
`customer_id`; `is_active` archive flag; encrypted `initial_medical_notes`) and `NurseBankAccounts`
(encrypted `iban` + `UNIQUE(iban_hash)` deterministic-hash duplicate guard + filtered
`UNIQUE(nurse_id) WHERE is_primary=1`). Features live under `Baya.Application/Features/Identity/{Commands|Queries}/`;
one `IEntityTypeConfiguration<T>` each in `Persistence/Configuration/IdentityConfig/`; per-domain
repositories in `Persistence/Repositories/` exposed on `IUnitOfWork` (reads project to DTOs, incl. the
masked IBAN). The **`IBankAccountOwnershipVerifier`** seam (Application `Contracts/Common`; mock
`MockBankAccountOwnershipVerifier` in CrossCutting, registered in `AddCrossCuttingSeams`) runs the mocked
استعلام شبا IBAN-owner ↔ national-id inquiry that sets `matched_national_id` (the b13 first-payout gate).
Encrypted-PII value converters for the new columns are wired in `ApplicationDbContext.OnModelCreating`
alongside the b2 `User` ones. **FluentValidation activation:** `AddApplicationServices` now registers
every `AbstractValidator<T>` in the Application assembly as `IValidator<T>` so the pre-existing
`ValidateCommandBehavior` (and the `ModelStateValidationAttribute` controller filter) actually run —
route-supplied ids (e.g. `patients/update/{id}`) must therefore **not** be validated in the body command.

**Geography, addresses & nurse service areas (backend-phase-4).** A new **`geo` schema** holds the
`Provinces` 1:N `Cities` 1:N `Districts` reference hierarchy (tables, not code lists — new regions launch
by admin insert; `is_active`/`sort_order` drive ordered, toggleable dropdowns) plus `NurseServiceAreas`
(where a nurse travels). `usr.CustomerAddresses` (identity-domain) holds saved service locations. Seeded
via `HasData` (b1 path): 31 provinces + their capital cities (covers the white-space targets) + Tehran's 22
مناطق. Features under `Baya.Application/Features/{Geography|ServiceAreas|Addresses}/`; configs in
`Persistence/Configuration/{GeographyConfig|IdentityConfig}/`; per-domain repos (`IGeoRepository`,
`INurseServiceAreaRepository`, `ICustomerAddressRepository`) on `IUnitOfWork`. Load-bearing rules:
- **`district_id = NULL` means "entire city"** — a real coverage choice, not missing data. Whole-city
  uniqueness is enforced with a **filtered-index pair** (`UNIQUE(nurse_id, city_id) WHERE district_id IS
  NULL …` + `UNIQUE(nurse_id, city_id, district_id) WHERE district_id IS NOT NULL …`, both `AND deleted_at
  IS NULL`), because SQL Server treats NULLs as distinct. A duplicate area returns **409** (`OperationResult.ConflictResult` → new `IsConflict` → `BaseController` 409 mapping).
- **Coverage is named districts, not GPS radii.** Address lat/lng exists only for the later EVV distance
  check (b9); it is never used for coverage matching.
- **Single primary address** per customer via filtered `UNIQUE(customer_id) WHERE is_primary=1 AND
  deleted_at IS NULL` + clear-then-set in one transaction; the first address is primary by default.
- **Address PII** (`address_line`, `postal_code`, recipient name/phone) is encrypted at rest through
  `IFieldEncryptor` (converters in `ApplicationDbContext`); decrypted only in the owner's own read.
- **`IGeocoder`** (new seam, `Contracts/Common`; mock `MockGeocoder` in CrossCutting, config
  `Seams:Geocoding`) turns a typed address into deterministic `decimal` coordinates with no network call;
  a config switch / `NO_GEO` marker forces the null-coordinate path.
- **Reference reads are cached** through `ICacheService` behind a generation-token key scheme (`GeoCache`);
  any admin geo write bumps the token, invalidating the whole geo cache namespace at once.

**Service catalog & nurse pricing variants (backend-phase-5).** A new **`catalog` schema** holds the two-tier
service model. The **admin skeleton** — `ServiceCategories` → `ServiceOptionGroups` → `ServiceOptionValues` —
is intentionally **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. The **nurse layer** —
`NurseServiceVariants` (the atomic **bookable unit**: FK `nurse_profiles` + category + `Price` **BIGINT IRR**
+ `PriceUnit` code + `SessionCount?` + auto-generated-but-editable `DisplayName`) + `NurseServiceVariantOptions`
(one row per answered dimension, `UNIQUE(variant_id, option_group_id)`) — turns the skeleton into priced
offerings. Features under `Baya.Application/Features/{Catalog|Variants}/`; configs +
seed in `Persistence/Configuration/CatalogConfig/`; per-domain repos (`ICatalogRepository`,
`INurseServiceVariantRepository`) on `IUnitOfWork`. Load-bearing rules:
- **The bookable unit is the variant, not the nurse.** b7 (search) and b8 (booking) operate on a variant;
  keep it a clean projectable source. `price` is IRR `BIGINT` (no floats) and crosses the wire as a digit
  string; the engagement total is `price` + `price_unit` + `session_count`, never `price` alone.
- **Duplicate-listing guard** = a deterministic `OptionSetHash` (see `CONVENTIONS.md`) + a filtered
  `UNIQUE(nurse_id, service_category_id, option_set_hash) WHERE deleted_at IS NULL` backstop, plus a friendly
  pre-check (409) — a multi-row option-set can't be a plain composite unique.
- **Applicable groups = the category's own groups + every cross-category (NULL) group** everywhere (public
  browse, required-group validation, duplicate guard). All required groups must be answered; one value per
  dimension; deactivate, never hard-delete (soft-delete query filters).
- **Public catalog reads are cached** through `ICacheService` behind a `CatalogCache` generation-token scheme;
  any admin catalog write bumps the token.
- **`IVariantSnapshotSerializer`** (Application contract, single real impl in `Application/Common`) emits the
  canonical `variant_snapshot_json` and is **consumed by b8** (which owns the `booking_requests` column);
  this phase ships and unit-tests it but persists nothing. `nurse_search_index` is **b7's** (not built here).

**Search & matching (backend-phase-7).** A new **`search` schema** holds the single denormalized read model
`NurseSearchIndex` (table `NurseSearchIndices`) — **one flat row per (bookable variant × covered service
area)** (fan-out), copying the variant's category/price/unit, the covered `city_id`/`district_id`
(`district_id = NULL` = whole city), the nurse's `nurse_gender` + rating aggregates, and the single
`is_searchable` visibility gate. It is a **read-only projection**, written only by the maintainer that
re-derives it from source. Features under `Baya.Application/Features/Search/{Queries|Commands}/`; config in
`Persistence/Configuration/SearchConfig/`; the maintainer + SQL search in `Persistence/Services/Search/`.
Two seams live in `Application/Contracts/Search/`, registered by `AddPersistenceServices` (config key
`Search:Backend`, default `sql`):
- **`INurseSearch`** (read) — impl `SqlNurseSearch` reads **only `is_searchable = 1`** rows, applies the
  category/city/district/gender/price filters + rating sort + pagination. The real MVP backend; a later
  `ElasticNurseSearch` is a config-selected drop-in and callers depend only on the interface.
- **`ISearchIndexMaintainer`** (write, the "ISearchIndexWriter" shape) — `SearchIndexMaintainer` keeps the
  index consistent **inline, inside the source write's own unit of work** (single `CommitAsync`), invoked
  from the b3/b4/b5/b6 handlers that own each source row: `ReindexVariantAsync` (variant create/edit/toggle),
  `ReindexNurseAsync` (verification flip / suspend / accepting-toggle / rating recompute),
  `FanOutServiceAreaAsync` + `RemoveServiceAreaRowsAsync` (area add/remove), and `RebuildAsync` (idempotent
  full rebuild — the admin `POST admin_search/rebuild_index` job). It shares the request-scoped
  `ApplicationDbContext`, so it only *stages* changes; the handler's commit flushes source + 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. Load-bearing rules:
  - **`is_searchable = 1` only when** nurse `is_verified = 1` AND `nurse_verifications.status != 'suspended'`
    AND `is_accepting_bookings = 1` AND variant `is_active = 1` — recomputed on every relevant source write.
    An unverified/paused/suspended/deactivated nurse or variant must **never** surface.
  - **`district_id = NULL` = 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 (whole-city) rows. Uniqueness
    (`UNIQUE(variant_id, city_id, district_id) WHERE deleted_at IS NULL`) uses the filtered-index pair (the
    `nurse_service_areas` trick) so NULL participates on SQL Server; the maintainer resurrects a soft-deleted
    row on re-upsert so each (variant × area) has exactly one live row.
  - **Incremental maintenance and full rebuild must converge** — the index is fully re-derivable from source.

**Booking requests — pre-payment intent (backend-phase-8).** A new **`booking` schema** holds the single
table `BookingRequests` — the **money-free** first half of the engagement lifecycle (`bookings` + money are
b9/b10). One customer requests one nurse for a patient/variant/address/date; the nurse accepts (opening a
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 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`
  (never routed through `IFieldEncryptor`); the nurse view of a request **masks the full address** (line/postal/
  recipient) to a coarse city/district. The encrypted `booking_care_instructions` are b9's stage 2.
- **Tenancy invariant.** patient + address ∈ the caller's `customer_id`; variant ∈ the requested `nurse_id`.
  Resolved from `ICurrentUser`, never the body; a mismatch is a clean 404.
- **Same-gender match at request time.** `required_caregiver_gender` (`male`/`female`/`any`) is matched against
  the nurse's `User.Gender`; required on create, never silently defaulted.
- **Deadlines frozen from config.** `nurse_response_deadline_at` = `now + nurse_response_deadline_hours` at
  create; `payment_deadline_at` = `now + booking_payment_deadline_minutes` (30) at accept — both stored as
  absolute UTC `datetime2` so a later config change can't move them. Stored as `DateTime` (not `DateTimeOffset`)
  because they are compared/sorted in queries and the SQLite test provider can't translate `DateTimeOffset`.
- **Forward-only status guard** (`BookingRequestTransitions`) — every write is pre-checked; an illegal edge is a
  409, terminal states have no outgoing edge; the expiry sweep's `WHERE status = …` predicate is the concurrency
  guard (a row a racing accept/cancel moved is simply not reloaded). See CONVENTIONS §6.

**Bookings, sessions, EVV & cancellation (backend-phase-9).** The `booking` schema gains the five post-payment
tables — `Bookings`, `BookingSessions`, `BookingCareInstructions`, `VisitVerifications`, `CancellationPolicies`
(entities in `Domain/Entities/Booking/`, configs in `Persistence/Configuration/BookingConfig/`, one migration).
A `bookings` row exists **only** when the nurse accepted **and** payment was captured: `ConvertRequestToBooking`
reads an `accepted_awaiting_payment` request, confirms a capture, and creates the booking 1:1 (`pending_payment →
confirmed`), fanning out N `booking_sessions`. Features under `Baya.Application/Features/Bookings/{Commands|Queries}/`
(namespace **plural** `Bookings` — distinct from b8's singular `Booking`; the entity type `Booking` is aliased where
the two collide); per-domain repos `IBookingRepository` + `ICancellationPolicyRepository` on `IUnitOfWork`;
controllers `BookingsController` / `BookingSessionsController` / `AdminEvvController` / `AdminCancellationPoliciesController`.
Load-bearing rules:
- **Money is IRR `BIGINT`, three amounts reconcile.** `gross_price_irr = balinyaar_commission_irr + nurse_payout_amount`
  (all ≥ 0) is a **DB CHECK** and handler invariant; commission = integer-round(`gross × platform_fee_rate`) with the
  rate **snapshotted** onto the booking; `nurse_payout_amount` is derived, never free-entered. `Σ(visit_payout_amount)
  = nurse_payout_amount` exactly (integer split, remainder on the last session — `BookingAmounts`). The
  `payout_released` boolean was **cut** — paid-ness is derived later (b13). On the wire money is a **digit string**.
- **Snapshots freeze history.** `variant_snapshot_json` (via `IVariantSnapshotSerializer`), the **encrypted**
  `address_snapshot_json`, `platform_fee_rate`, and the resolved cancellation `code` + `refund_percentage` are frozen
  at their moment; later edits to the source variant/address/policy never mutate an existing booking.
- **Two-stage clinical disclosure (stage 2).** `booking_care_instructions` (all fields **encrypted** through
  `IFieldEncryptor`) are readable **only post-confirmation** and **only** by the **assigned nurse + admin** —
  `GetCareInstructionsQuery` enforces it; the fields are never projected into a list or logged.
- **EVV is per session; mismatch is advisory.** `visit_verifications` FK is on `booking_session_id`. Check-in computes
  the distance to the frozen booking address (reusing `IGeocoder` + `GeoDistance` haversine) against
  `evv_location_tolerance_meters`; a mismatch raises a `location_mismatch` `support_alerts` + notifies **without
  blocking**. GPS-denied still checks in (flagged null).
- **`SetDisputeWindow` is the only payout-eligibility trigger.** Booking completion (last check-out, or all sessions
  settled) sets `dispute_window_ends_at = completed_at + config(dispute_window_hours, 72)` and each completed session's
  `payout_eligible_at`; b13 gates payout on those, never on `completed` alone.
- **Cancellation snapshots the policy + refunds only un-started sessions.** The applicable `cancellation_policies` tier
  is resolved by `(actor, lead-time bucket)` and its `code` + `refund_percentage` + computed refundable amount are
  frozen onto the booking; only still-`scheduled` sessions are refundable; **no refund ledger is posted (b11)**.
- **`IPaymentCaptureSimulator`** (Application `Contracts/Common`; mock `MockPaymentCaptureSimulator` in CrossCutting,
  registered in `AddCrossCuttingSeams`, config `Seams:PaymentCapture`) is the **temporary conversion trigger** — b10's
  real card capture replaces it by calling `ConvertRequestToBooking` directly on a `succeeded` transaction. The no-show
  sweep (`DetectNoShowSessions`) is admin/test-triggered; its recurring cron is DEFERRED (like b8's expiry sweep).

**Payments core — ledger, transactions, webhooks & card capture (backend-phase-10).** A new **`payments`
schema** holds the money core: `PaymentGateways` (config per PSP; **encrypted `config_json`**;
selection by `type`+`priority`), `PaymentTransactions` (every attempt; the **two filtered uniques** —
`UNIQUE(gateway_reference_code) WHERE NOT NULL` and `UNIQUE(booking_id) WHERE status='succeeded'` — are the
anti-double-capture backstop), `PaymentWebhookEvents` (the idempotency store; **`UNIQUE(provider_code,
external_event_id)`**), and the **append-only** `LedgerEntries` (double-entry source of truth). Entities in
`Domain/Entities/Payments/` (+ `LedgerPosting` balanced-group builder, `LedgerAccountType`/`PaymentTransactionStatus`/
`WebhookProcessingStatus`/`PaymentGatewayType` code sets); configs in `Persistence/Configuration/PaymentsConfig/`;
one migration (`PaymentsCoreLedger`). Features under `Baya.Application/Features/Payments/{Commands|Queries}/`
(`InitiatePayment`, `HandlePaymentWebhook`, `ConfirmPaymentAndPostLedger`, `GetNursePayableBalance`);
`IPaymentRepository` on `IUnitOfWork`; controllers `PaymentsController` (`POST bookings/{id}/payments`),
`WebhooksController` (public `POST webhooks/payments/{provider}`), `NursePayableBalanceController`
(`GET nurses/{id}/payable_balance`). Load-bearing rules:
- **A `bookings` row exists only on capture (b9).** So a payment is initiated against the
  `accepted_awaiting_payment` **request**; `payment_transactions.booking_id` is **nullable**, bound only when
  the confirm creates/loads the booking. Confirm reuses b9 via the extracted **`BookingFactory`** (shared
  conversion/amount logic) rather than re-implementing it — the mock `IPaymentCaptureSimulator` Convert path
  stays for b9's own tests.
- **Idempotency ordering:** `HandlePaymentWebhook` **upserts the webhook event first** on `(provider,
  external_event_id)` and **no-ops on a duplicate**; on a new success event it **re-verifies server-side**
  (`IPaymentProvider.VerifyAsync`) then dispatches `ConfirmPaymentAndPostLedger`, all under
  `IDistributedLock(booking-request:{id}:payment)`. A unique-violation on confirm is treated as an
  **idempotent no-op success**, not an error.
- **The card-capture group is balanced:** `LedgerPosting.CardCapture` posts DEBIT `escrow_held` gross =
  CREDIT `platform_revenue` commission + `nurse_payable` payout under one `transaction_group_id`
  (Σdebit = Σcredit; throws if the three frozen amounts don't reconcile). `ledger_entries` is **append-only**
  (implements `IEntity` only — no `ITimeModification`, so the audit interceptor never stamps it; no soft-delete).
- **Escrow IS the ledger.** `GetNursePayableBalance` is the **signed sum** over `nurse_payable` legs — never a
  stored column. The lawful split is **تسهیم via `ISettlementSplitProvider`** to registered IBANs (the platform
  never moves money).
- **Four money-path seams** in `Application/Contracts/Payments/` — `IPaymentProvider`,
  `ISettlementSplitProvider`, `IWebhookVerifier`, `IDistributedLock` — with faithful mocks in
  `CrossCutting/Seams/` (`MockPaymentProvider`, `MockSettlementSplitProvider`, `MockWebhookVerifier`,
  `InProcessDistributedLock`), registered by `AddCrossCuttingSeams`. `payment_gateways.config_json` is
  encrypted through the b0 `IFieldEncryptor` (converter wired in `ApplicationDbContext`).

**Refunds, clawbacks & invoices (backend-phase-11).** The `payments` schema gains three tables — `Refunds`,
`NurseClawbacks`, `Invoices` (+ the single-row `InvoiceNumberSequences` counter) — entities in
`Domain/Entities/Refunds/` + `…/Invoices/`, configs in `Persistence/Configuration/{RefundsConfig|InvoicesConfig}/`,
one migration (`RefundsClawbacksInvoices`). Features under `Baya.Application/Features/{Refunds|Invoices}/`;
per-domain repos `IRefundRepository` + `IInvoiceRepository` on `IUnitOfWork`; controllers `AdminRefundsController`
/ `AdminClawbacksController` / `AdminInvoicesController` (admin policy, rate-limited) + customer-facing
`RefundsController` (`refunds/{id}/status`) / `InvoicesController` (`invoices/{booking_id}`). Load-bearing rules:
- **A refund decomposes across both fee legs and reverses the ledger.** `CreateRefundCommand` (the whole
  money-path under `lock(booking:{id}:refund)`) reads the booking's frozen split + b9 cancellation snapshot +
  captured transaction (`IRefundRepository.GetRefundContextAsync`), splits `amount = platform_fee_refunded_irr +
  nurse_payout_refunded_irr` pro-rata at the resolved %, enforces **`Σ refunded ≤ captured`** (handler backstop),
  executes the channel behind its seam, and posts the balanced reversal via **b10's `LedgerPosting`** helper
  (extended with `RefundReversalPrePayout` / `ClawbackReversalPostPayout` / `RefundPayableClearing` /
  `ClawbackWriteOff`). The channel-execution/ledger "internal step" commands from the phase are cohesive private
  steps in the handler (mirroring b10's `ConfirmPaymentAndPostLedger`) so they stay atomic.
- **Pre-payout reversal vs post-payout clawback fork.** `INursePayoutStatus` (Application `Contracts/Payments`;
  DB-backed `NursePayoutStatusService` in `Persistence/Services/Payments`) answers "was the nurse already paid?"
  — pre-payout debits `nurse_payable` (clean reversal); post-payout debits `nurse_clawback_receivable` **and**
  opens a `pending` `nurse_clawbacks` row + raises a `nurse_clawback` support alert, because an Iranian IBAN
  transfer is irreversible. Until b13 ships `nurse_payouts`, "paid?" is derived from the booking's
  `dispute_window_ends_at` close (+ a `refund_assume_nurse_paid` config override); b13 swaps the registration.
  Clawback **recovery/netting is b13** — this phase only opens the receivable + supports admin `write_off`.
- **Channel parity.** `psp_card` and `bnpl_revert` post the **same** reversal legs — only the channel, the
  external reference (`gateway_refund_reference` vs `external_revert_reference`), and the ETA differ (card =
  immediate `succeeded` + clearing posts now; BNPL = `processing` + `expected_customer_refund_eta` ≈ now + config
  business days, clearing deferred to reconciliation). The `refund_payable ↔ escrow_held` clearing posts only
  once the customer cash-back confirms — **reached (refinement-phase-6) by `ConfirmRefundSettlementCommand`**
  (admin `POST admin_refunds/{id}/confirm_settlement` + the BNPL cash-back callback branch), which transitions
  `processing → succeeded`, stamps the settled instant, and posts `LedgerPosting.RefundPayableClearing` in the
  same commit (idempotent under `booking:{id}:refund`); `MarkRefundSettlementFailedCommand` (`.../mark_failed`)
  is the counterpart. **The refund row is now persisted (approved) *before* the external channel call** — the
  crash-window fix (claim-first / execute-second), matching the webhook handler.
- **Invoices: VAT on the commission line only, sequential number.** `IssueInvoiceCommand` computes
  `vat_irr = round(platform_commission_irr × vat_rate)` (config `vat_rate`, default 0.10; `vat_rate = 0` ⇒ 0),
  never on the nurse payout, and draws a gap-free `invoice_number` from the `InvoiceNumberSequences` counter row
  (locked + committed with the invoice, portable across SQL Server/SQLite — no DB sequence). Idempotent per
  booking (`UNIQUE(booking_id)`). `IMoadianClient` (introduced here; `MockMoadianClient` in CrossCutting) submits
  to سامانه مودیان — mock leaves `moadian_status = pending` / no ref (config can force `registered`).
- **Forward-dep columns — FKs added in refinement-phase-6.** `refunds.ticket_id` (→ `messaging.Tickets`),
  `nurse_clawbacks.original_payout_id` / `recovered_in_payout_id` (→ `payouts.NursePayouts`),
  `invoices.partner_center_id` (→ `partner.PartnerCenters`, + index) now carry real FKs (`ON DELETE NO ACTION`;
  all nullable) — b15 unconditionally auto-opens the refund ticket so `refunds.ticket_id` is always non-null, and
  the orphaned `refund_ticket_required` config key was retired (its rule had no consumer left). The
  data-model's `manual_bank` channel is stored/served as the canonical wire code **`manual`**. `IBnplProvider` is
  introduced here as a **thin local stub** so the `bnpl_revert` path runs before b12 merges — **b12 owns the real
  seam definition**.

**BNPL — provider-financed installments (backend-phase-12).** The `payments` schema gains one table —
`BnplTransactions` (entity in `Domain/Entities/Bnpl/`, config in `Persistence/Configuration/BnplConfig/`, one
migration `BnplTransactions`) — **1:1 with its `payment_transaction`** (`UNIQUE(payment_transaction_id)`).
A BNPL order is, in our books, **a card payment that lands net-of-fee**: there is no customer-installment
tracking (the provider owns the schedule + 100% default risk). Features under
`Baya.Application/Features/Bnpl/{Commands|Queries}/` (eligibility/initiate/verify/settle/revert/callback/status);
per-domain repo `IBnplRepository` on `IUnitOfWork`; controllers `CheckoutBnplController` (customer, rate-limited)
/ `WebhooksBnplController` (anonymous, signature-verified, rate-limited) / `AdminBnplController` (admin,
rate-limited). The b10 booking-conversion path was extracted to the shared **`Features/Bookings/BookingConversion`**
helper (used by both the card `ConfirmPaymentAndPostLedger` and the BNPL settle). Load-bearing rules:
- **Forward-only `BnplStatus` state machine** (`eligible → token_issued → verified → settled →
  reverted/cancelled/failed`, `BnplTransitions`), mutated only through the entity's mark-* methods — the
  idempotency spine. A replayed settle/revert that would re-drive a completed transition is an idempotent no-op.
- **Settle posts the net-of-fee group via `LedgerPosting.BnplSettle`** — the card-capture legs **plus** `DEBIT
  bnpl_fee_expense / CREDIT escrow_held` for the provider commission, one balanced `transaction_group_id`, so
  escrow reflects the **net** cash (`settled_amount_irr = order − commission`). Settle confirms the parent
  `payment_transaction` (which triggers the booking conversion) exactly like the card capture.
- **The nurse's payout is invariant to payment method** — `nurse_payable` comes from the booking split
  (`gross − commission`), **never** from `settled_amount_irr`; the BNPL commission is a **platform expense**.
- **`settled_at` is per-transaction and nullable** — never assumed instant; the commission is read from the
  actual settlement, never hardcoded. **Currency is normalized to IRR at the provider boundary only**.
- **Revert reuses the b11 refund path** (`CreateRefundCommand` with `refund_channel='bnpl_revert'`) — money
  flows customer ↔ provider ↔ Balinyaar only; the async ~7–10-business-day customer ETA is surfaced.
- **Two new seams** in `Application/Contracts/Payments/`: **`IBnplProvider`** (the full SnappPay-superset verb
  set, superseding b11's revert-only stub; the b11 refund path still injects it) selected per `provider_code`
  by **`IBnplProviderResolver`**, and **`ICurrencyNormalizer`** (Toman↔IRR at the boundary). Mocks
  (`MockBnplProvider`/`MockBnplProviderResolver`/`MockCurrencyNormalizer`) in `CrossCutting/Seams/`, registered by
  `AddCrossCuttingSeams`. `bnpl_settlement_entries` (tranched settlement) is **DEFERRED — modeled-but-not-built**.

**Weekly nurse payouts (backend-phase-13).** A new **`payouts` schema** holds the money-out engine: three tables
— `NursePayoutBatches` (weekly aggregation, holiday-shifted `period_end`/`processing_date`), `NursePayouts`
(one row per nurse per batch; the `net = gross − clawback` split as a DB CHECK; **encrypted `iban_snapshot`**
frozen from the verified primary account) and `NursePayoutBookingLinks` (**`UNIQUE(booking_id)` unconditional** —
the structural one-payout-per-booking-ever guard). Entities in `Domain/Entities/Payouts/`; configs in
`Persistence/Configuration/PayoutsConfig/`; one migration (`NursePayoutEngine`). Features under
`Baya.Application/Features/Payouts/{Commands|Queries}/` (compute-eligible / generate-batch / process / retry /
mark-failed + admin batch-detail/list + nurse history), with the shared **`PayoutSettlement`** step (payout
ledger post + clawback netting); per-domain repo `IPayoutRepository` on `IUnitOfWork`; controllers
`AdminPayoutsController` (admin, rate-limited) / `NursePayoutsController` (nurse, tenancy-scoped). Load-bearing rules:
- **Payout eligibility ≠ completed.** A booking enters a batch only when `status='completed'` **AND**
  `dispute_window_ends_at < now` **AND** it has no active refund **AND** it isn't already in a link row. There is
  no `payout_released` boolean — paid-ness is derived from a `nurse_payout_booking_links` row + the ledger.
- **One payout per booking, forever.** `nurse_payout_booking_links.booking_id` is an **unconditional** UNIQUE
  (not filtered on soft-delete); the "not already linked" filter is the fast first line, the UNIQUE the backstop.
- **The payout drains `nurse_payable`.** `ExecutePayoutBatch` posts `DEBIT nurse_payable / CREDIT escrow_held` for
  the paid net (b10's `LedgerPosting.NursePayout`); a netted clawback posts `DEBIT nurse_payable / CREDIT
  nurse_clawback_receivable` (`LedgerPosting.ClawbackRecovery`) and marks the `nurse_clawbacks` row `recovered`
  (`recovered_in_payout_id` + `resolved_at`). Netting recovers **whole** pending clawbacks up to earnings (never a
  negative net, never a partial single-clawback recovery). Forward-only `PayoutStatus` machine + the ledger-exists
  guard + a batch idempotency key make a retried process never double-send an irreversible transfer.
- **Holiday-aware.** `period_end`/`processing_date` shift off `is_bank_closed` days via **`IHolidayCalendar`**;
  retry refuses on a bank-closed day. **First-payout gate:** only a `is_primary=1 AND is_verified=1 AND
  matched_national_id=1` account is paid; a nurse without one is skipped with a recorded reason.
- **`IBankTransferProvider`** (new seam, `Contracts/Payments`; mock `MockBankTransferProvider` in `CrossCutting/Seams/`,
  config `Seams:BankTransfer`) is the mocked PAYA/SATNA rail — PAYA vs SATNA chosen by the
  `payout_satna_threshold_irr` config; a config switch forces whole-batch/single-row failures. b13 also swaps the
  `INursePayoutStatus` registration to the authoritative **`NursePayoutLinkStatusService`** (a booking is paid iff
  linked to a `paid` payout), superseding the b11 dispute-window derivation. The weekly **cron trigger is DEFERRED**
  (batches are admin-triggered; cadence in `nurse_payout_interval_days`); the BNPL `settled_at` guard is the
  default-off `require_bnpl_settlement_for_payout` config flag.

**Reviews, ratings & patient care records (backend-phase-14).** A new **`reviews` schema** holds four tables:
`Reviews` (one per completed booking — `UNIQUE(booking_id)`, `CHECK(rating 1–5)`, `moderation_status` code +
guarded moderation fields; `IAuditable` so the interceptor audits every transition), `ReviewTagsMaster` (seeded
tag vocabulary, `UNIQUE(code)`), `ReviewTagLinks` (N:N, `UNIQUE(review_id, review_tag_master_id)`), and
`PatientCareRecords` (nurse-authored, **encrypted, patient-scoped** clinical notes; `(patient_id, recorded_at)`
index). Entities in `Domain/Entities/Reviews/`; configs in `Persistence/Configuration/ReviewsConfig/`; per-domain
repos `IReviewRepository` + `IPatientCareRecordRepository` on `IUnitOfWork`; features under
`Baya.Application/Features/{Reviews|PatientCareRecords}/`; controllers `BookingReviewsController` (submit) /
`ReviewsController` (tags + moderate) / `AdminReviewsController` (queue) / `NursesController` (public reviews +
review_tags) / `PatientCareRecordsController`. Load-bearing rules:
- **Reviews are for completed/closed bookings only, owned by the caller, 1:1.** The `UNIQUE(booking_id)` is the
  backstop; the handler pre-checks and returns a clean `OperationResult` (409 on a duplicate, not a raw DB error).
  A cross-tenant booking is a 404, never a leak.
- **Recompute the nurse aggregate from source on EVERY transition — not a delta.** `RecomputeNurseRating`
  (`Features/Reviews/`) reads `COUNT`/`SUM(rating)` over the nurse's currently-`published` reviews **excluding the
  transitioning review**, folds in that review's *new* status in memory, sets `nurse_profiles.average_rating`/
  `total_reviews` (guarded `NurseProfile.SetReviewAggregates`), and stages the b7 `ReindexNurseAsync` refresh — 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.
- **Publish gate — `pending_moderation` is never public.** `ListReviewsForNurse` and the aggregate count
  `published` only, filtered at the query layer. The public aggregate read is cached (`ReviewCache`) and evicted on
  every transition.
- **Low rating raises a `support_alert` reliably.** `rating <= min_rating_for_support_alert` (config, default 2)
  → `RaiseSupportAlert(low_rating)` in the same flow (after the main commit, never silently swallowed).
- **`patient_care_records` are patient-scoped (not booking-scoped) + encrypted + strict access.** `body_encrypted`
  holds `IFieldEncryptor` ciphertext with **no EF value converter** — the handler encrypts on write and decrypts
  only after the access check passes (owning customer / nurse with a confirmed booking / admin; anyone else 403).
- **`IReviewModerationService`** (new seam, `Contracts/Reviews`; mock `MockReviewModerationService` in CrossCutting,
  config `Seams:ReviewModeration`) is the AI pre-screen; clean text stays pending by default (publish gate),
  banned-word → auto-hidden. Decision authority stays with `ModerateReviewCommand` (human override).

**Messaging, partner centers & admin backoffice (backend-phase-15).** The final backend phase adds two schemas
and consolidates the admin surface. A new **`messaging` schema** holds `Tickets` / `TicketParticipants` /
`TicketMessages` (entities in `Domain/Entities/Messaging/` + `TicketStatus`/`TicketCategory`/`TicketParticipantRole`
codes) — the only sanctioned post-booking channel. A new **`partner` schema** holds `PartnerCenters` (entity in
`Domain/Entities/PartnerCenters/`, `IAuditable`; the licensed sponsor / merchant-of-record). Configs in
`Persistence/Configuration/{MessagingConfig|PartnerCentersConfig}/`; per-domain repos `ITicketRepository` +
`IPartnerCenterRepository` on `IUnitOfWork`; features under `Baya.Application/Features/{Messaging|PartnerCenters}/`;
controllers `TicketsController` / `AdminTicketsController` / `AdminPartnerCentersController` / `CentersController`
/ `InternalCentersController`; one migration (`MessagingAndPartnerCenters`, which also adds the
`nurse_profiles.partner_center_id` FK in place). Load-bearing rules:
- **`is_internal` is a HARD visibility boundary enforced at the QUERY layer.** `GetTicketThreadQuery` takes an
  `AsAdmin` flag; the user view (`false`) strips every `is_internal` message in the repository projection
  (`GetMessagesAsync(includeInternal:false)`), the admin view (`true`, staff only) returns them. A non-staff
  caller can never *set* `is_internal` on `PostMessage` nor *read* one. Never enforced only in the UI.
- **No direct nurse↔customer channel.** All post-booking comms are ticket-mediated + admin-readable; participation
  (via `TicketParticipant`, `UNIQUE(ticket_id, user_id)`, soft-remove via `removed_at`) plus staff is the auth
  boundary. `reference_code` is minted once (collision-checked, UNIQUE) and stable. Both `booking_id`/`refund_id`
  links are nullable — handle a ticket with neither. A coordination ticket is auto-created (idempotent, one per
  booking) on confirmation via `AutoCreateCoordinationTicketCommand`, dispatched from the card confirm + BNPL
  settle handlers. `LogEmergencyTicket` records the aftermath of an out-of-platform emergency call (+ optional
  `support_alert`) — it exposes no phone number.
- **Merchant-of-record resolution follows `partner_centers`, not a hardcoded platform.**
  `PartnerCenterRepository.ResolveCenterForBookingAsync` (surfaced by `GetCenterForBookingQuery`, endpoint
  `GET /internal/bookings/{id}/center`) resolves booking → nurse → `partner_center_id`; the issuer/settlement
  target is `partner_center` **only** when that center `is_merchant_of_record`, else `platform`. This is the
  single resolver **b11's `IssueInvoice` now calls** to set `invoices.issuing_entity_type` + `partner_center_id`.
- **`partner_centers` ≠ `organizations`.** The launch licensing *sponsor* (`partner_centers`) is distinct from
  the future *employer* (`organizations`, DEFERRED). `settlement_iban` is encrypted at rest (converter in
  `ApplicationDbContext`, `[AuditRedacted]`) and **masked** (last 4) in every read; `commission_rate` (the
  center's cut) is separate from `platform_fee_rate`. The four DEFERRED tables (`organizations`,
  `organization_nurses`, `fraud_flags`, `recurring_booking_schedules`) are **not** created.
- **Refund↔ticket link wired.** `CreateRefundCommand` (b11) now auto-opens a `category=refund` ticket via
  `OpenTicketCommand` when the caller supplies none, so `refunds.ticket_id` is always non-null.
- **Backoffice consolidation surfaces, doesn't rebuild.** The support-alert worklist (`ISupportAlertService`
  List/Assign/Resolve — `SupportAlertsController`) and the audit viewer (`GetAuditTrail` — `AuditController`)
  already existed since b1 and are reused as-is; verification/refund/payout/moderation surfaces are their own
  phases'. New seam **`ILicenseVerificationService`** (`Contracts/Common`; mock `MockLicenseVerificationService`
  in CrossCutting, config `Seams:LicenseVerification`, `AutoApprove` toggle) is the eNamad / MoH permit check —
  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`) · `MoadianReconciliationJob` (6 h, refinement-phase-8) ·
`audit_log_retention` (`audit_retention_scan_cadence_hours`, refinement-phase-9). 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.
- **Audit-log retention (refinement-phase-9 §9.4)** is an `IRecurringJob` (`AuditLogRetentionJob`) over the
  append-only `ops.AuditLogs`: a **two-tier** sweep via `IAuditLogger.PurgeExpiredAsync` — financial/verification
  entity types (`Refund`/`NurseClawback`/`NursePayout`/`NursePayoutBatch`/`NurseVerification`/`PlatformConfig`/
  `PartnerCenter`) keep `audit_retention_financial_days` (default 2555 ≈ 7 yr); everyday rows
  `audit_retention_general_days` (default 730 ≈ 2 yr). Oldest-first, capped, id-keyed delete; idempotent.

**Observability (refinement-phase-9).** One **OpenTelemetry** stack (`Baya.Infrastructure.Monitoring`,
`SetupOpenTelemetry`): metrics (runtime + ASP.NET Core + the `mediator_meter` histogram) scraped at `/metrics` via
the OTel Prometheus exporter, and **tracing** (ASP.NET Core + EF Core) sharing `service.name = Baya.Web.Api`. The
duplicate prometheus-net stack was removed. **OTLP export (traces + metrics) is opt-in** — wired only when
`OpenTelemetry:Otlp:Endpoint` is set, so an MVP with Prometheus alone runs unchanged. `ApiResult.RequestId` is the
W3C trace id (`Activity.Current.TraceId`, `Activity.DefaultIdFormat = W3C`), so a support ticket maps 1:1 to a
trace. **Health checks split** (`ConfigureHealthChecks`/`UseHealthChecks`): `/healthz/live` (process, dependency-
free), `/healthz/ready` (app DB + `logDb` [deployed only] + an `IObjectStorage` write-probe), `/HealthCheck`
(aggregate, kept for compat). **Logs:** deployed envs write **Information+** to `Baya_Logs` (framework categories
held at Warning); **no PII/secrets** — the mock SMS sender never logs the OTP code; clinical text/IBANs are
encrypted/masked. The dead Elasticsearch sink + package were removed (SQL sink is the deployed default; set the
OTLP collector to ship logs off-box). **gRPC reflection is Development-only** (`GrpcPluginStartup` gates
`AddGrpcReflection`/`MapGrpcReflectionService` on `IsDevelopment`); the plugin shares the mixed-protocol Kestrel
listener. **`TicketMessage.Body` is encrypted at rest** through `IFieldEncryptor` (converter in
`ApplicationDbContext`; column widened to `nvarchar(max)`; the 4000-char cap stays a boundary-validation rule) —
ticket bodies are the refund/dispute paper trail (phone numbers, addresses, clinical detail).

**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
**same** change. This is the server-specific form of the root "Keep docs honest" rule: the map is
only canonical if it stays accurate.

---

## Startup wiring

Service registration is composed from per-layer extension methods (each project's `ServiceConfiguration/`):

```
builder.ValidateRequiredSecrets()  // refinement-phase-5: fail fast on missing/placeholder DB + crypto secrets
ConfigureHealthChecks() · SetupOpenTelemetry()   // refinement-phase-9: live/ready health split + object-storage probe; one OTel stack (metrics + tracing, opt-in OTLP)
AddApplicationServices()           // Mediator + pipeline behaviors (Logging → Metrics → Validate)
RegisterIdentityServices(…, requireHttpsMetadata)  // Identity, JWT/JWE (RequireHttpsMetadata on outside Dev/Testing), ICurrentUser
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)
AddForwardedHeadersConfiguration(config)  // refinement-phase-5: trust ForwardedHeaders:KnownProxies/KnownNetworks so the rate limiter sees the real client IP behind a proxy
AddRateLimitingPolicies()          // built-in rate limiter: per-resolved-IP global + named (otp/auth/sensitive/webhook)
AddSwagger("v1", "v1.1") · RegisterValidatorsAsServices() · AddMapster()
ConfigureGrpcPluginServices(builder.Environment)  // refinement-phase-9: gRPC reflection registered only in Development
// Development-only: AddDevelopmentOtpCapture() (refinement-phase-0) decorates ISmsSender to capture each
// OTP in-memory for the GET /api/v1/dev/last_otp/{phone} helper — never wired outside Development.
```

Pipeline order: **forwarded headers** → exception handler → Swagger → routing → **CORS → rate limiter →
authentication → authorization** → controllers → metrics → health checks → gRPC. `UseForwardedHeaders()`
(refinement-phase-5) is **first** so the resolved client IP (`X-Forwarded-For` from a trusted proxy) is in
place before the rate limiter partitions on it. `UseCors(...)` (refinement-phase-0) sits **after
`UseRouting()` and before `UseRateLimiter()`** so a pre-flight `OPTIONS` is answered before the limiter/auth
run; `UseRateLimiter()` is placed **before** `UseAuthentication()` so over-limit auth/OTP attempts are
rejected (`429`) before hitting the auth stack.

When adding new infrastructure, expose it as an extension method and call it from `Program.cs` —
never inline registrations there directly.

---

## CQRS — how a feature is shaped

Features live under `Baya.Application/Features/<Area>/{Commands|Queries}/<Name>/`:

```
Features/<Area>/
├── Commands/<VerbNoun>Command/
│   ├── <VerbNoun>Command.cs          record : IRequest<OperationResult<T>>
│   ├── <VerbNoun>Command.Handler.cs  internal sealed class : IRequestHandler<...>
│   └── <VerbNoun>Command.Validator.cs
└── Queries/<VerbNoun>Query/
    ├── <VerbNoun>Query.cs
    ├── <VerbNoun>Query.Handler.cs
    └── <VerbNoun>Query.Result.cs
```

A minimal live example shipped in backend-phase-0: `Features/System/Queries/Ping/` (query + handler +
result), surfaced by `Controllers/V1/PingController`.

Handlers are `internal sealed`. Requests are `record` types. Validators use FluentValidation and are
picked up automatically by the `ValidateCommandBehavior` pipeline behavior. Never throw for expected
failures — use `OperationResult` factory methods.

**To add a feature:** create the folder, implement request + handler + (optional) validator, add any
new contracts to `Application/Contracts/` and implement them in Infrastructure, then wire a controller
action to `sender.Send(...)`. Full conventions are in [CONVENTIONS.md](CONVENTIONS.md) §5.

---

## Persistence

- Access the DB through `IUnitOfWork` — not `ApplicationDbContext` directly outside Infrastructure.
- Commit once per command via `unitOfWork.CommitAsync()`.
- Use `AsNoTracking()` on all read-only queries.
- Always project to a DTO in queries — never return entity objects from handlers.
- Add entity config in `Persistence/Configuration/<Area>Config/` implementing `IEntityTypeConfiguration<T>`.
- Soft delete is enforced via a global query filter per entity (see [CONVENTIONS.md](CONVENTIONS.md) §6).
- **Development demo seeder (refinement-phase-1).** `Persistence/Services/Seeding/DemoWorldSeeder.cs`
  (+ `DemoWorldDefinitions.cs`) idempotently populates a coherent demo marketplace on top of the reference
  `HasData` seeds — 3 nurses (2 verified w/ variants + Tehran coverage + `approved` verification + credentials
  + a `matched_national_id` bank account, 1 unverified), 2 customers (patients + addresses), **2 phone-OTP
  admins** (refinement-phase-2: a `super_admin` + a scoped `finance` operator, so the `/admin` console is
  reachable through the normal phone-OTP login and `useAdminCapabilities` gating is demonstrable — admin
  sub-roles are server-granted, never self-selectable), and one cross-category required demo option group
  (شیفت / *Shift Type*). It writes through the real entities and
  drives the search projection through `ISearchIndexMaintainer.RebuildAsync` (never hand-inserts index rows),
  guarding each persona on its phone number so re-runs are a no-op. Invoked via `SeedDemoWorldAsync()`
  **only under `IsDevelopment()`** — never in Production/Staging. The demo world (phones, which nurse is
  verified) is in `dev/post-phase/refinement/RUNBOOK.md`.

---

## Identity & auth

- JWT/JWE issued by `IJwtService` (`Baya.Infrastructure.Identity/Jwt/JwtService.cs`).
  `GenerateAccessTokenAsync` mints an access token only (the REST flow); the legacy `GenerateAsync`
  additionally writes a `UserRefreshTokens` row and still feeds the gRPC path.
- **Phone-OTP is the public login** (backend-phase-2): `Controllers/V1/AuthController`
  (`request_otp`/`verify_otp`/`refresh`/`logout`) + `MeController` (`/me`, `select_role`) drive the
  `Features/Identity/` slices. OTP delivery goes through the **`ISmsSender`** seam (mock
  `LoggingSmsSender` in CrossCutting logs the code; registered in `AddCrossCuttingSeams`).
- **Sessions & rotation:** every login creates a revocable `usr.UserSessions` row storing only the
  refresh token's `IFieldEncryptor.Hash`. Refresh rotates (old session revoked, new pair issued);
  a replayed/revoked token revokes **all** the user's sessions and returns 401. Logout revokes the
  session **and** rotates the security stamp so outstanding access tokens fail the JWE
  `OnTokenValidated` stamp check.
- **Encrypted PII:** `users.PhoneNumber/Email/NationalId` are encrypted at rest via an EF value
  converter over `IFieldEncryptor` (wired in `ApplicationDbContext`; the encryptor must stay a
  process-wide singleton because EF caches the model). Equality lookups go through the deterministic
  `PhoneHash` column (UNIQUE, synced on SaveChanges — which also resets `ShahkarVerifiedAt` when the
  phone actually changes). Never query `PhoneNumber == x`.
- **Roles:** full vocabulary in `Domain/Entities/User/RoleNames`; `SeedDataBase` always seeds the roles,
  and seeds a **bootstrap admin only when `Seed:AdminUsername`/`Seed:AdminPassword` are configured**
  (refinement-phase-5 — no more committed `admin`/`qw123321`; break-glass only, day-to-day admins come from
  the phone-OTP demo seeds or are provisioned out-of-band). `customer`/`nurse` are self-selectable via
  `POST me/select_role` (audited `granted_by`/`granted_at`, idempotent, both can be held); admin sub-roles are
  internal-only and return 403 there. `user_roles.revoked_at` has a global query filter, so revoked grants
  disappear from every role read automatically. Auth knobs (`auth_otp_resend_seconds`, `auth_otp_max_attempts`,
  `auth_session_ttl_days`) are `platform_configs` rows read via `IPlatformConfig`.
- Dynamic permission system: `DynamicPermissionHandler` reads `[controller]` + `[action]` route
  values and checks role claims. Always use `[controller]`/`[action]` tokens so the keys stay
  consistent (see CONVENTIONS.md §1 Routing).
- Settings bound from `appsettings.json` → `IdentitySettings`. **JWE keys are never committed**: the
  committed values are `SET_VIA_USER_SECRETS_OR_ENV` placeholders (real ones via user-secrets/env; Development
  uses dev-only keys in `appsettings.Development.json`). `RequireHttpsMetadata` is **on outside Dev/Testing**
  (passed into `RegisterIdentityServices`), the access-token lifetime is `ExpirationMinutes: 60`, and
  `Issuer`/`Audience` are real (`Balinyaar`/`BalinyaarClient`) — refinement-phase-5.
- Auth and OTP endpoints must be rate-limited (CONVENTIONS.md §11) — `request_otp`/`verify_otp` use
  the `otp` policy, `refresh` the `auth` policy; plus a per-phone resend window via `ICacheService`. The two
  PSP/BNPL webhooks share the single deliberate **`webhook`** policy (bursty-tolerant, partitioned per-provider);
  behind a reverse proxy the limiter partitions on the forwarded client IP (see Startup wiring).

---

## Conventions — quick reference

Full rules in [CONVENTIONS.md](CONVENTIONS.md). The essentials:

- All URL segments are `snake_case` via `SnakeCaseParameterTransformer` — use `[controller]`/`[action]` tokens.
- Controllers are `sealed`, inherit `BaseController`, inject `ISender`, return `base.OperationResult(result)`.
  Never call `Ok()` / `BadRequest()` / `NotFound()` directly.
- Handlers are `internal sealed`; never throw for expected failures — return `OperationResult`.
- `record` for requests/DTOs, `class` for entities (no public setters), `sealed class` for handlers/services.
- `async`/`await` all the way; pass `CancellationToken` through every async call; never `.Result`/`.Wait()`/`async void`.
- Mapster for mapping; FluentValidation for validation (validate at the boundary).
- Package versions live **only** in `Directory.Packages.props` — never `Version=` in a `.csproj`.
- No unused code (usings, locals, parameters, private fields/members) and no *what*-comments — explain *why*, prefer self-documenting names (§2).
- Architecture changes (a project/layer/major folder or a cross-layer dependency) must update the **Project map** in the same change.
- The `Baya.*` namespace is project naming — do not rename without explicit instruction.

---

## Known build warnings (pre-existing — do not fix unless tasked)

| Warning | Project | Note |
| ------- | ------- | ---- |
| `NU1510` on `Microsoft.Extensions.Logging.Debug` | `Baya.Web.Api` | Redundant transitive reference, harmless |
| `NETSDK1057` (preview SDK) | all | .NET 10 SDK is preview on this machine |
