cleanup phase 1

This commit is contained in:
hamid
2026-07-30 02:26:52 +03:30
parent d3ec723119
commit c889c46110
36 changed files with 4251 additions and 2552 deletions
-1
View File
@@ -12,7 +12,6 @@ Dockerfile
.dockerignore
docker-compose.yml
CLAUDE.md
CONVENTIONS.md
AGENTS.md
README.md
LICENSE.md
+6 -5
View File
@@ -1,12 +1,13 @@
# AGENTS.md — Balinyaar Server
The canonical agent guide for the backend is **[CLAUDE.md](CLAUDE.md)** (same folder): role, stack,
commands, architecture, project map, and a conventions quick-reference.
The **full coding rule set** is in **[CONVENTIONS.md](CONVENTIONS.md)** — read it before writing any
server code.
The canonical agent guide for the backend is **[CLAUDE.md](CLAUDE.md)** (same folder): stack,
commands, the quality gates, the project map, and the hard rules every change must follow.
- Reference rules, read on demand per area → [../docs/rules/server/](../docs/rules/server/)
(structure · cqrs · persistence · **money** · identity · conventions). `conventions.md` is the
successor to the old `CONVENTIONS.md`, which was distilled into it.
- Repo-wide context → [../CLAUDE.md](../CLAUDE.md)
- Business rules (schema, payments, escrow, verification) → [../product/](../product/index.md)
- Human setup/run instructions → [README.md](README.md)
`CLAUDE.md` is the single source of truth; this file is just a pointer so the convention is
+137 -725
View File
@@ -1,29 +1,14 @@
# Balinyaar Server — Claude Code Guidelines
# Balinyaar Server
The backend API of **Balinyaar**, a trust-first home-nursing marketplace in Iran.
The backend API of **Balinyaar**, a trust-first home-nursing marketplace in Iran. It owns the booking
lifecycle, an escrow-style double-entry ledger, weekly nurse payouts, the nurse verification pipeline, and
every piece of encrypted PII and clinical data on the platform.
- **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.
> Last verified: 2026-07-30 against commit `d3ec723`.
---
## 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.
- Repo-wide context and the frontend → root [CLAUDE.md](../CLAUDE.md)
- Business rules (schema, payments, escrow, verification) → [`product/`](../product/index.md). **Read the
relevant doc before designing an entity, feature, or endpoint** — don't infer a business rule from code.
---
@@ -31,742 +16,169 @@ You are a **senior .NET software engineer** working on this codebase. That means
- **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
- **CQRS** with **Mediator** (`martinothamar/Mediator` — source-generator based, **not** MediatR). Use
`ISender`/`ICommand`/`IQuery`; any prose that says "MediatR" is wrong.
- **EF Core 10** + **SQL Server** (Repository + Unit of Work)
- **ASP.NET Core Identity** with **JWE** (signed + AES-128-encrypted JWT), phone-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
- **OpenTelemetry** (metrics at `/metrics`, tracing, opt-in OTLP), **NSwag** for OpenAPI, **Asp.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` |
| --- | --- |
| Restore / build | `dotnet restore Baya.sln` · `dotnet build Baya.sln` |
| Run API | `dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj` |
| Test | `dotnet test Baya.sln` |
| Apply migrations (deploy-time one-shot) | `dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj -- migrate` |
| 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). **`dotnet user-secrets` is no longer used** — the `<UserSecretsId>` was removed from
`Baya.Web.Api.csproj`, so that store is not read at all. Every value, connection strings and dev-only crypto keys
alike, lives in `appsettings.Development.json`; the deployment's two container-specific overrides live in the root
`docker-compose.yml` (see [DEPLOY.md](../DEPLOY.md)).
**Default URL: `http://localhost:5002`** (per `launchSettings.json`), Swagger at `/swagger`. A reachable SQL
Server is required to start.
## Quality gates
1. `dotnet build Baya.sln`**zero new warnings.** Unused usings, locals, parameters, private fields or
members count as failures. Delete them; don't suppress them.
2. `dotnet test Baya.sln` — all tests pass, including the ones your change adds.
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.
Two pre-existing warnings are expected and must **not** be "fixed" unless a task says so: `NU1510` on
`Microsoft.Extensions.Logging.Debug` (`Baya.Web.Api`), and `NETSDK1057` (the .NET 10 SDK is preview here).
---
## Quality gates — run before declaring work done
## Hard rules
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").
1. **Dependencies point inward.** Domain references nothing; Application references only Domain.
**Never reference Infrastructure or the API from Domain or Application.**
2. **Never throw for an expected failure.** Return `OperationResult.SuccessResult` / `FailureResult` /
`NotFoundResult` / `ConflictResult`. Let genuinely unexpected exceptions reach the global
`ExceptionHandler`; never swallow one.
3. **Controllers are `sealed`, inherit `BaseController`, inject `ISender`, and return
`base.OperationResult(result)`.** Never call `Ok()`/`BadRequest()`/`NotFound()` directly. One `Send`, one
result, no business logic.
4. **Route segments come from `[controller]`/`[action]` tokens** (the snake_case transformer). Never hardcode a
route string — it also breaks the dynamic-permission key. If a method name doesn't read as a URL, rename it.
5. **Handlers are `internal sealed`; requests are `record`s; one handler per request.** Entities are `class`
with **no public setters**.
6. **Reads use `AsNoTracking()` and project with `.Select()` to a DTO.** Never hydrate entities to map them,
never return an entity from a handler. **Every unbounded list is paginated.**
7. **Access the DB through `IUnitOfWork`; commit once per command.** `ApplicationDbContext` is referenced
directly only inside Infrastructure.
8. **Every soft-deletable entity declares a global query filter** in its `IEntityTypeConfiguration<T>`. A
missing filter is a silent data leak. Never `Where(x => !x.IsDeleted)` per query.
9. **Money is IRR `BIGINT`, integer-only — no float path anywhere.** `gross = commission + payout` always.
Toman converts only inside a provider adapter at its boundary. `ledger_entries` is append-only and every
posting group balances.
10. **Config is rows, read at compute time** via `IPlatformConfig` — never hardcoded. And **a rate change is
never retroactive**: snapshot the rate onto the row at compute time.
11. **Money-path writes are idempotent**: upsert the webhook event first and no-op on a duplicate, claim before
executing, and treat a unique-violation on confirm as an idempotent success. The DB constraint is the
authoritative backstop, not the handler's `if`.
12. **Money movement stays human-approved.** A scheduled job may *generate* a draft payout batch; the
irreversible `process` step is always an explicit admin action.
13. **Route every status write through the forward-only transition table.** `status` has a private setter and
only cohesive domain methods mutate it; the handler pre-checks and returns a clean **409**.
14. **A guarded cross-aggregate flip is one transaction**: load both tracked, mutate through one pure domain
helper, `CommitAsync` once. Never flip a derived flag from a controller or out of band.
15. **Self-committing facades run *after* `CommitAsync()`**`RaiseAsync`, `DispatchAsync`, `WriteAsync` and
`SetConfig` each call `SaveChanges` on the shared scoped context and will flush your partial changes.
16. **`Seams:FieldEncryption:Key` and `:HashKey` are load-bearing — never change them.** They decrypt all
existing PII and derive the phone-lookup hash.
17. **PII goes through `IFieldEncryptor`; equality lookups go through the deterministic hash column.**
**Never query `PhoneNumber == x`.** The encryptor must stay a process-wide singleton.
18. **Two-stage clinical disclosure.** A booking request exposes only limited unencrypted `customer_notes` and
masks the address to a coarse city/district; encrypted care instructions are readable only
post-confirmation, only by the assigned nurse and admin, and never projected into a list or logged.
19. **`is_internal` is a hard visibility boundary enforced at the QUERY layer**, never in the UI. A non-staff
caller can never set or read one.
20. **Tenancy is resolved from `ICurrentUser`, never from the request body, and a mismatch is a clean 404**
never a 403, which confirms the row exists.
21. **Auth, OTP and money endpoints are rate-limited** (`otp` / `auth` / `sensitive` / `webhook` policies).
22. **Never hardcode a secret in C#**, and never put a real value in the base `appsettings.json` — it stays at
its `StartupSecretsGuard`-rejected placeholder. **`dotnet user-secrets` is not used and is not read** (the
`<UserSecretsId>` was removed), so any instruction to use it is stale.
23. **Never concatenate raw SQL.** EF parameterizes; if you must, `FromSqlInterpolated`, never `FromSqlRaw`
with user data.
24. **`async`/`await` all the way, `CancellationToken` threaded through every call.** Never `.Result`,
`.Wait()`, or `async void`. Don't add `.ConfigureAwait(false)` in this app.
25. **Never log PII or secrets.** Structured templates only; use `userId`, not an email.
26. **Package versions live only in `Directory.Packages.props`** — never `Version=` in a `.csproj`.
27. **Register infrastructure through a `ServiceConfiguration/` extension method** called from `Program.cs`.
No inline registration; `Program.cs` stays an orchestrator.
28. **A mock lives behind a DI-registered seam, selected by config, defaulting to the mock.** Never an
`if (mock)` in a handler. Record every mock in `docs/status/`.
29. **No dead code** (the gate is zero new warnings) and **comment the *why*, never the *what*.**
30. **When you change the architecture, update the Project map below in the same change.**
---
## 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.
The canonical list of projects, layers, and cross-layer dependencies — **14 `.csproj` projects, 55 V1
controllers.** Expanded, with the seam catalogue and startup wiring, in
[`docs/rules/server/structure.md`](../docs/rules/server/structure.md).
```
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)
│ ├── Baya.Domain Entities per area (User, Identity, Geography, Catalog, Verification,
│ Search, Booking, Payments, Refunds, Invoices, Bnpl, Payouts, Reviews,
│ │ Messaging, PartnerCenters, + Configuration/Audit/Analytics/Holidays/
│ │ Notifications/SupportAlerts) · Common/ (BaseEntity, IAuditable,
│ │ [AuditRedacted]) · status-code sets + transition tables
│ └── Baya.Application Features/<Area>/{Commands|Queries}/ · Contracts/ (the seams:
│ Common, Payments, Search, Reviews, Persistence) · Models/ ·
│ pipeline behaviors (Logging → Metrics → Validate)
├── 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)
│ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII converters & phone-hash sync) ·
│ ValueConversion/ · Configuration/<Area>Config/ · Repositories/ ·
│ Migrations/ · Interceptors/ (AuditFieldInterceptor) ·
│ Services/ (DB-backed platform facades, Scheduling/, Search/, Seeding/)
│ ├── Baya.Infrastructure.Identity Jwt/ · Identity/ (Managers, Stores, PermissionManager, Seed, CurrentUser/)
│ ├── Baya.Infrastructure.CrossCutting Serilog wiring · Seams/ (mocks) · Seams/Real/ (vendor adapters) ·
│ │ AddCrossCuttingSeams
│ └── Baya.Infrastructure.Monitoring HealthChecks (live/ready split) · OpenTelemetry
├── 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
│ ├── Baya.Web.Api Program.cs · Controllers/V1/ (55) · appsettings*.json
│ ├── Baya.WebFramework BaseController · Filters/ · Middlewares/ · Swagger/ · Routing/ ·
│ ServiceConfiguration/ (rate limiting)
│ └── Plugins/Baya.Web.Plugins.Grpc gRPC services + .proto (User only)
├── Shared/Baya.SharedKernel Extensions + validation base
└── Tests/
├── Baya.Tests.Setup Shared test infrastructure (SQLite, NSubstitute setup, TestFieldEncryptor)
├── Baya.Tests.Setup Shared test infrastructure (SQLite, NSubstitute, 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")
├── Baya.Test.Foundation Cross-cutting plumbing + identity handler unit tests
└── Baya.Test.Api WebApplicationFactory integration tests (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.
**DB schemas**, one per area: `usr`, `ops`, `geo`, `catalog`, `verif`, `search`, `booking`, `payments`,
`payouts`, `reviews`, `messaging`, `partner`.
**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:*` (appsettings/env). Swapping is a registration change; **no handler is touched**. The adapters:
`KavenegarSmsSender` (`Sms:Provider=kavenegar`**launch-critical**; when a real gateway is selected the
Development OTP-in-logs bridge is **disabled**, so the OTP is never logged), `TelegramSmsSender`
(`Sms:Provider=telegram`**broadcast, not a gateway**; the pre-launch demo OTP rail: it posts to the standalone
`telegram-otp-bot/` relay, which pushes *every* code to a fixed list of Telegram chat ids, so manual testing
beats reading OTPs out of the log. It is the **one non-mock SMS provider that keeps the OTP-capture bridge
enabled** — see Startup wiring — and its `Seams:Sms:Telegram:ApiKey` is a user-secret, never committed),
`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 ~710-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 15)`, `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.
> `Features/Booking` (singular — the money-free pre-payment request) and `Features/Bookings` (plural — the
> post-payment engine) are **different areas, not a rename.** The entity type `Booking` is aliased where the
> namespaces collide.
---
## Startup wiring
## Where to read more
Service registration is composed from per-layer extension methods (each project's `ServiceConfiguration/`):
Open **one** of these for the area you are touching.
```
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 the registered ISmsSender to
// capture each OTP in-memory for the GET /api/v1/dev/last_otp/{phone} helper — never wired outside Development,
// and only for a capture-safe Seams:Sms:Provider (`mock` / unset, or the Development-only `telegram` relay).
// A real gateway (kavenegar) disables it, so the code only ever leaves the process over the SMS wire.
```
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`.
- **Development lifecycle seeder (manual-testing bring-up).** `Persistence/Services/Seeding/DemoLifecycleSeeder.cs`
(+ `.Money.cs`/`.Social.cs` partials + `DemoLifecycleDefinitions.cs`) layers a full **lifecycle** world on the
demo personas so every flow is manually testable: booking requests in every status, 8 bookings across every
reachable state (upcoming w/ care instructions, a 5-session package mid-engagement with EVV, completed
inside/past the dispute window, BNPL-settled, cancelled-with-refund, clawed-back), the balanced payment
ledger behind each (via `LedgerPosting`), refunds on all three forks, a **paid** and a **draft** payout batch
(dispatched through the real `GeneratePayoutBatch`/`ExecutePayoutBatch` commands), moderated reviews +
recomputed nurse aggregates, tickets (incl. an `is_internal` note + coordination tickets via the real
command), notifications, patient care records, a merchant-of-record partner center (portal user
`09120000030`, linked to the second nurse), and a mid-pipeline verification case for the unverified nurse.
States are reached through the entities' guarded transition methods + `BookingFactory` (Application grants
`InternalsVisibleTo` to Persistence for this); business timestamps are backdated explicitly. Idempotent per
scenario on natural keys — **never guard on a Persian string**: the `ApplicationDbContext` save hook
normalizes Persian digits/ZWNJ in every stored string, so a Persian literal never round-trips equal.
Invoked via `SeedDemoLifecycleAsync()` after the demo-world + gateway seeds, Development-only. Scenario
table + testing plan: `dev/post-phase/manual-testing-plan.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`. The base `appsettings.json` carries
`SET_VIA_USER_SECRETS_OR_ENV` placeholders that `StartupSecretsGuard` rejects; the real values live in the
environment-specific file (`appsettings.Development.json` holds the dev-only keys the demo deployment runs on). `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 |
| Working on… | Read |
| --- | --- |
| Projects, layers, startup wiring, the seam catalogue, observability | [docs/rules/server/structure.md](../docs/rules/server/structure.md) |
| Adding a feature — command, query, handler, validator, controller | [docs/rules/server/cqrs.md](../docs/rules/server/cqrs.md) |
| EF Core, audit, state machines, uniqueness, snapshots, search, jobs, seeders | [docs/rules/server/persistence.md](../docs/rules/server/persistence.md) |
| **Anything on the money path** — ledger, refunds, BNPL, payouts, invoices | [docs/rules/server/money.md](../docs/rules/server/money.md) |
| Auth, JWE, sessions, field encryption, tenancy, disclosure, logging | [docs/rules/server/identity.md](../docs/rules/server/identity.md) |
| C# style, naming, async, error handling, tests, DI | [docs/rules/server/conventions.md](../docs/rules/server/conventions.md) |
| The wire contract — envelope, status codes, enums, pagination | [docs/integration/](../docs/integration/index.md) |
| What is built, what is mocked, what is next | [docs/status/](../docs/status/index.md) |
| Cross-project rules — naming, gates, code quality, config | [docs/rules/shared/](../docs/rules/shared/) |
-508
View File
@@ -1,508 +0,0 @@
# Server Coding Conventions
Rules enforced for all code in `server/`. These represent the standards expected from a **senior .NET engineer**. Read alongside [CLAUDE.md](CLAUDE.md).
When in doubt, ask: _would a senior engineer approve this diff without comment?_
---
## 1. Routing
### Rule: all URL segments must be `snake_case`
`SnakeCaseParameterTransformer` (`Baya.WebFramework/Routing/`) is registered globally via `RouteTokenTransformerConvention`. It converts `[controller]` and `[action]` tokens automatically.
```csharp
// ✅ transformer converts MyFeature → my_feature, GetBySlug → get_by_slug
[Route("api/v{version:apiVersion}/[controller]")]
public class MyFeatureController : BaseController
{
[HttpGet("[action]")]
public Task<IActionResult> GetBySlug(...) { }
}
// ❌ bypasses transformer — hardcoded segment escapes snake_case enforcement
[Route("api/v{version:apiVersion}/MyFeature")]
[HttpGet("GetBySlug")]
```
If a method name doesn't read cleanly as a URL, **rename the method** — don't hardcode the route string.
---
## 2. C# code quality
### Use the right type for the job
| Scenario | Use |
|---|---|
| Request/response/DTO | `record` (immutable, value semantics) |
| Domain entity | `class` (mutable state, encapsulated) |
| Shared small value | `readonly record struct` |
| Handler, service | `sealed class` |
### Language features — use them
```csharp
// ✅ primary constructor (C# 12)
public sealed class OrderHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<...> { }
// ✅ switch expression over if/else chains
var label = status switch
{
OrderStatus.Pending => "Pending",
OrderStatus.Shipped => "Shipped",
OrderStatus.Cancelled => "Cancelled",
_ => throw new ArgumentOutOfRangeException(nameof(status))
};
// ✅ pattern matching
if (result is { IsSuccess: false, IsNotFound: true }) return NotFound();
// ✅ collection expressions (C# 12)
List<string> tags = ["new", "sale"];
```
### Immutability & safety
- Mark fields `readonly` unless mutation is genuinely needed.
- Prefer `IReadOnlyList<T>` / `IReadOnlyCollection<T>` over `List<T>` in signatures unless the caller needs to mutate.
- Never expose public setters on entities — use methods or constructors.
- Avoid `static` mutable state.
### Null handling
- Enable `<Nullable>enable</Nullable>` in any new project you create.
- Use guard clauses at the entry point; don't scatter null checks throughout.
- Prefer returning `OperationResult.NotFoundResult(...)` over returning `null` from handlers.
- Never use `null!` (null-forgiving) unless you can prove the value cannot be null and the compiler cannot.
### Naming
| Kind | Convention | Example |
|---|---|---|
| Class, record, interface | PascalCase | `OrderHandler`, `IOrderRepository` |
| Method | PascalCase | `GetUserOrdersAsync` |
| Parameter, local variable | camelCase | `orderId`, `userEmail` |
| Private field | `_camelCase` | `_unitOfWork` |
| Constant | PascalCase | `MaxRetryCount` |
| Generic type param | `T` or descriptive `TEntity` | |
| Command | `{Verb}{Noun}Command` | `CreateOrderCommand` |
| Query | `{Verb}{Noun}Query` | `GetUserOrdersQuery` |
| Handler | `{RequestName}Handler` | `CreateOrderCommandHandler` |
| Result DTO | `{RequestName}Result` | `CreateOrderCommandResult` |
No abbreviations unless universally understood (`dto`, `id`, `url`). No Hungarian notation (`strName`, `intCount`).
### No unused code
Leave nothing dead behind. Remove unused `using` directives, local variables, parameters, private fields, and private members rather than letting them accumulate.
- These already surface as compiler/analyzer signals — `CS0168` (variable declared, never used), `CS0219` (variable assigned, value never used), `CS0169` (private field never used), `IDE0005` (unnecessary `using`). The quality gate is **zero new warnings**, so treat unused code as a gate failure.
- **Delete it — don't silence it.** Do not add `#pragma warning disable`, throwaway discards, or `_ =` assignments just to quiet the analyzer.
- The one exception: a parameter that must exist to satisfy an interface or delegate signature but is genuinely unused. Keep it, name it conventionally, and add a one-line `// why` only if the reason isn't obvious.
### Comments — explain *why*, never *what*
Code that needs a comment to be understood usually needs a better name instead. Prefer self-documenting names over prose.
- **Do not** write comments that restate what the code already says — no `// constructor`, `// loop over users`, or XML-doc that merely echoes the method name.
- **Do** add a comment only where a non-obvious decision, constraint, business rule, workaround, or trade-off is *not* evident from the code — explain the reasoning, not the mechanics.
- Keep any necessary comment tight, and delete comments that no longer match the code.
```csharp
// ❌ restates the obvious
// increment the retry counter
retryCount++;
// ✅ captures a non-obvious constraint the code can't express on its own
// Payment gateway rejects amounts above 50M IRR per call; split larger settlements upstream.
if (amount > MaxPerCallRial) ...
```
---
## 3. Async / await
```csharp
// ✅ always async all the way — no .Result or .Wait()
public async ValueTask<OperationResult<T>> Handle(MyQuery request, CancellationToken ct)
{
var entity = await _repository.GetAsync(request.Id, ct);
return OperationResult<T>.SuccessResult(_mapper.Map(entity));
}
// ❌ blocks the thread, risks deadlock
var result = _repository.GetAsync(id).Result;
// ✅ pass CancellationToken through every async call
await _db.SaveChangesAsync(cancellationToken);
// ❌ fire and forget with no error handling
_ = DoSomethingAsync();
```
- Every public async method must accept `CancellationToken` and pass it downstream.
- Use `ValueTask<T>` for hot paths (handlers, repositories). Use `Task<T>` for rarely-called or always-async methods.
- Never use `async void` — it swallows exceptions. Use `async Task` even for event-like callbacks.
- Do not add `.ConfigureAwait(false)` in this ASP.NET Core app — it's unnecessary and adds noise.
---
## 4. Controllers
Every controller must follow this skeleton:
```csharp
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[Display(Description = "One-line description shown in Swagger")]
[Authorize(ConstantPolicies.DynamicPermission)] // or [Authorize], or omit for public
public sealed class MyFeatureController(ISender sender) : BaseController
{
[HttpGet("[action]")]
[ProducesOkApiResponseType<MyQueryResult>]
public async Task<IActionResult> GetSomething(CancellationToken ct)
=> OperationResult(await sender.Send(new MyQuery(), ct));
[HttpPost("[action]")]
[ProducesOkApiResponseType<MyCommandResult>]
public async Task<IActionResult> CreateSomething(MyCommand command, CancellationToken ct)
=> OperationResult(await sender.Send(command, ct));
}
```
Rules:
- `sealed` — controllers are not designed for inheritance beyond `BaseController`.
- Inject `ISender` via primary constructor — not `IMediator`.
- **Never call `Ok()`, `BadRequest()`, `NotFound()` directly** — always `base.OperationResult(result)`.
- Keep controller methods thin: one `Send`, one `OperationResult`. No business logic in controllers.
- Use `[Display(Description = "...")]` so NSwag generates meaningful Swagger tags.
- Pass `CancellationToken` from the action into `sender.Send(...)`.
### Authorization levels — use the narrowest that fits
| Attribute | When |
|---|---|
| _(none)_ | Truly public (health check, metrics) |
| `[Authorize]` | Any authenticated user |
| `[Authorize(ConstantPolicies.DynamicPermission)]` | Role/claim-gated admin action |
| `[RequireTokenWithoutAuthorization]` | Token must be present but may be expired (e.g. refresh) |
Apply at the **controller level** for uniform policy; override at the action level only for exceptions.
---
## 5. CQRS — feature structure
```
Features/<Area>/
├── Commands/<VerbNoun>Command/
│ ├── <Name>Command.cs record Command(…) : IRequest<OperationResult<T>>
│ ├── <Name>Command.Handler.cs internal sealed class Handler : IRequestHandler<…>
│ └── <Name>Command.Validator.cs AbstractValidator<Command> (omit if no validation needed)
└── Queries/<VerbNoun>Query/
├── <Name>Query.cs record Query(…) : IRequest<OperationResult<T>>
├── <Name>Query.Handler.cs internal sealed class Handler : IRequestHandler<…>
└── <Name>Query.Result.cs record Result(…) ← the DTO returned
```
- Request types are `record` — immutable.
- Handlers are `internal sealed` — they are never used outside the Application layer.
- **Handlers must not throw for expected failures.** Use `OperationResult` factory methods:
- `OperationResult<T>.SuccessResult(value)` — happy path
- `OperationResult<T>.FailureResult(errors)` — validation / business rule failure
- `OperationResult<T>.NotFoundResult(message)` — entity not found
- Only one handler per request type — no conditional dispatch.
- Contracts the handler depends on go in `Application/Contracts/` as interfaces; implementations live in Infrastructure.
---
## 6. Persistence — EF Core rules
```csharp
// ✅ project to DTO in the query — never load full entity for read operations
var dto = await _db.Orders
.AsNoTracking()
.Where(o => o.UserId == userId)
.Select(o => new OrderResult(o.Id, o.Status, o.CreatedAt))
.ToListAsync(ct);
// ❌ loads entire 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);
```
Rules:
- **Always use `AsNoTracking()`** on read-only queries.
- **Always project with `Select()`** in queries — never hydrate full entities just to map them.
- Never load more than you need. Pagination is mandatory for any unbounded list: `Skip` / `Take`.
- Use `Include` only in command handlers where you need to mutate the aggregate and need navigation properties loaded.
- Access the DB through `IUnitOfWork` in Application-layer handlers. `ApplicationDbContext` is only referenced directly inside Infrastructure.
- Commit once per command at the end: `await _unitOfWork.CommitAsync(ct)`.
- One `IEntityTypeConfiguration<T>` per entity, in `Persistence/Configuration/<Area>Config/`.
- Migrations command: `dotnet ef migrations add <Name> --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api`
### Soft delete
Every entity that supports soft delete **must** declare a global EF query filter in its `IEntityTypeConfiguration<T>`:
```csharp
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.HasQueryFilter(o => !o.IsDeleted);
}
```
Without this filter, soft-deleted records appear in every query that doesn't explicitly filter them — a silent data leak. Never add `Where(x => !x.IsDeleted)` in individual queries; the filter makes it automatic and auditable.
### Entity audit fields
When designing or extending an entity, include audit fields alongside timestamps:
| Field | Type | Set by |
|---|---|---|
| `CreatedAt` | `DateTimeOffset` | `SaveChangesAsync` override (on Add) |
| `ModifiedAt` | `DateTimeOffset` | `SaveChangesAsync` override (on Update) |
| `CreatedById` | `int?` | `SaveChangesAsync` override via `ICurrentUser` |
| `ModifiedById` | `int?` | `SaveChangesAsync` override via `ICurrentUser` |
Wire `ICurrentUser` (HTTP context accessor wrapped in an interface, registered Scoped) into `ApplicationDbContext` so the context can stamp who made the change without handlers needing to pass it explicitly. Audit fields cannot be backfilled retroactively — design them in from the start.
> **As built (backend-phase-0):** the audit base type is `BaseEntity`/`IAuditableEntity` in
> `Baya.Domain/Common/BaseEntity.cs` (`CreatedAt`/`ModifiedAt` as `DateTimeOffset`, `CreatedById`/
> `ModifiedById` as `int?`). Stamping is done by `AuditFieldInterceptor`
> (`Baya.Infrastructure.Persistence/Interceptors/`), a `SaveChangesInterceptor` that reads time from
> `IDateTimeProvider` and the user from `ICurrentUser` — not in the `DbContext` itself.
> **As built (backend-phase-1) — reusable patterns you should follow:**
> - **Config is rows, read at compute time.** Money-critical constants (commission %, VAT, deadlines,
> EVV tolerance, cancellation tiers) live in `platform_configs`, read via `IPlatformConfig.GetConfig<T>`
> (cached, parsed by the row's `data_type`) — **never hardcode**. Changing a rate must never
> retroactively alter an already-computed amount: later phases snapshot the rate onto the
> booking/invoice at compute time; do not live-re-read a rate for an already-priced row.
> - **Append-only audit trail.** `audit_logs` is immutable — there is **no** update/delete path in app
> code. Mark a compliance-sensitive entity with `IAuditable` (`Baya.Domain/Common`) and the
> `AuditFieldInterceptor` writes an old/new diff row per change in the same transaction; annotate any
> encrypted/PII property with `[AuditRedacted]` so it is redacted (never plaintext) in the diff.
> `platform_configs` is the first `IAuditable` entity.
> - **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 these tables directly. `IAnalyticsSink` is fire-and-forget
> (never fail the caller); `INotificationService`/notification endpoints are always tenant-scoped to
> `ICurrentUser`; `support_alerts` are admin-only and never appear on a user-facing route.
> - **Retention/scheduling seam.** Background jobs run behind the hosted-service seam
> (`NotificationRetentionHostedService`); real Hangfire/Quartz is deferred. The notification retention
> predicate is exactly `is_read = 1 AND age > 90d` — unread is never auto-deleted.
### Money is IRR `BIGINT` — integer-only, no floats
Every monetary value is **IRR Rials stored as `long` / `BIGINT`**. There is **no float/decimal path** on money — not in entities, DTOs, the API, or arithmetic. Toman is display-only and converts to/from Rials **only** inside a provider adapter at its boundary, never in domain or shared code. If a money value object is introduced later it must be integer-only. The three booking amounts always satisfy `gross = commission + payout`.
### Deterministic set-hash for multi-row uniqueness
When "no two rows may share the same *set* of child rows" must be enforced (e.g. a nurse can't list two
identical variants — same category + identical answered option-set), a plain composite unique index can't
express it because the set spans multiple rows. Reduce the set to a single comparable column with
**`Baya.Application.Common.OptionSetHash.Compute(pairs)`** (backend-phase-5): it sorts the `(long, long)`
pairs and SHA-256s them to a stable 64-char hex hash that is **order-independent** (identical sets always
collide). Persist it (`NVARCHAR(64)`) and back it with a **filtered unique index** (e.g.
`UNIQUE(nurse_id, service_category_id, option_set_hash) WHERE deleted_at IS NULL`) as the race-safe backstop,
with a handler pre-check for the friendly `409`. Reuse this helper for any future "same set of ids" guard;
do **not** reuse `IFieldEncryptor.Hash` (that is for PII-column equality lookups).
### Guarded cross-aggregate state flip (backend-phase-6)
When one write must atomically change a header row's state **and** a derived boolean on a *different*
aggregate (e.g. `nurse_verifications.status``nurse_profiles.is_verified`), do it in one transaction:
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. Two follow-on
rules this establishes:
- **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.
- **Persist a status enum as its stable snake_case code, not the member name.** Define the C# enum, then map
it with a `HasConversion(e => e.ToCode(), s => Parse(s))` value converter (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.
### Forward-only status machine (backend-phase-8)
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. The b8
pattern (reused by b9 for the `bookings` machine):
- **Statuses are `const string` codes** (`BookingRequestStatus`) persisted as the stable snake_case string —
no C# enum, no value converter needed. **Edges live in a static `CanTransition(from, to)`**
(`BookingRequestTransitions`) built from a `Dictionary<string, IReadOnlyCollection<string>>`; terminal
states map to an empty set.
- **The entity owns the transition.** `status` has a **private setter**; the only mutators are cohesive domain
methods (`Accept`/`Reject`/`Cancel…`) that call a private `Transition(target)` which asserts the edge is
legal (throws on an illegal edge — a programming error, since the handler pre-checks). Side-effect fields
(`payment_deadline_at`, `rejection_reason`) 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.
- **Time-sensitive commands self-guard** against a passed deadline via `IDateTimeProvider` rather than trusting
a sweep has run; the recurring expiry `BackgroundService` is bounded/paginated/idempotent, and its
`WHERE status = …` predicate (re-queried each tick) is the concurrency guard — a row a racing action moved is
simply not reloaded.
- **Deadline columns that are compared/sorted use `DateTime` (UTC `datetime2`), not `DateTimeOffset`** — the
SQLite test provider cannot translate `DateTimeOffset` comparison/`ORDER BY`. Order lists/sweeps by `Id`, not
the timestamp, for the same reason.
---
## 7. Validation
- All commands that accept user input need a `FluentValidation` validator. The `ValidateCommandBehavior` pipeline behavior runs it automatically before the handler.
- Validators are registered automatically via `RegisterValidatorsAsServices()` in `Program.cs`.
- Validate at the boundary (command/query), not deep in the domain or repositories.
```csharp
public sealed class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
public CreateOrderCommandValidator()
{
RuleFor(x => x.UserId).GreaterThan(0);
RuleFor(x => x.Items).NotEmpty().WithMessage("Order must have at least one item.");
RuleForEach(x => x.Items).ChildRules(item =>
{
item.RuleFor(i => i.ProductId).GreaterThan(0);
item.RuleFor(i => i.Quantity).InclusiveBetween(1, 100);
});
}
}
```
---
## 8. Mapping — Mapster rules
- Use `IMapper` (injected via DI) for all entity↔DTO mapping in handlers.
- Register type adapter configs in `Program.cs` via `TypeAdapterConfig.GlobalSettings.Scan(...)`. Add new assemblies that contain mapping configs there.
- Never write manual mapping code when Mapster can infer it — only write custom `TypeAdapterConfig` when shapes diverge.
- Mapping happens **in the handler after the DB query**, not in the repository.
---
## 9. Error handling & logging
```csharp
// ✅ expected failure — use OperationResult, do not throw
if (user is null)
return OperationResult<T>.NotFoundResult("User not found.");
// ✅ unexpected failure — let it propagate; ExceptionHandler middleware catches it
// Log at the point you catch unexpected exceptions (ExceptionHandler logs automatically)
// ❌ swallowing exceptions
try { ... } catch { return OperationResult<T>.FailureResult(...); }
// ✅ structured logging — never interpolate sensitive data
_logger.LogInformation("Order {OrderId} created for user {UserId}", order.Id, userId);
// ❌ logs PII / secrets
_logger.LogInformation($"Token for {user.Email}: {token}");
```
- Log at the correct level: `Debug` for trace info, `Information` for meaningful events, `Warning` for recoverable issues, `Error` for unexpected failures.
- Never log passwords, tokens, secrets, or full PII (email is borderline — use `userId` in logs instead).
- The global `ExceptionHandler` middleware catches unhandled exceptions — do not add try/catch in handlers for unknown exceptions; let them propagate.
---
## 10. Testing
### Arrange — Act — Assert, always
```csharp
[Fact]
public async Task CreateOrder_ValidCommand_ReturnsSuccess()
{
// Arrange
var command = new CreateOrderCommand(UserId: 1, Items: [new(ProductId: 5, Quantity: 2)]);
var handler = new CreateOrderCommandHandler(_unitOfWork, _mapper);
// Act
var result = await handler.Handle(command, CancellationToken.None);
// Assert
result.IsSuccess.Should().BeTrue();
result.Result.Should().NotBeNull();
}
```
- Test the **handler directly** — not the controller. Controllers are thin wrappers.
- Use `NSubstitute` for mocking: `Substitute.For<IUnitOfWork>()`.
- Integration tests use `Baya.Tests.Setup` which provides an in-memory SQLite context — prefer this over mocking the DB for persistence tests.
- Name tests: `{MethodUnderTest}_{Scenario}_{ExpectedOutcome}`.
- One assertion concept per test. Multiple `.Should()` calls are fine if they all verify the same outcome.
- Do not test EF internals (entity tracking, migrations) — test behavior through the handler.
### Integration tests — HTTP pipeline coverage
Handler tests verify business logic but leave the entire HTTP stack (routing, auth pipeline, middleware, `OperationResult → IActionResult` translation) untested. Each feature area must have at least one `WebApplicationFactory<Program>`-based test covering:
1. Happy path — authenticated request returns 200 with correct body shape.
2. Unauthenticated request returns 401.
3. Validation failure returns 400 with field-level error detail.
```csharp
public class MyFeatureApiTests(WebApplicationFactory<Program> factory)
: IClassFixture<WebApplicationFactory<Program>>
{
[Fact]
public async Task GetSomething_Authenticated_Returns200()
{
var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", TestTokens.ValidAdminToken);
var response = await client.GetAsync("/api/v1/my_feature/get_something");
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
}
```
Place these tests in a dedicated `Baya.Test.Api` project so they can run against the full `Program.cs` wiring.
---
## 11. Security rules
- **Never hardcode secrets in C#.** Keys, connection strings, and tokens come from `appsettings.*.json` or environment variables, bound to typed settings classes — never a literal in a handler or service. (`dotnet user-secrets` is not used; see [DEPLOY.md](../DEPLOY.md) for the configuration model.)
- `SecretKey` and `Encryptkey` (in `IdentitySettings`) belong in the environment-specific file, never in the base `appsettings.json`, which stays at its `StartupSecretsGuard`-rejected placeholder.
- Always validate all external input with FluentValidation before processing.
- EF Core parameterizes queries automatically — never concatenate raw SQL.
- If you must use raw SQL, use `FromSqlInterpolated` (parameterized), never `FromSqlRaw` with user data.
- Respect the principle of least privilege: grant `[Authorize(ConstantPolicies.DynamicPermission)]` to admin actions, not just `[Authorize]`.
- **Auth and OTP endpoints must be rate-limited.** Use ASP.NET Core's built-in `AddRateLimiter` (no extra NuGet package needed). Apply at minimum to: login, OTP request, and token refresh. A fixed window or token bucket policy per IP is the baseline. Register the limiter in a `ServiceConfiguration/` extension; add `app.UseRateLimiter()` before `app.UseAuthentication()` in `Program.cs`.
---
## 12. Service registration
- Every new infrastructure service gets an extension method in the project's `ServiceConfiguration/` folder.
- That extension is called from `Program.cs` — no inline DI registration in `Program.cs`.
- Register with the correct lifetime:
- **Singleton** — stateless, thread-safe services (e.g. `IHttpContextAccessor`)
- **Scoped** — per-request services (repositories, `DbContext`, handlers)
- **Transient** — lightweight, stateless (validators, transformers)
- All NuGet versions live in `Directory.Packages.props`. Never add `Version=` to a `<PackageReference>` in a `.csproj`.
---
## 13. Code organisation
- One type per file. File name matches the type name exactly.
- Handlers and validators go in the same feature folder — not in separate `Handlers/` or `Validators/` root folders.
- If a file exceeds ~150 lines, consider splitting it. Long files usually mean mixed concerns.
- Partial classes are only for generated code (source generators, EF scaffolding).
- Keep `Program.cs` as an orchestrator — extension method calls only, no logic.
+3 -2
View File
@@ -9,8 +9,9 @@ Backend API for the Balinyaar application. It is an **ASP.NET Core (.NET 10)** s
- A modular **gRPC plugin** mounted via Application Parts
- Observability out of the box: Serilog, OpenTelemetry, Prometheus metrics, health checks
> Looking for an architecture/file map to navigate the code? See [CLAUDE.md](CLAUDE.md) (agent guide)
> and [CONVENTIONS.md](CONVENTIONS.md) (coding rules).
> Looking for an architecture/file map to navigate the code? See [CLAUDE.md](CLAUDE.md) (the project map
> and the hard rules) and [../docs/rules/server/](../docs/rules/server/) (the coding rules, one file per
> area — `conventions.md` is the successor to the former `CONVENTIONS.md`).
## Requirements
+7 -3
View File
@@ -5,11 +5,15 @@
# seeds roles + an admin user + a sandbox gateway against this empty instance.
#
# The SA password below is a well-known DEV-ONLY value: it is NOT a secret, is used only on localhost,
# and never reaches a deployed environment. Point the API at this instance via `dotnet user-secrets`
# (see dev/post-phase/refinement/RUNBOOK.md) — never by editing a committed appsettings*.json.
# and never reaches a deployed environment.
#
# Point the API at this instance by setting `ConnectionStrings:SqlServer` in
# `src/API/Baya.Web.Api/appsettings.Development.json` (or as an environment variable).
# `dotnet user-secrets` is NOT used in this repo — the `<UserSecretsId>` was removed, so that store is
# never read. See docs/rules/shared/code-quality.md §6 for the configuration model.
#
# docker compose up -d
# # then set ConnectionStrings:SqlServer via user-secrets (see the runbook), then `dotnet run`
# # then set ConnectionStrings:SqlServer as above, then `dotnet run`
#
# The API itself runs on the host via `dotnet run` (not in a container) for the local-dev loop — this
# compose file intentionally provisions only the database.