add build development phases

This commit is contained in:
hamid
2026-06-28 21:59:59 +03:30
parent 1df3cd9f64
commit 53a40dc51d
52 changed files with 12379 additions and 0 deletions
+175
View File
@@ -0,0 +1,175 @@
# Backend Phase 0 — Foundation, cross-cutting seams & starter cleanup
> **Mission:** turn the inherited starter skeleton into a clean Balinyaar foundation. Remove the demo
> scaffolding, stand up the **REST API surface** the marketplace needs (the baseline is gRPC-only today),
> wire the missing cross-cutting plumbing (rate limiting, request logging, current-user + audit-field
> stamping, PII encryption), and define every **mock-able external dependency as a DI seam** with a
> faithful in-memory implementation so later phases just plug real providers in. No domain tables yet —
> this phase makes the next fifteen phases possible and consistent.
>
> **Track:** backend · **Depends on:** nothing (first phase) · **Unlocks:** every backend phase
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
This is the very first build phase. The server (`server/`, .NET 10, Clean Architecture, CQRS via
**`martinothamar/Mediator`** — *not* MediatR) already ships a working spine you must **keep and build
on**, and some demo scaffolding you must **remove**.
**What already exists (do not rebuild) — confirmed in the codebase:**
- ASP.NET Core Identity + **JWE/JWT** + **phone-OTP (passwordless TOTP)** auth, the dynamic-permission
RBAC system, the CQRS pipeline (`ValidateCommandBehavior`, `MetricsBehaviour`), `OperationResult<T>`,
Mapster, FluentValidation, Serilog, OpenTelemetry/prometheus, health checks, the `BaseController` +
the full MVC filter/versioning/Swagger stack, and `Baya.Tests.Setup` (in-memory SQLite).
- The 12-project Clean-Arch solution `Baya.sln`. Identity tables map to the **`usr`** schema.
**What is starter scaffolding you will remove in this phase:**
- The `Order` demo end-to-end: `Domain/Entities/Order/Order.cs`, `Application/Features/Order/**`,
`Contracts/Persistence/IOrderRepository.cs`, `Persistence/Repositories/OrderRepository.cs`,
`Persistence/Configuration/OrderConfig/`, the `User.Orders` nav, `IUnitOfWork.OrderRepository`,
`OrderGrpcServices` + `OrderGrpcServiceModels.proto` and its wiring.
- The three old migrations + snapshot (`Migrations/2021…`, `2022…`, `2023…`, namespace
`Persistence.Migrations`) — they predate the marketplace and will be regenerated as the new baseline
in **backend-phase-1**.
**Known gaps you will close here:** no HTTP controllers exist (REST surface must be created);
`LoggingBehavior<,>` is defined but never registered; **no rate limiting** is wired despite
`CONVENTIONS.md` §11; OTP delivery is stubbed (handled in b2).
## 2. Required reading (do this first)
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md).
- [`server/CLAUDE.md`](../../../server/CLAUDE.md) — *Startup wiring*, *Project map*, *Identity & auth*,
*Persistence*; and [`server/CONVENTIONS.md`](../../../server/CONVENTIONS.md) — §1 routing, §4
controllers, §6 persistence (audit fields, soft delete), §9 logging, §11 security (rate limiting),
§12 service registration.
- [`product/overview/platform-summary.md`](../../../product/overview/platform-summary.md) — the four
ground truths (no cash custody, 10% VAT, full-upfront BNPL, weekly payout) and the **IRR-Rials-always**
rule that shapes the money types you scaffold here.
- [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md) and
[`money-and-types.md`](../../contracts/conventions/money-and-types.md) — the envelope/format your REST
surface must honour.
- Read the actual code you'll touch: `Baya.Web.Api/Program.cs`, `Baya.Application/Common/*Behavior*.cs`,
`Baya.WebFramework/BaseController/*`, `Baya.Infrastructure.Persistence/ApplicationDbContext.cs`,
and each project's `ServiceConfiguration/` extension.
## 3. Scope — build this
### 3.1 Remove the starter scaffolding
Delete the `Order` feature/entity/repository/config/gRPC/proto and the three old migrations listed in §1.
Remove every reference (nav property, `IUnitOfWork` member, DI wiring, proto compile). The solution must
build clean afterwards. Do **not** remove Identity/auth/observability — those stay.
### 3.2 Stand up the REST surface
The marketplace is REST/JSON. Create the first versioned controller(s) under `Baya.Web.Api/Controllers/V1/`
following `CONVENTIONS.md` §4 exactly (sealed, `BaseController`, inject `ISender`, `[controller]`/`[action]`
tokens, `base.OperationResult(...)`, `[Display(Description=...)]`). A minimal **health/ping** or
**reference** controller is enough to prove the pipeline end-to-end through Swagger; later phases add the
real controllers. Confirm `MapControllers()` now serves real routes and Swagger renders them.
### 3.3 Close the wiring gaps
- **Register `LoggingBehavior<,>`** in the Application pipeline (alongside the existing behaviors), in the
correct order, so every request is structurally logged (no PII — `CONVENTIONS.md` §9).
- **Rate limiting** (`CONVENTIONS.md` §11): add `AddRateLimiter` via a `ServiceConfiguration/` extension
and `UseRateLimiter()` **before** `UseAuthentication()` in `Program.cs`. Define named policies (a
per-IP fixed-window/token-bucket baseline) ready for auth/OTP/refund/payout endpoints to apply later.
### 3.4 Current-user + audit-field stamping
- Add **`ICurrentUser`** (Application contract) wrapping the HTTP context (user id, roles), registered
**Scoped**, with a null-object for non-HTTP contexts (jobs/tests).
- Add a **SaveChanges interceptor** (or extend the existing `SavingChanges` hook) that stamps
`CreatedAt/ModifiedAt` and `CreatedById/ModifiedById` from `ICurrentUser` on a shared base/interface,
per `CONVENTIONS.md` §6. Define the audit-capable base type (extend the existing `BaseEntity`/
`ITimeModification` rather than inventing a parallel one). The `audit_logs` *table* and the
append-only change log come in **b1** — here you build the *field-stamping* plumbing the interceptor
needs and leave a clean extension point for b1 to add log-row writing.
### 3.5 Define the cross-cutting seams (interfaces + mocks + DI)
Create these **Application-layer interfaces** with real-shaped signatures, an **Infrastructure mock
implementation** each, and **DI registration** via a `ServiceConfiguration/` extension (selected by
config so a real impl swaps in later). Keep amounts as IRR `long`. This phase **introduces** these seams;
later phases reuse them.
- **`IDateTimeProvider`** — `DateTimeOffset UtcNow` (so time is testable; no `DateTime.Now` in handlers).
- **`IFieldEncryptor`** — `string Encrypt(string)` / `string Decrypt(string)` (+ a deterministic
`Hash(string)` for lookups like `iban_hash`). Mock = local symmetric key from config; **never** log
plaintext PII. This is what every encrypted PII column will use.
- **`ICacheService`** — typed get/set/remove with TTL and a `GetOrCreateAsync`. Mock = in-memory
(`IMemoryCache`-backed). The config accessor (b1) and read-heavy queries cache through this; Redis later.
- **`IObjectStorage`** — presigned/streamed put/get/delete keyed by an opaque storage key, returning a
retrievable URL. Mock = local-disk/in-memory store under a scratch path. MinIO/S3 later.
- **`INotificationDispatcher`** — `DispatchAsync(notification)` with a channel concept (in-app now;
SMS/push later). Mock = no-op/log; the real in-app `notifications` write lands in b15. Define the seam
now so emitting domains (booking, payments) can depend on it.
> The **OTP/SMS** seam (`ISmsSender`) is introduced in **b2** (with the auth REST surface), and the
> **search/payment/bnpl/bank/vendor** seams in their phases — do not pre-build those here; just leave the
> registry rows. (Listed in [mocks-registry.md](../../shared-working-context/reports/mocks-registry.md).)
### 3.6 Money & convention guardrails
Add (or confirm) the small shared helpers the money path will rely on: IRR is `long`/`BIGINT`
everywhere; if you add a money value object keep it integer-only with no float path. Document the rule in
`server/CONVENTIONS.md` if not already explicit.
## 4. Mocks & seams in this phase
| Seam | Mock behaviour | Registry |
| --- | --- | --- |
| `IDateTimeProvider` | returns real UTC now (deterministic override in tests) | n/a (not external) |
| `IFieldEncryptor` | local symmetric key from config; passthrough-but-reversible; deterministic hash | update row |
| `ICacheService` | in-memory `IMemoryCache` | update row |
| `IObjectStorage` | local-disk/in-memory blob store | update row |
| `INotificationDispatcher` | log/no-op (in-app write arrives in b15) | update row |
Record each in [`mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) (seam, file,
what's faked, config keys, how to make real, status 🟡).
## 5. Critical rules you must not get wrong
- **Don't break the working spine.** Identity, JWE auth, dynamic permissions, the CQRS behaviors, and
observability must still work after cleanup. Run the existing tests.
- **Seams live in Application, implementations in Infrastructure.** Never reference Infrastructure from
Application/Domain. Register via `ServiceConfiguration/` extensions called from `Program.cs` — no inline
DI in `Program.cs` (`CONVENTIONS.md` §12).
- **Mock = real interface, fake body.** No `if (mock)` branches in handlers; selection is by registration.
- **`IFieldEncryptor` never leaks plaintext** into logs, exceptions, or non-`AsNoTracking` query
projections of PII.
- **Audit-field stamping is infrastructure, not handler code** — handlers never set `CreatedById` etc.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] `Order` + the three old migrations are gone; `dotnet build Baya.sln` is clean (zero new warnings);
`dotnet test Baya.sln` passes (existing identity tests still green).
- [ ] At least one real REST controller is reachable through Swagger and returns the `OperationResult`
envelope.
- [ ] `LoggingBehavior` registered; rate limiter wired (policies defined, `UseRateLimiter` placed before
auth).
- [ ] `ICurrentUser` + audit-field interceptor in place; the five seams (§3.5) registered with mocks.
- [ ] The **Project map** in `server/CLAUDE.md` is updated (Order removed; seams/cross-cutting noted);
`CONVENTIONS.md` notes the IRR-`BIGINT` money rule if it wasn't explicit.
## 7. How to test (what a human can verify after this phase)
- `dotnet build Baya.sln` and `dotnet test Baya.sln` — both succeed.
- `dotnet run` the API → open `/swagger` → the new REST controller appears and its endpoint returns a
200 in the standard envelope; the Order endpoints are gone.
- Hit the rate-limited test endpoint past its limit → `429`.
- A unit test proves `IFieldEncryptor.Decrypt(Encrypt(x)) == x` and the audit interceptor stamps
`CreatedAt`/`CreatedById` on a save (use the SQLite test context).
## 8. Hand off & document (close the phase)
- **Docs:** update `server/CLAUDE.md` *Project map* (+ a one-line note on the new seams and where they're
registered). Update `CONVENTIONS.md` only if you established a new rule.
- **Contracts:** no domain API yet — but publish the first `swagger.json` snapshot
([openapi/README.md](../../contracts/openapi/README.md)) so the frontend's f0 can wire its type pipeline
against the envelope shape.
- **Handoff & report:** write `shared-working-context/backend/handoff/after-backend-phase-0.md` (the spine
is clean, REST works, seams exist, what f0/b1 can rely on), append to `backend/STATUS.md`, write
`reports/backend-phase-0-report.md`, and update `reports/mocks-registry.md` (the five rows → 🟡).
- **Memory:** save a `project` memory note for any non-obvious decision (e.g. how the seams are selected
by config, the audit interceptor design) with a `MEMORY.md` pointer.
+414
View File
@@ -0,0 +1,414 @@
# Backend Phase 1 — Config, reference & platform signals
> **Mission:** lay the platform backbone every later phase reads from or writes to. This phase creates
> the **first marketplace EF migration baseline** (b0 deleted the old starter migrations) and stands up
> the cross-cutting tables nothing in the marketplace can run without: typed runtime config
> (`platform_configs`), the immutable audit trail (`audit_logs`) wired to the SaveChanges interceptor,
> the analytics event log (`system_events`), the shared `iranian_holidays` calendar that shifts payout
> dates, in-app `notifications`, and the internal `support_alerts` staff worklist. It ships the typed
> cached config accessor, the holiday calendar, the audit/event/notification/alert capabilities, seeds
> the canonical config keys (fee rate, VAT, dispute window, deadlines, payout interval, EVV tolerance,
> BNPL flags, cancellation tiers) and a sample holiday set — so b2…b15 read real values instead of
> hardcoding money-critical constants.
>
> **Track:** backend · **Depends on:** [backend-phase-0](backend-phase-0.md) (the cross-cutting seams, `ICurrentUser`, the audit-field SaveChanges interceptor, `IFieldEncryptor`, `ICacheService`) · **Unlocks:** every phase that reads config/holidays or raises notifications/alerts — i.e. all of b2…b15
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
This is the **second** backend phase and the foundation for the rest of the chain. b0 cleaned the
starter skeleton, stood up the REST surface, wired rate limiting + request logging + current-user/
audit-field stamping, and **defined the cross-cutting seams as DI-registered mocks**. It deliberately
shipped **no domain tables** — that starts here. Everything b2…b15 does (auth, profiles, catalog,
booking, payments, payouts, reviews, admin) reads config rows, shifts dates off holidays, writes audit
rows, raises notifications, or raises support alerts. Those four mechanisms are built **once, here**, so
no later phase reinvents them.
This is also where the **first marketplace migration baseline** is born: b0 removed the three pre-2024
starter migrations and their snapshot. The migration you generate in this phase is the new `Persistence`
baseline that every subsequent phase adds incrementally onto.
**What already exists (do not rebuild) — built by [backend-phase-0](backend-phase-0.md):**
- The cross-cutting **seams** with in-memory mocks, DI-registered via `ServiceConfiguration/` extensions
and selected by config: **`ICacheService`** (in-memory `IMemoryCache`; your config accessor caches
through it), **`IFieldEncryptor`** (`Encrypt`/`Decrypt`/`Hash`; reuse for any encrypted column),
**`IDateTimeProvider`** (`DateTimeOffset UtcNow`; never call `DateTime.Now`), **`IObjectStorage`**,
and **`INotificationDispatcher`** (b0 shipped a **no-op/log stub****this phase supersedes it** with a
real in-app `notifications` write; see §3.6 and §4).
- **`ICurrentUser`** (Application contract, Scoped, null-object outside HTTP) and the **SaveChanges
interceptor** that stamps `CreatedAt/ModifiedAt/CreatedById/ModifiedById`. b0 left a **clean extension
point on that interceptor for b1 to add audit-log-row writing** — you extend it here, you do not write
a parallel interceptor.
- The full working spine: ASP.NET Core Identity + JWE/JWT + phone-OTP, dynamic-permission RBAC, the CQRS
pipeline (`ValidateCommandBehavior`, `MetricsBehaviour`, and the now-registered `LoggingBehavior`),
`OperationResult<T>`, Mapster, FluentValidation, the `BaseController` + MVC/versioning/Swagger stack,
the REST controller surface under `Baya.Web.Api/Controllers/V1/`, rate-limiting policies, and
`Baya.Tests.Setup` (in-memory SQLite). Identity tables live in the **`usr`** schema.
- The 12-project Clean-Arch solution `Baya.sln`. The `swagger.json` envelope snapshot is published so the
frontend's f0 type pipeline already knows the `OperationResult`/`ApiResult` shape.
**What is NOT yet built (you build it here):** any marketplace table, any migration, the typed config
accessor, the holiday calendar, audit-row writing, system-event emission, notifications, support alerts.
## 2. Required reading (do this first)
**Operating rules & conventions**
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md).
- [`server/CLAUDE.md`](../../../server/CLAUDE.md) — *Project map*, *Persistence*, *Startup wiring*,
*Features* (the `Features/<Area>/{Commands|Queries}/<Name>/` layout); and
[`server/CONVENTIONS.md`](../../../server/CONVENTIONS.md) — §6 persistence (configs per entity, audit
fields, soft-delete filters, EF projection + pagination), §9 logging (no PII), §12 service registration.
**Product / domain truth (the business rules are decisions, read them — don't infer from code)**
- [`product/data-model/12-audit-config-and-reference.md`](../../../product/data-model/12-audit-config-and-reference.md)
— the canonical field list and roles of `audit_logs`, `system_events`, `platform_configs`, and the
**new** `iranian_holidays` table (exact columns) + the full config-key list to seed.
- [`product/data-model/11-notifications.md`](../../../product/data-model/11-notifications.md) — the roles
of `notifications` (N:1 → `users`, `data_json` typed payload, 90-day read-retention) and
`support_alerts` (internal-only, polymorphic `entity_type`/`entity_id` + nullable typed FKs).
- [`product/business/14-notifications-and-admin.md`](../../../product/business/14-notifications-and-admin.md)
*why* in-app-only/polled, the 90-day retention rule, the admin/backoffice spine these rows feed, the
append-only audit requirement, and the Shamsi/holiday reasoning back-office depends on.
- [`product/business/13-tax-invoicing-and-legal.md`](../../../product/business/13-tax-invoicing-and-legal.md)
*why* `vat_rate` (10%, configurable, applies to the **commission line** only) and the BNPL
merchant-of-record config flags exist; finance must be able to prove the exact rate at any past moment
(this is the reason every config change is audited).
**Contract conventions you must honour**
- [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md) — the
`OperationResult` envelope, snake_case routes, mandatory list pagination (`page`/`page_size`),
localisation (`name_fa` returned for reference data), status codes.
- [`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md) — IRR
`BIGINT`/`long`, no floats. `platform_fee_rate`/`vat_rate` are **rates** (`DECIMAL`), not money; the
amounts they later multiply are IRR `long`.
**Prior handoff**
- [`../../shared-working-context/backend/handoff/after-backend-phase-0.md`](../../shared-working-context/backend/handoff/after-backend-phase-0.md)
and [`../../shared-working-context/reports/backend-phase-0-report.md`](../../shared-working-context/reports/backend-phase-0-report.md)
— exactly what b0 left you (seam names/files, the interceptor extension point, the REST pattern).
**Code to mirror**
- The seam interfaces + their mock implementations and `ServiceConfiguration/` registration from b0
(mirror that style for `IHolidayCalendar`, `IAnalyticsSink`, and the real `INotificationDispatcher`).
- The SaveChanges interceptor b0 extended — you add audit-row writing to its diff-collection path.
- The `BaseController` REST pattern from b0's controller — mirror it for the new admin controllers.
## 3. Scope — build this
> Layout follows `Features/<Area>/{Commands|Queries}/<Name>/`. Suggested areas:
> **`Configuration`**, **`Audit`**, **`Analytics`**, **`Holidays`**, **`Notifications`**,
> **`SupportAlerts`**. Each entity gets exactly one `IEntityTypeConfiguration<T>` under
> `Persistence/Configuration/<Area>Config/`. Reads use `AsNoTracking()` + `.Select(...)` projection +
> pagination; commands return `OperationResult`. Put these tables in a dedicated **`ref`/`ops` schema**
> (mirror how Identity uses `usr`) — keep them off the `usr` schema.
### 3.1 Entities & the first marketplace migration baseline
Create these EF entities + configurations, then generate **one** migration that becomes the new
`Persistence` baseline. (b0 already deleted the old migrations/snapshot.)
- **`platform_configs`** — typed key-value runtime parameters. Columns: `id` (BIGINT PK), `key`
(NVARCHAR, **UNIQUE**), `value` (NVARCHAR — the raw string), `data_type` (NVARCHAR — `decimal` / `int` /
`bool` / `string` / `json`; **tells the app how to parse `value`**), `description` (NVARCHAR, nullable),
plus the standard audit fields. **Every change to a row here is audited** (§3.3). Soft-delete is **not**
appropriate — configs are updated in place, the audit trail is the history.
- **`audit_logs`** — immutable, append-only. Columns: `id` (BIGINT PK), `entity_type` (NVARCHAR),
`entity_id` (NVARCHAR — string so it's polymorphic across PK types), `action` (NVARCHAR —
`created`/`updated`/`deleted`), `changed_fields_json` (NVARCHAR(MAX) — `{ field: { old, new } }` for
fast filtering), `actor_user_id` (FK→`users`, nullable for system actions), `occurred_at`
(DATETIMEOFFSET, from `IDateTimeProvider`). **No `ModifiedAt`/`IsDeleted` — append-only, never updated
or deleted.** Index `(entity_type, entity_id)` and `occurred_at`. (Month-partitioning + cold-storage
archival is **(DEFERRED)** — note the index design now; the archive store seam lands later.)
- **`system_events`** — high-volume analytics, **NOT compliance**. Columns: `id` (BIGINT PK), `name`
(NVARCHAR — event name), `props_json` (NVARCHAR(MAX) — arbitrary properties), `user_id` (FK→`users`,
nullable), `occurred_at` (DATETIMEOFFSET). Append-only; no audit fields needed beyond `occurred_at`.
- **`iranian_holidays`** — exact columns from the data-model doc: `id` (BIGINT PK), `holiday_date`
(DATE, index it — lookups are by date), `name_fa` (NVARCHAR(200)), `type` (NVARCHAR(20) —
`official`/`religious`/`national`), `is_bank_closed` (BIT). Unique on `holiday_date`.
- **`notifications`** — in-app, per user. Columns: `id` (BIGINT PK), `user_id` (FK→`users`, indexed),
`type` (NVARCHAR — drives front-end rendering/deep-link), `title` (NVARCHAR), `body` (NVARCHAR,
nullable), `data_json` (NVARCHAR(MAX) — **typed deep-link payload**, a versioned contract, not an
arbitrary blob), `is_read` (BIT, default 0), `read_at` (DATETIMEOFFSET, nullable), `created_at`. Index
`(user_id, is_read, created_at)` to serve unread-first paging and the unread-count query cheaply.
- **`support_alerts`** — internal staff worklist, **never user-facing**. Columns: `id` (BIGINT PK),
`type` (NVARCHAR — `low_rating`/`evv_no_show`/`evv_location_mismatch`/`verification_expired`/
`payment_anomaly`/`fraud_signal`), `severity` (NVARCHAR — `low`/`medium`/`high`), `status` (NVARCHAR —
`open`/`assigned`/`resolved`), `entity_type` (NVARCHAR) + `entity_id` (NVARCHAR) — **polymorphic,
validated at the application layer, no DB FK** — plus **nullable typed FKs** `booking_id`
(FK→`bookings`, nullable) and `review_id` (FK→`reviews`, nullable) for the common cases (declare the FK
columns now; the `bookings`/`reviews` tables arrive in b9/b14 — gate the relationship config so the
migration is additive-safe, or add the FK constraints in the phase that creates the target table and
note it in your handoff), `owner_user_id` (FK→`users`, nullable — the assigned admin), `resolution_note`
(NVARCHAR, nullable), `resolved_at` (DATETIMEOFFSET, nullable), plus standard audit fields.
> **Migration discipline:** generate the migration with a descriptive name (e.g.
> `InitialMarketplaceBaseline`) into `Baya.Infrastructure.Persistence/Migrations`. It must apply cleanly
> against a fresh DB and be the snapshot every later phase builds on. Run `dotnet ef database update`
> (or the project's migration entry point) to confirm.
### 3.2 Config — `IPlatformConfig` (Application contract) + cached accessor
- **`IPlatformConfig`** in `Application/Contracts/` with: `Task<T> GetConfig<T>(string key, …)` (parses by
the row's `data_type`, **cached** through `ICacheService` with a sensible TTL and key scheme), and
`Task SetConfig(string key, string value, …)` (admin path — writes the row **and** an audit entry; see
§3.3) and `Task<…> GetConfigChangeHistory(string key, …)` (reads the audit trail filtered to that key's
rows). Implement in Infrastructure.
- **Cache invalidation:** `SetConfig` must evict the cache key it changes so the next `GetConfig<T>` reads
the new value — but **see the money-correctness rule in §5**: changing a rate must never retroactively
alter already-computed amounts.
- **Commands/queries:** `UpdatePlatformConfig` (command, admin-only, validated, audited),
`ListPlatformConfigs` (query, paged), `GetConfigChangeHistory` (query, paged).
### 3.3 Audit — interceptor-driven write + trail query
- **Extend the b0 SaveChanges interceptor** (do not write a new one) so that on `SavingChanges` it
inspects tracked entries for entities marked **auditable** (a marker interface, e.g. `IAuditable`, or an
explicit allow-list — `platform_configs` is auditable; `system_events`/`notifications` are **not**) and
appends an `audit_logs` row per change with `entity_type`, `entity_id`, `action`, a computed
`changed_fields_json` (old/new per modified property; redact encrypted PII — never write plaintext into
the diff), and `actor_user_id` from `ICurrentUser`. The audit write happens in the **same transaction**
as the change.
- **`WriteAuditLog`** is the interceptor-driven mechanism above; also expose an explicit
`IAuditLogger.WriteAsync(...)` Application contract for cases a handler needs to record a non-entity
state change (e.g. an action with no row diff).
- **Query `GetAuditTrail(entity_type, entity_id)`** — paged, `AsNoTracking()` + projection, admin-only.
### 3.4 Analytics — `IAnalyticsSink` + `EmitSystemEvent`
- **`IAnalyticsSink`** (Application contract, **seam introduced here**) with
`Task EmitAsync(string name, object props, …)`. Mock implementation = **insert a `system_events` row**.
Calls are **fire-and-forget** from the caller's perspective (do not block or fail the user's operation
if the sink errors — log and continue). `EmitSystemEvent` is the thin command/helper that calls the
sink. **Never** route compliance-relevant facts here — those go to `audit_logs`.
### 3.5 Holidays — `IHolidayCalendar` + seed
- **`IHolidayCalendar`** (Application contract, **seam introduced here**) with
`Task<bool> IsHoliday(DateOnly date, …)`, `Task<bool> IsBankClosed(DateOnly date, …)`, and
`Task<DateOnly> NextBusinessDay(DateOnly date, …)`. Mock implementation = the **seeded
`iranian_holidays` table** (cache the lookups through `ICacheService`). `NextBusinessDay` skips
`is_bank_closed` days (and weekend rules per the Iranian banking week) — this is what payout scheduling
(b13) calls.
- **Admin CRUD** for the calendar: `ListHolidays` (query, paged/by-range), `UpsertHoliday`,
`DeleteHoliday` (commands, admin-only, audited if you make the table auditable — at minimum log via the
interceptor allow-list if finance cares; otherwise standard audit fields suffice).
### 3.6 Notifications — real in-app write (supersedes the b0 stub)
- **Replace** the b0 no-op `INotificationDispatcher` mock with a **real in-app implementation** that writes
a `notifications` row (channel concept retained: in-app **now**; SMS/push are **(DEFERRED)** to later
channels behind the same dispatcher — do **not** build them). Keep the seam interface stable so other
domains (booking, payments, reviews, verification) call `DispatchAsync(...)` unchanged.
- **`CreateNotification(user_id, type, data_json)`** — the internal command other domains call (via the
dispatcher) to mint a typed in-app record with a deep-link payload.
- **Queries:** `ListMyNotifications` (paged, **unread-first** ordering, tenant-scoped to the caller),
`GetUnreadCount` (cheap count for the polling bell — index-backed).
- **Commands:** `MarkNotificationRead` (single, sets `is_read`+`read_at`), `MarkAllRead` (bulk for the
caller).
- **Retention job `PurgeOldReadNotifications`** — hard-deletes notifications where `is_read = 1` **AND**
`created_at` (or `read_at`) older than 90 days; **never deletes unread**. There is no scheduler in the
codebase yet — introduce the job behind a small **`IJobScheduler`/hosted-service seam** (mock = a
`BackgroundService` running on an interval, or an endpoint/CLI trigger), register it, and record it in
the mock registry. Real Hangfire/Quartz is **(DEFERRED)**.
### 3.7 Support alerts — raise / assign / resolve / list
- **`RaiseSupportAlert(type, entity_type, entity_id, severity, booking_id?, review_id?)`** — the internal
command review/EVV/verification/payment flows call later. Validate the polymorphic
`(entity_type, entity_id)` at the application layer; prefer setting the typed FK when the entity is a
booking/review.
- **`AssignSupportAlert(alert_id, owner_user_id)`** and **`ResolveSupportAlert(alert_id, note)`** — owner +
resolution trail; forward-only status (`open``assigned``resolved`).
- **`ListSupportAlerts`** — admin-only query, paged, filter by `type`/`status`/`owner`. **These are on
admin-only routes and never joined into any user-facing endpoint.**
### 3.8 Admin REST surface
Add the controllers (sealed, `BaseController`, inject `ISender`, `base.OperationResult(...)`,
`[controller]`/`[action]` tokens, narrowest fitting admin policy, lists paginated). Suggested actions
(final route casing is snake_case per conventions):
- **Config:** `GET get_platform_configs`, `POST update_platform_config`, `GET get_config_change_history`.
- **Holidays:** `GET get_holidays`, `POST upsert_holiday`, `POST delete_holiday`.
- **Audit:** `GET get_audit_trail`.
- **Support alerts (admin):** `GET get_support_alerts`, `POST assign_support_alert`,
`POST resolve_support_alert`.
- **Notifications (current user):** `GET get_notifications`, `GET get_unread_count`,
`POST mark_notification_read`, `POST mark_all_read`.
> **Not exposed via REST:** `CreateNotification`, `RaiseSupportAlert`, `EmitSystemEvent`,
> `WriteAuditLog` — these are **internal application commands** other domains invoke through their
> contracts/the dispatcher, never user-callable HTTP endpoints.
### 3.9 Seed data
- **`platform_configs` keys (seed all, with `data_type`):** `platform_fee_rate` (decimal — platform
commission %), `vat_rate` (decimal, **0.10**), `dispute_window_hours` (int, **72**),
`booking_payment_deadline_minutes` (int, **30**), `nurse_response_deadline_hours` (int),
`nurse_payout_interval_days` (int), `evv_location_tolerance_meters` (int),
`min_rating_for_support_alert` (decimal/int), `bnpl_merchant_of_record` (string),
`bnpl_provider_commission_rate` (decimal), `bnpl_settlement_timing` (string), and the
**cancellation-tier defaults** (the tiered cancellation policy thresholds/percentages — store as
`json` or as discrete tier keys; b9/b11 consume them). Seed via the migration or an idempotent seeder.
- **`iranian_holidays`:** seed a representative sample set (Nowruz block, a few official/religious/national
days) with correct `is_bank_closed` flags so `IsBankClosed`/`NextBusinessDay` are testable. The full,
maintained, partly-lunar-Hijri calendar feed is **(DEFERRED)** behind the `IHolidayCalendar` "make it
real" path.
### 3.10 Out of scope (DEFERRED — do not build here)
- `roles`/`user_roles` admin RBAC tables and the role-scope authorization grid → **(DEFERRED to b15)**;
use the existing dynamic-permission RBAC + the narrowest fitting policy for now.
- The verification queue, refund tooling, payout tooling, invoices, partner centers, tickets → their own
phases (b6, b11, b13, b15). This phase builds only the **shared signals** they all use.
- `audit_logs` month-partitioning + cold-storage archival (`IArchiveStore`); `system_events`
warehouse/exporter; SMS/push notification channels; real Hangfire/Quartz scheduler; the external
Iranian-holiday sync feed → all **(DEFERRED)** behind their seams.
## 4. Mocks & seams in this phase
This phase **introduces** three seams and **supersedes** one b0 stub. Reuse the b0 seams
(`ICacheService`, `IFieldEncryptor`, `IDateTimeProvider`, `IObjectStorage`) as-is.
| Seam | New / changed | Mock behaviour | Registry |
| --- | --- | --- | --- |
| `IHolidayCalendar` | **introduced (b1)** | reads the seeded `iranian_holidays` table; cached via `ICacheService` | update row (was listed for b1) |
| `IAnalyticsSink` | **introduced (b1)** | inserts a `system_events` row; fire-and-forget | **add new row** |
| `INotificationDispatcher` | **superseded** (b0 stub → real) | writes a real `notifications` row (in-app channel); SMS/push deferred | update row (b0/15 → in-app real here) |
| `IJobScheduler` (retention) | **introduced (b1)** | in-proc `BackgroundService`/interval runner for `PurgeOldReadNotifications`; real Hangfire/Quartz deferred | **add new row** |
| `ICacheService` | reuse (b0) | in-memory `IMemoryCache` — config + holiday lookups cache through it | n/a (already 🟡) |
| `IFieldEncryptor` | reuse (b0) | used to redact PII in `changed_fields_json` | n/a (already 🟡) |
Record each introduced/changed seam in
[`mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) (seam, file, what's faked,
config keys, how to make it real, status 🟡). Selection stays **by registration/config**, never by an
`if (mock)` branch in a handler.
## 5. Critical rules you must not get wrong
- **`audit_logs` is append-only / immutable.** There is **NO update or delete of `audit_logs` rows in app
code** — ever. No `Update`/`Remove` on the set, no soft-delete column, no admin edit endpoint. It is the
system of record for compliance; enforce append-only at the repository/configuration level.
- **EVERY `platform_configs` change is audited.** Finance must be able to **prove the exact commission/VAT
rate in effect at any moment**. `SetConfig`/`UpdatePlatformConfig` must write the row **and** an
`audit_logs` entry in the **same transaction** — never mutate a config without the audit entry.
- **Config-as-rows = money correctness.** Commission %, VAT rate, dispute window, payout interval, EVV
tolerance, cancellation tiers all flow from `platform_configs`. **Read them at compute time (cached),
never hardcode**, and respect `data_type` when parsing. **Changing a rate must NOT retroactively alter
already-computed bookings/ledger** — later phases snapshot the rate they used onto the booking/invoice
at compute time; this phase must not encourage live re-reading of a rate for an already-priced row.
- **`iranian_holidays` drives payout date shifting.** If PAYA/SATNA banks are closed
(`is_bank_closed = 1`), a weekly payout shifts to the next business day via `NextBusinessDay`. Get the
calendar and the "next open bank day" logic right or payouts (b13) mis-schedule. Holidays are partly
movable/lunar-Hijri — the table is maintained, the calendar is a seam.
- **`system_events` is analytics, NOT compliance.** Never rely on it for audit/dispute evidence — it can
be sampled, dropped, or exported. Compliance facts go to `audit_logs`. Emission is fire-and-forget and
must never fail or slow the user's operation.
- **Notifications retention purges only READ notifications older than 90 days — NEVER unread.** The purge
predicate is `is_read = 1 AND <age> > 90d`. Unread notifications are never auto-deleted.
- **`support_alerts` are internal-only.** They must **never** appear in any user-facing endpoint, query,
or join. Keep them on admin-only routes with admin authorization. Never leak alert content into a
user-facing `notification`.
- **`data_json` is a typed contract.** Front-end navigation/deep-linking depends on its shape — version it
and validate it; do not dump arbitrary blobs. Same discipline for `support_alerts` polymorphic
`(entity_type, entity_id)`: validate at the application layer (no DB FK), prefer the typed FK
(`booking_id`/`review_id`) when available.
- **Tenancy.** A user sees only their **own** notifications. `ListMyNotifications`/`GetUnreadCount`/
`MarkAllRead` are always scoped to `ICurrentUser` — never a `user_id` from the request body.
- **Encrypted PII never enters `changed_fields_json`.** When the interceptor diffs an auditable entity,
redact any encrypted/PII property — write a marker (e.g. `"<redacted>"`), never the plaintext.
- **Money is IRR `BIGINT`/`long`, no floats.** Rates (`platform_fee_rate`, `vat_rate`,
`bnpl_provider_commission_rate`) are `DECIMAL` rate values; the IRR amounts they later multiply are
`long`. No money column or float appears in this phase's tables, but keep the rule visible for the
phases that read these rates.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] The **first marketplace migration baseline** is generated and applies cleanly to a fresh DB; all six
tables (`platform_configs`, `audit_logs`, `system_events`, `iranian_holidays`, `notifications`,
`support_alerts`) exist with the columns/indexes/uniques above.
- [ ] `IPlatformConfig.GetConfig<T>` returns a correctly **typed** value parsed by `data_type` and is
cached; `SetConfig`/`UpdatePlatformConfig` writes the row **and** an `audit_logs` row in one
transaction; `GetConfigChangeHistory` returns that audit entry.
- [ ] The SaveChanges interceptor (extended from b0) writes an `audit_logs` row on an auditable-entity
change with correct `changed_fields_json` and `actor_user_id`; no path updates/deletes `audit_logs`.
- [ ] `IHolidayCalendar.IsBankClosed`/`IsHoliday`/`NextBusinessDay` answer correctly over the seeded
calendar; the seed set is present.
- [ ] `IAnalyticsSink.EmitAsync` inserts a `system_events` row and is fire-and-forget.
- [ ] The **real** `INotificationDispatcher` writes a `notifications` row (b0 stub removed/replaced);
`CreateNotification``ListMyNotifications` (unread-first, tenant-scoped) + `GetUnreadCount` work;
`MarkNotificationRead`/`MarkAllRead` flip `is_read`; `PurgeOldReadNotifications` deletes only
read>90d.
- [ ] `RaiseSupportAlert``ListSupportAlerts` (admin) works; `AssignSupportAlert`/`ResolveSupportAlert`
record owner + resolution; support alerts appear on no user-facing route.
- [ ] All seeded config keys (§3.9) and the sample holidays are present after migration/seed.
- [ ] Admin + notification controllers reachable via Swagger, returning the `OperationResult` envelope;
lists paginated; admin routes authorized.
- [ ] `dotnet build Baya.sln` zero new warnings; `dotnet test Baya.sln` green (the handler + integration
tests this phase adds included). The **Project map** in `server/CLAUDE.md` is updated (new areas,
new schema, the three new seams + where they're registered).
## 7. How to test (what a human can verify after this phase)
Run `dotnet ef database update` (fresh DB) then `dotnet run` the API and use Swagger; back the unit/
integration assertions with `Baya.Tests.Setup` (SQLite).
1. **Migration creates all tables.** Apply the migration to a clean DB → all six tables + indexes/uniques
exist; the seeded config keys and sample holidays are present (query them).
2. **Typed config read.** `GetConfig<decimal>("vat_rate")``0.10m`; `GetConfig<int>("dispute_window_hours")`
`72`; `GetConfig<int>("booking_payment_deadline_minutes")``30`. A second read hits the cache.
3. **Config change is audited.** `POST update_platform_config { key:"platform_fee_rate", value:"0.18" }`
→ succeeds; `GET get_config_change_history?key=platform_fee_rate` shows the change (old→new, actor,
timestamp); a follow-up `GetConfig<decimal>("platform_fee_rate")` returns the new value (cache evicted).
Confirm no path can update/delete the `audit_logs` row.
4. **Holiday calendar.** `IsBankClosed(<a seeded bank-closed date>)``true`; `IsHoliday(<non-holiday>)`
`false`; `NextBusinessDay(<a seeded holiday>)` → the next open bank day.
5. **System event.** `EmitSystemEvent("nurse_search_performed", {...})` → a `system_events` row exists;
failure of the sink does not surface to the caller.
6. **Notifications round-trip.** `CreateNotification(user, "booking_confirmed", {...})` then
`GET get_notifications` (as that user) shows it unread-first; `GET get_unread_count` returns `1`;
`POST mark_notification_read` → unread count `0`. As a **different** user, the notification is not
visible (tenancy).
7. **Retention.** Insert a read notification dated >90 days, run `PurgeOldReadNotifications` → it is gone;
an unread >90-day notification and a read <90-day notification both **remain**.
8. **Support alerts.** `RaiseSupportAlert("low_rating", "review", "42", review_id:42)` then
`GET get_support_alerts` (admin) shows it `open`; `POST assign_support_alert``assigned` with owner;
`POST resolve_support_alert``resolved` with note. Confirm it appears on **no** user-facing endpoint.
## 8. Hand off & document (close the phase)
- **Docs:** update the **Project map** in [`server/CLAUDE.md`](../../../server/CLAUDE.md) — the new feature
areas (`Configuration`/`Audit`/`Analytics`/`Holidays`/`Notifications`/`SupportAlerts`), the new
`ref`/`ops` schema, and the three new seams + where they register (`ServiceConfiguration/`). Add a short
note in `server/CONVENTIONS.md` if you established a reusable pattern (the typed config accessor, the
auditable-entity marker, the retention-job seam). If you discovered/decided any config-key default the
`product/` docs don't capture, record it in
[`product/data-model/12-audit-config-and-reference.md`](../../../product/data-model/12-audit-config-and-reference.md)
(don't invent rules — record decisions) and regenerate the HTML view per `product/CLAUDE.md`.
- **Contract:** write
[`../../contracts/domains/config-reference.md`](../../contracts/domains/config-reference.md) (use
[`_TEMPLATE.md`](../../contracts/domains/_TEMPLATE.md)) covering the admin config/holiday/audit/
support-alert endpoints and the current-user notification endpoints — request/response shapes, the
`data_json`/`changed_fields_json` payload contracts, enums (`data_type`, holiday `type`, alert
`type`/`status`/`severity`, notification `type`), pagination, status codes. Re-publish the `swagger.json`
snapshot per [`../../contracts/openapi/README.md`](../../contracts/openapi/README.md). Mark it **live as
of backend-phase-1**, frontend consumer **f14** (notification center) / **f15** (admin config/holidays/
audit/alerts).
- **Handoff & report:** write
`shared-working-context/backend/handoff/after-backend-phase-1.md` (the baseline migration is live; config
is read via `IPlatformConfig`; holidays via `IHolidayCalendar`; audit rows write automatically on
auditable entities; `INotificationDispatcher` now writes real in-app notifications; `RaiseSupportAlert`
exists for later domains to call — list the internal contracts b2…b15 should depend on, not re-create),
append to `shared-working-context/backend/STATUS.md`, write
`shared-working-context/reports/backend-phase-1-report.md` (what was built, what is now testable and
exactly how — §7, what is mocked + how to make it real, contracts produced, follow-ups: the FK
constraints for `support_alerts.booking_id`/`review_id` to be added in b9/b14), and update
`shared-working-context/reports/mocks-registry.md` (`IHolidayCalendar`, `IAnalyticsSink`, the retention
`IJobScheduler` → 🟡; flip `INotificationDispatcher` to in-app-real 🟡).
- **Memory:** save a `project` memory note for the non-obvious decisions this phase fixes (config-as-rows
read-at-compute-time + rate-snapshot rule; the auditable-entity marker + append-only enforcement; the
notification retention predicate; the support-alert internal-only boundary; the new `ref`/`ops` schema)
with a one-line `MEMORY.md` pointer.
+439
View File
@@ -0,0 +1,439 @@
# Backend Phase 10 — Payments core: ledger, transactions, webhooks & card capture
> **Mission:** stand up the **money core** — the append-only, double-entry **`ledger_entries`** that is
> the financial source of truth; **`payment_transactions`** (every attempt, with the two filtered-unique
> guards that make capture idempotent); **`payment_webhook_events`** (the at-least-once callback store whose
> `UNIQUE(provider_code, external_event_id)` is the single idempotency chokepoint); and **`payment_gateways`**
> (encrypted provider config for selection/failover). On top of these, build the card rail end-to-end:
> **InitiatePayment** against a `pending_payment` booking → a PSP webhook **confirms** it → the balanced
> **card-capture ledger group** posts (DEBIT `escrow_held` gross = CREDIT `platform_revenue` commission +
> `nurse_payable` payout) → the booking **converts/confirms** (the b9 `ConvertRequestToBooking`). Every
> mutation runs behind a **Redis `lock(booking:{id}:payment)`** with the DB constraints as the authoritative
> backstop. This is the foundation refunds (b11), BNPL (b12), and payouts (b13) all post against — get the
> idempotency and the balanced posting exactly right and the rest of the money path is safe.
>
> **Track:** backend · **Depends on:** [b9](./backend-phase-9.md) (bookings + the three-amount split + `ConvertRequestToBooking`), [b1](./backend-phase-1.md) (typed cached `platform_configs`), [b0](./backend-phase-0.md) (`IFieldEncryptor`, `ICacheService`, `IDateTimeProvider`, REST surface, audit interceptor) · **Unlocks:** refunds/invoices/clawbacks (b11), BNPL (b12), payouts (b13); frontend **f9-b10**
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
This is **backend phase b10**, the inbound money rail. Until now the platform could create a booking but
never take a Rial: [b9](./backend-phase-9.md) built `bookings` carrying the frozen three-amount split
(`gross_price_irr`, `balinyaar_commission_irr`, `nurse_payout_amount`, with the
`gross_price_irr = balinyaar_commission_irr + nurse_payout_amount` CHECK) plus `dispute_window_ends_at`, and
the `ConvertRequestToBooking` command that turns an `accepted_awaiting_payment` request into a money-bearing
booking **on capture** — that conversion is the hook this phase fires. This phase makes "the family pays the
gross price by card" real and lawful: Balinyaar is **merchant-of-record but never a cash custodian** (a
پرداخت‌یار may not hold deposits, run wallets, or move money between merchants), so "escrow" is modeled as an
**internal double-entry ledger STATE** over funds that legally sit at the licensed PSP/bank — never as
platform-held cash. The provider sits behind a **swappable seam** because Iranian provider cut-offs are real
(Toman/Jibit were abruptly suspended Nov 2024), and every callback is **idempotency-deduplicated before any
money state mutates** because PSP callbacks are at-least-once and retried.
**What already exists (do not rebuild) — built by prior phases:**
- **Bookings + the three-amount split + conversion** — [b9](./backend-phase-9.md) built `bookings`
(`gross_price_irr`, `balinyaar_commission_irr`, `platform_fee_rate`, `nurse_payout_amount`, the
`gross = commission + payout` CHECK, all amounts ≥ 0), the booking status machine
(`pending_payment``confirmed``in_progress``completed``disputed`/`closed`/`cancelled`),
`dispute_window_ends_at`, and the **`ConvertRequestToBooking`** command (creates the `bookings` row 1:1
from an `accepted_awaiting_payment` `booking_requests`, writes `variant_snapshot_json` + encrypted
`address_snapshot_json`, computes the three amounts). **This phase calls `ConvertRequestToBooking` on
successful capture — it does not re-implement booking creation or the amount math.** The CUT `payout_released`
BIT stays CUT — "paid" derives from the ledger + payout links, never a boolean.
- **Config (typed, cached)** — [b1](./backend-phase-1.md) built `platform_configs` + the **typed cached
config accessor**. Read `commission_rate`/`vat_rate`/`dispute_window_hours` and any gateway-selection
defaults **through that accessor** (cached), never hardcoded. (The amounts themselves are already frozen
on the booking by b9; this phase reads config only where it must, e.g. dispute-window seeding lives on the
booking already.)
- **Cross-cutting seams & plumbing** — [b0](./backend-phase-0.md) built the REST surface (`BaseController`,
`base.OperationResult(...)`, snake_case `[controller]`/`[action]` routing, rate limiting), CQRS via
**`martinothamar/Mediator`** (`ISender`/`ICommand`/`IQuery`, `internal sealed` handlers,
`OperationResult<T>` for expected failures), the audit-field SaveChanges interceptor, and the seams
**`IFieldEncryptor`** (encrypts `payment_gateways.config_json`), **`ICacheService`**, **`IDateTimeProvider`**
(stamps `created_at`/`received_at`/`processed_at`). Reuse all of these — do not redefine them.
- **The `IUnitOfWork`/`CommitAsync` pattern, FluentValidation `ValidateCommandBehavior`, Mapster, soft-delete
query filters, one `IEntityTypeConfiguration<T>` per entity** — established in b0/b1 and used by every phase
since. Mirror them exactly.
**What this phase introduces:** the four payments-core tables (`payment_gateways`, `payment_transactions`,
`payment_webhook_events`, `ledger_entries`) + their EF configs + **one migration**; the capabilities
`InitiatePayment`, `HandlePaymentWebhook`, `ConfirmPaymentAndPostLedger`, `GetNursePayableBalance`; the four
money-path seams **`IPaymentProvider`**, **`ISettlementSplitProvider`**, **`IWebhookVerifier`**,
**`IDistributedLock`** (with faithful mocks); and the public/webhook REST surface. Refunds, clawbacks,
invoices (b11), BNPL settle (b12), and payouts (b13) are **(DEFERRED)** here — see §3.6 — but the ledger,
the idempotency store, and the six `account_type`s they all post against are built here.
## 2. Required reading (do this first)
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md) — especially
*Performance/caching/money/idempotency*: **money is IRR `BIGINT`, no floats**; money-path writes are
**idempotent** (webhook dedup on the unique external-event key; filtered unique on succeeded transaction)
and **guarded by a Redis distributed lock with the DB constraint as the authoritative backstop**;
`ledger_entries` is **append-only and balanced** (Σdebit = Σcredit per `transaction_group_id`).
- [`../../../product/business/08-payments-and-escrow.md`](../../../product/business/08-payments-and-escrow.md) —
the inbound money path: card → PSP → Shaparak → registered IBANs; **escrow is a ledger state, not held
cash**; every callback idempotency-deduplicated before money moves; provider swappable by config.
- [`../../../product/payments/index.md`](../../../product/payments/index.md) and
[`../../../product/payments/escrow-ledger.md`](../../../product/payments/escrow-ledger.md) — **the canonical
ledger postings** (the six `account_type`s and the exact card-capture group: DEBIT `escrow_held` gross =
CREDIT `platform_revenue` commission + `nurse_payable` payout). Mirror the account names and posting
discipline **exactly**.
- [`../../../product/payments/iranian-payment-reality.md`](../../../product/payments/iranian-payment-reality.md) —
**why** the platform may not custody funds (§2.2 پرداخت‌یار custody prohibition), why تسهیم
(settlement-sharing) is the lawful split primitive (§2.3), why a held platform pool is **banned** (§2.4),
and why providers must be swappable (§2.5 Toman/Jibit cut-off). This is the legal shape your seams encode.
- [`../../../product/payments/integration-notes.md`](../../../product/payments/integration-notes.md) — the
per-provider verb sets and the server-side `verify` re-check rule (**never trust a callback alone**); the
"make it real" detail you record in the mock registry.
- [`../../../product/data-model/06-payments-ledger-and-refunds.md`](../../../product/data-model/06-payments-ledger-and-refunds.md) —
**the canonical schemas**: `payment_gateways`, `payment_transactions` (and its two **NEW** filtered uniques),
`payment_webhook_events` (field table + the `UNIQUE(provider_code, external_event_id)` idempotency key),
and `ledger_entries` (the field table, the `account_type` set, the canonical-postings table). Mirror field
names exactly. (`refunds`, `nurse_clawbacks`, `invoices` in this doc are **b11** — read for context only.)
- **Contract conventions:** [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
and [`money-and-types.md`](../../contracts/conventions/money-and-types.md) — **IRR `BIGINT` serialized as a
string of digits** on the wire, the envelope, the `payment`/`refund_channel` enum codes, Toman is
display-only and converted **only inside a provider adapter at its boundary**.
- **Code to mirror:** b9's `Features/Booking/**` (the `ConvertRequestToBooking` command + the booking status
machine you call/transition), b9's amount-bearing `bookings` config; b1's typed config accessor; b0's seam
registration (`ServiceConfiguration/` extension, config-selected impls) and the `IFieldEncryptor` usage on
encrypted columns. Mirror their `Features/<Area>/{Commands|Queries}/<Name>/` layout, `IEntityTypeConfiguration<T>`,
and the `IUnitOfWork`/single-`CommitAsync` pattern.
- **Prior handoffs:** `dev/shared-working-context/backend/handoff/after-backend-phase-9.md`, `…-1.md`, `…-0.md`,
and `reports/mocks-registry.md` (the `IFieldEncryptor`/`ICacheService`/`IDateTimeProvider` rows you reuse,
and the `IPaymentProvider`/`ISettlementSplitProvider`/`IWebhookVerifier`/`IDistributedLock` rows you flip to 🟡).
## 3. Scope — build this
All money is IRR `long` / `BIGINT`**no floats anywhere**. The payments features live under
`Baya.Application/Features/Payments/{Commands|Queries}/<Name>/`; the entities in
`Baya.Domain/Entities/Payments/`; one `IEntityTypeConfiguration<T>` per entity in
`Persistence/Configuration/PaymentsConfig/`; the four seams in `Application/Contracts/` with their mock
implementations in Infrastructure, **DI-registered via a `ServiceConfiguration/` extension** (config-selected
so a real adapter swaps in later); **one EF migration** for the four tables and their indexes.
### 3.1 Entities + migration
**`payment_gateways`** [CORE] — config per connected PSP/BNPL provider; selection/failover.
- Fields: `id` BIGINT PK; `provider_code` NVARCHAR(50) (`zarinpal` / `sadad` / `vandar` / `jibit` …);
`type` NVARCHAR(20) — **`standard`** (card IPG) / **`bnpl`** — *selects the flow*; `display_name`;
**`config_json` NVARCHAR(MAX) — ENCRYPTED via `IFieldEncryptor`** (merchant id, terminal/IBAN
registration for the تسهیم split, base_url, sandbox flag — **provider-selection / failover config, NEVER
per-transaction credentials**); `is_active` BIT; `priority` INT (failover order); soft-delete + audit.
- **`config_json` is encrypted at rest and never logged in plaintext.** Selection is config-driven: pick the
active `standard` gateway by `priority` so a cut-off provider is swapped **by config, not code change**.
**`payment_transactions`** [CORE] — every payment attempt against a booking; the `succeeded` row triggers
confirmation; stores the full `gateway_response_json` and the **Shaparak `gateway_reference_code`** (definitive
proof for reconciliation/chargebacks).
- Fields (mirror [`product/data-model/06`](../../../product/data-model/06-payments-ledger-and-refunds.md)):
`id` BIGINT PK; `booking_id` BIGINT FK → `bookings`; `customer_id` BIGINT FK; `gateway_id` BIGINT FK →
`payment_gateways`; **`amount` BIGINT (IRR)**; `currency` NVARCHAR (always `IRR` internally);
`status` NVARCHAR(20) — `pending` / `succeeded` / `failed`; `gateway_transaction_id`;
**`gateway_reference_code`** NVARCHAR NULL; `gateway_response_code`; `gateway_response_json` NVARCHAR(MAX);
`is_installment` BIT; `ip_address`; `user_agent`; soft-delete + audit timestamps.
- **The two structural idempotency guards (NEW — do not drop):**
- **filtered `UNIQUE(gateway_reference_code) WHERE gateway_reference_code IS NOT NULL`** — Shaparak ref dedupe.
- **filtered `UNIQUE(booking_id) WHERE status = 'succeeded'`** — **at most one capturing transaction per
booking**; this is the authoritative anti-double-capture backstop.
- Secondary index on `(booking_id, status)` for the lookup in capture/initiate.
**`payment_webhook_events`** [CORE] — raw, deduplicated store of every PSP/BNPL callback; the **idempotency
chokepoint**.
- Fields: `id` BIGINT PK; **`provider_code` NVARCHAR(50)**; **`external_event_id` NVARCHAR(200)**;
`event_type` NVARCHAR(80); `signature_valid` BIT; `payload_json` NVARCHAR(MAX) (raw callback);
`processing_status` NVARCHAR(20) — `received` / `processed` / `failed` / `ignored`;
`related_payment_transaction_id` BIGINT NULL; `received_at`, `processed_at` DATETIME2.
- **`UNIQUE(provider_code, external_event_id)`** — the idempotency key. The handler **inserts/upserts here
first** and **no-ops on a duplicate**, inside the same transaction that mutates payment state (§3.3).
**`ledger_entries`** [CORE] — the append-only, double-entry financial **source of truth**. Every money event
posts **balanced** rows sharing a `transaction_group_id` (Σdebit = Σcredit per group).
- Fields (mirror [`product/data-model/06`](../../../product/data-model/06-payments-ledger-and-refunds.md)):
`id` BIGINT PK; **`transaction_group_id` UNIQUEIDENTIFIER** (groups the balanced legs of one event);
**`account_type` NVARCHAR(40)** — the closed set: **`escrow_held` / `platform_revenue` / `nurse_payable` /
`refund_payable` / `bnpl_fee_expense` / `nurse_clawback_receivable`** (define all six now even though this
phase only posts the first three — b11/b12/b13 post the rest; the data-model doc also lists `psp_fee_expense`/
`bad_debt`, include them if present in the canonical schema you mirror); `nurse_id` BIGINT FK NULL (set for
`nurse_payable` / `nurse_clawback_receivable`); **`direction` NVARCHAR(6)** — `debit` / `credit`;
**`amount_irr` BIGINT — always positive; `direction` carries the sign**; `booking_id` BIGINT FK NULL;
`source_ref_type` NVARCHAR(40) (`payment_transaction` / `refund` / `nurse_payout` / `bnpl_transaction` /
`clawback`); `source_ref_id` BIGINT; `memo` NVARCHAR(300) NULL; **`created_at` DATETIME2 — append-only,
never updated**.
- **No soft-delete, no audit-modified columns, no UPDATE/DELETE path** — `ledger_entries` is append-only;
corrections are **new balancing rows**. Do not configure a `ModifiedAt`/`IsDeleted` flow on this entity;
it is insert-only by design (mark the entity so the audit interceptor never stamps a modify on it).
- Indexes: `(account_type, nurse_id)` (for `GetNursePayableBalance` and later balance reads),
`transaction_group_id` (to read a posting group), `(source_ref_type, source_ref_id)`, `booking_id`.
> **Build-order rule (from the payments digest):** the **ledger + webhook idempotency** come first; the
> provider adapters plug into the seams only after that foundation exists. Get the table shapes and the two
> filtered uniques right before writing a single capture.
### 3.2 `InitiatePayment` (start a card attempt)
**`InitiatePaymentCommand(bookingId)`** [CORE] — creates a `pending` `payment_transactions` row against a
booking in `pending_payment`, selects the active `standard` gateway (by `payment_gateways.type='standard'`,
active, lowest `priority`), and calls the provider to start the IPG session.
- Validates the booking exists and is `pending_payment` (tenancy: the caller is the booking's customer); the
payment deadline (`payment_deadline_at` from the originating request, b8/b9) has **not** lapsed.
- Reads `amount` = the booking's `gross_price_irr` (already frozen by b9 — **never recompute it here**).
- Calls **`IPaymentProvider.InitPaymentAsync(bookingId, amountIrr, idempotencyKey, ct)`** → returns the
redirect URL + a deterministic `gatewayReferenceCode`; persists the `pending` `payment_transactions` row
(with `gateway_reference_code`, honouring the filtered unique) and returns the redirect/token to the client.
- Route: **`POST api/v1/bookings/{bookingId}/payments`** (authenticated; **rate-limited** as a money endpoint;
carries an **idempotency key**). Returns the redirect URL + the transaction id.
- Validator (FluentValidation): `bookingId` present; resolves to a `pending_payment` booking owned by the caller.
- **Idempotency:** a repeat InitiatePayment for a booking that already has a `succeeded` transaction returns a
`409` (the booking is already paid) — do not create a second attempt; the filtered `UNIQUE(booking_id) WHERE
status='succeeded'` is the backstop.
### 3.3 `HandlePaymentWebhook` (the idempotent callback ingest)
**`HandlePaymentWebhookCommand(provider, headers, rawBody)`** [CORE] — the verify-then-dedup-then-mutate path
for every inbound PSP callback.
- Route: **`POST api/v1/webhooks/payments/{provider}`** (no user auth — authenticated by **signature**;
rate-limited; tolerant of at-least-once retries by design).
- Steps, **all inside one DB transaction** (single `CommitAsync`):
1. **Verify** the callback via **`IWebhookVerifier.Verify(provider, headers, rawBody)`** →
`(signatureValid, externalEventId, eventType, parsedPayload)`. If the signature is invalid, store the
event with `signature_valid=0`, `processing_status='ignored'`, and stop (never mutate money on an
unverified callback).
2. **Upsert `payment_webhook_events` FIRST** keyed on **`(provider_code, external_event_id)`**. If the row
already exists (duplicate replay), **no-op**: mark/leave `processing_status` and return success **without
mutating any payment or ledger state**. This is the idempotency guarantee — a replayed `succeeded` must
never double-confirm and a replayed `settled` must never double-count.
3. On a **new** event whose `event_type` indicates success, **re-verify server-side** (the integration-notes
rule — never trust the callback alone): call **`IPaymentProvider.VerifyAsync(gatewayReferenceCode,
expectedAmountIrr, ct)`** to re-check the amount and reference against the stored `pending` transaction,
then dispatch **`ConfirmPaymentAndPostLedger`** (§3.4).
4. Set `processing_status='processed'`, `processed_at`, and `related_payment_transaction_id`.
- **The whole thing is wrapped in a Redis `lock(booking:{id}:payment)`** via **`IDistributedLock`** so a fast
double-callback and a user retry don't both start money mutation; the DB uniques are the authoritative
backstop if the lock is lost/expired or Redis is down.
### 3.4 `ConfirmPaymentAndPostLedger` (capture → ledger → convert booking)
**`ConfirmPaymentAndPostLedgerCommand(paymentTransactionId)`** [CORE] — flips the transaction to `succeeded`
under the filtered-unique guard, posts the **card-capture ledger group**, and triggers booking conversion.
- Steps (inside the same transaction/lock from §3.3):
1. Mark the `payment_transactions` row **`status='succeeded'`** — the filtered `UNIQUE(booking_id) WHERE
status='succeeded'` makes a second succeeded row impossible (a concurrent double-confirm fails on the
constraint, which the handler treats as "already captured → no-op success").
2. Post the **card-capture group** to `ledger_entries` under one fresh `transaction_group_id`, reading the
booking's three frozen amounts:
```
DEBIT escrow_held gross_price_irr
CREDIT platform_revenue balinyaar_commission_irr
CREDIT nurse_payable nurse_payout_amount (nurse_id set; = gross balinyaar_commission)
```
**The group must balance: Σdebit (gross) = Σcredit (commission + payout).** `amount_irr` is positive on
every row; `direction` carries the sign. `source_ref_type='payment_transaction'`,
`source_ref_id=paymentTransactionId`, `booking_id` set, `created_at` from `IDateTimeProvider`.
3. **Register the تسهیم split** via **`ISettlementSplitProvider.RegisterSplitAsync(bookingId, legs, ct)`**
where `legs = [(nurseSheba, nurse_payout_amount, "nurse"), (platformSheba, balinyaar_commission_irr,
"platform")]` — the lawful split-by-ratio to registered IBANs (the provider credits each IBAN directly;
Balinyaar never moves the money). The mock accepts any legs whose sum = gross and returns `Settled`.
4. **Trigger `ConvertRequestToBooking`** (from [b9](./backend-phase-9.md)) — *or*, if the booking row was
already created at request-conversion time per b9's design, transition it `pending_payment → confirmed`.
Follow whichever b9 actually did; **do not duplicate the conversion/amount logic** — call b9's command.
- This command is **never a public endpoint** — it is dispatched only from `HandlePaymentWebhook` (and, in
tests, directly). The webhook is the only public confirm path.
### 3.5 `GetNursePayableBalance` (derived, never stored)
**`GetNursePayableBalanceQuery(nurseId)`** [CORE] — sums `ledger_entries WHERE account_type='nurse_payable'
AND nurse_id=@nurseId`, **signed by `direction`** (credit adds, debit subtracts), to the IRR `BIGINT` balance
currently owed the nurse. **Pure projection over the ledger** — `AsNoTracking()`, a single aggregate query,
**no cached wallet column ever**. This is what b13 (payouts) reads to know what to pay, so it must be the
ledger truth, not a status flag.
- Route: **`GET api/v1/nurses/{nurseId}/payable_balance`** (authorized: the nurse themself or admin).
- (Optionally also expose `GetEscrowHeldQuery` / `GetCommissionIncomeQuery` as the same shape over their
account types — thin admin reads; build `nurse_payable` now, the others are trivial siblings.)
### 3.6 DEFERRED (do not build; leave the account type / seam / pointer)
- **Refunds, clawbacks, invoices** — `refunds` (1:N, fee/payout decomposition, `refund_channel`),
`nurse_clawbacks`, the refund/clawback ledger postings, and `invoices` (VAT on commission) are owned by
**[b11](./backend-phase-11.md)**. This phase **defines the `refund_payable` and `nurse_clawback_receivable`
account types** in the ledger so b11 just posts against them, and exposes
**`IPaymentProvider.RefundAsync`** in the seam (mock returns `Succeeded`) so b11 can call it — but builds no
refund table or flow. (DEFERRED → b11.)
- **BNPL settle** — the `bnpl_transactions` table, the **BNPL-settle ledger group** (card-capture legs **plus**
DEBIT `bnpl_fee_expense` / CREDIT `escrow_held` so escrow reflects net cash), and the `IBnplProvider` seam
are owned by **[b12](./backend-phase-12.md)**. This phase defines `bnpl_fee_expense` and routes BNPL callbacks
through the **same** `payment_webhook_events` idempotency store. (DEFERRED → b12.)
- **Payouts** — `nurse_payout_batches` / `nurse_payouts` / `nurse_payout_booking_links` and the payout ledger
movement (DEBIT `nurse_payable` / CREDIT `escrow_held`) are owned by **[b13](./backend-phase-13.md)**, gated
on the dispute window. `GetNursePayableBalance` (built here) is what it reads. (DEFERRED → b13.)
- **Real provider adapters** (ZarinPal/Sadad/Vandar/Jibit card + تسهیم; real signature/HMAC verification;
real Redis lock) — **mock now behind the seams**, recorded in the registry with the make-real steps. (DEFERRED.)
## 4. Mocks & seams in this phase
This phase **introduces** four money-path seams. Each is an **Application interface** with a faithful
Infrastructure **mock**, DI-registered via a `ServiceConfiguration/` extension (config-selected — **never an
`if (mock)` branch in a handler**). All amounts crossing these interfaces are **IRR `BIGINT`**; Toman
conversion happens **only inside the real adapter at the provider boundary**, never internally.
| Seam | Owner | Mock behaviour | Registry |
| --- | --- | --- | --- |
| **`IPaymentProvider`** | **introduced here** | `InitPaymentAsync` → **deterministic `gatewayReferenceCode`** + a fake redirect URL; `VerifyAsync` → **instant `Succeeded`, echoes the amount** (re-checks reference); `RefundAsync` → always `Succeeded` (for b11 to call). **No external call.** | **add a new row** (🟡) |
| **`ISettlementSplitProvider`** | **introduced here** (تسهیم) | `RegisterSplitAsync` → **accepts any legs whose sum = gross**, returns `Registered` then **instant `Settled`**; `GetSplitStatusAsync` → `Settled`. The platform never moves money — the mock just records the split intent. | **add a new row** (🟡) |
| **`IWebhookVerifier`** | **introduced here** | `Verify` → **`signatureValid=true`**, extracts a test `externalEventId` + `eventType` from the body. Lets tests replay duplicate webhooks to prove idempotency. | **add a new row** (🟡) |
| **`IDistributedLock`** | **introduced here** | **no-op / in-process** lock (a process-local semaphore keyed by the lock string) so the money-path code runs the same shape it will with real Redis. **The DB unique/state-machine is the authoritative backstop** — never rely on the lock alone. | **add a new row** (🟡) |
| `IFieldEncryptor` | reuse from **b0** | encrypts/decrypts `payment_gateways.config_json`; never logs plaintext. | reuse row |
| `ICacheService` | reuse from **b0** | typed config accessor (b1) reads `commission_rate`/`vat_rate` through it. | reuse row |
| `IDateTimeProvider` | reuse from **b0** | stamps `created_at`/`received_at`/`processed_at` (deterministic in tests). | reuse row |
Append the four new rows to
[`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md)
(seam, file, what's faked, config keys, **step-by-step how to make it real**): for `IPaymentProvider` —
ZarinPal/Sadad/Vandar/Jibit as acquirer-with-تسهیم, merchant id + terminal/IBAN registration, Shaparak
`gateway_reference_code`, persist the full gateway response, golden-tier eligibility; for
`ISettlementSplitProvider` — each beneficiary's registered Sheba, split-by-ratio config, min-amount caveat
(~100,000 IRR), provider credits IBANs directly; for `IWebhookVerifier` — per-provider HMAC/signature scheme
(or, where none exists, the mandatory server-side `verify` re-check of amount + reference); for
`IDistributedLock` — StackExchange.Redis with a lease/expiry, key conventions `booking:{id}:payment`. A
`IProviderRegistry`/config-driven factory selects the concrete provider per `payment_gateways.config_json` so a
cut-off provider is swapped without code change.
## 5. Critical rules you must not get wrong
- **Money is IRR `BIGINT`, no floats anywhere** — not in the DB, not in a handler, not on the wire. Toman
conversion happens **only inside a provider adapter at its boundary**; the seam interfaces and the ledger
speak IRR Rials only. Never introduce a `decimal`/`double` on the money path.
- **Idempotency: always upsert `payment_webhook_events` on `(provider_code, external_event_id)` FIRST and
no-op on duplicate** — inside the same DB transaction that mutates payment state — so a replayed
`succeeded` never double-confirms and a replayed `settled` never double-counts. This dedup is the single
chokepoint for every PSP/BNPL replay; do it before any money state changes.
- **Escrow IS the ledger** — never infer money state from status booleans or add money columns to "track" a
balance. `ledger_entries` is the single source of truth; every money event posts **balanced** rows; balances
are **derived by filter**, never stored in a drifting column. (The `payout_released` BIT stayed CUT in b9 for
exactly this reason.)
- **The card-capture posting is balanced:** **DEBIT `escrow_held` gross = CREDIT `platform_revenue` commission
+ `nurse_payable` payout**, all under one `transaction_group_id`, `amount_irr` positive with `direction`
carrying the sign, **Σdebit = Σcredit**. The three amounts are never conflated and come **frozen from the
booking** (b9) — never recomputed here.
- **`ledger_entries` is append-only** — never `UPDATE` or `DELETE` a ledger row; corrections are **new
balancing rows**, never edits. Configure the entity so the audit interceptor never stamps a modify and
there is no soft-delete path.
- **The filtered `UNIQUE(booking_id) WHERE status='succeeded'` is the structural anti-double-capture guard —
do not drop it.** It (and the `UNIQUE(gateway_reference_code)`) is what makes a retried success webhook
unable to create a second capture even if the lock is lost. Treat a unique-violation on confirm as
"already captured → idempotent no-op success", not an error to surface.
- **The Redis lock is the fast first line; the DB constraint is the authoritative backstop.** Wrap
capture/verify in `lock(booking:{id}:payment)` via `IDistributedLock`, but **never rely on the lock alone**
for correctness — if Redis is down or the lease expires, the DB uniques must still prevent a double-capture.
- **Escrow is a ledger state, not platform cash — never model a held pool.** A پرداخت‌یار may not hold
deposits, run wallets, or move money between merchants. The lawful split is **تسهیم via
`ISettlementSplitProvider`** to registered IBANs (the provider credits each directly); the ledger only
**mirrors** money that legally sits at the provider/bank. Do not design "collect into a platform pool, hold
until EVV, redistribute" — it is banned.
- **Provider swappable by config.** Handlers depend on `IPaymentProvider`/`IWebhookVerifier`/
`ISettlementSplitProvider`, never on a concrete client; selection is by `payment_gateways` config. The
ledger must survive a provider cut-off mid-cycle (Toman/Jibit Nov-2024 precedent).
- **`payment_gateways.config_json` is encrypted and is provider-selection/failover config — never
per-transaction credentials**, and never logged in plaintext (`IFieldEncryptor`).
- **Never trust a callback alone** — on a success event, re-verify server-side via
`IPaymentProvider.VerifyAsync` (amount + reference) before confirming. An unverified-signature callback
mutates nothing.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] The four tables (`payment_gateways`, `payment_transactions`, `payment_webhook_events`, `ledger_entries`)
exist via **one migration** with their `IEntityTypeConfiguration<T>`s: the **two filtered uniques** on
`payment_transactions` (`gateway_reference_code` WHERE NOT NULL; `booking_id` WHERE status='succeeded'),
the **`UNIQUE(provider_code, external_event_id)`** on `payment_webhook_events`, the six (or eight)
`account_type`s and the append-only (no soft-delete/no-modify) config on `ledger_entries`, and the
`config_json` encryption on `payment_gateways`.
- [ ] `InitiatePayment`, `HandlePaymentWebhook`, `ConfirmPaymentAndPostLedger`, and `GetNursePayableBalance`
are implemented per §3, behind the four seams, with FluentValidation on the input-bearing commands and
`AsNoTracking()` + `.Select(...)` projection on the balance query.
- [ ] The webhook handler **upserts `payment_webhook_events` first and no-ops on duplicate**, inside one
transaction wrapped in `IDistributedLock(booking:{id}:payment)`; the card-capture ledger group is
**balanced** (Σdebit = Σcredit) and triggers b9's `ConvertRequestToBooking`/`pending_payment→confirmed`.
- [ ] **`IPaymentProvider`, `ISettlementSplitProvider`, `IWebhookVerifier`, `IDistributedLock`** are
introduced as Application interfaces with Infrastructure mocks, **DI-registered via a
`ServiceConfiguration/` extension** (config-selected; no `if (mock)` in handlers).
- [ ] Handler/unit tests (NSubstitute): the card-capture group **balances** and posts the three correct legs;
a **replayed webhook event is a no-op** (no second confirm, no second ledger group); a **second
`succeeded` transaction for a booking is blocked** by the filtered unique; `GetNursePayableBalance`
equals the signed ledger sum; an **unverified-signature** callback mutates nothing. ≥1
`WebApplicationFactory` integration test for `POST api/v1/bookings/{id}/payments` (happy path, 401,
validation 400) and the webhook ingest (happy + duplicate-replay). `dotnet build Baya.sln` zero new
warnings; `dotnet test Baya.sln` green (a reachable SQL Server is required — the filtered uniques are
the test's whole point).
- [ ] The **Project map** in `server/CLAUDE.md` reflects the `Features/Payments/**` area, the four tables, and
the four new seams + where they're registered.
- [ ] The contract `dev/contracts/domains/payments.md` is written and the `swagger.json` snapshot republished.
## 7. How to test (what a human can verify after this phase)
Seed (or reuse from b9): one active **`standard`** `payment_gateways` row; a `bookings` row in
`pending_payment` for a known customer + nurse, with `gross_price_irr` = `balinyaar_commission_irr` +
`nurse_payout_amount` (e.g. gross `23300000`, commission `3495000`, payout `19805000` — adjust to your
config's commission rate). Configure the mock `IWebhookVerifier`/`IPaymentProvider`.
1. **Initiate a payment** — `POST api/v1/bookings/{bookingId}/payments` (as the customer) → `200` with a
redirect URL + a `pending` `payment_transactions` row carrying the mock's deterministic
`gateway_reference_code`. (No ledger rows yet, booking still `pending_payment`.)
2. **A webhook confirms it** — `POST api/v1/webhooks/payments/{provider}` with a `succeeded` event for that
reference → the transaction flips to `succeeded`; **one balanced ledger group** appears (DEBIT
`escrow_held` `23300000` = CREDIT `platform_revenue` `3495000` + `nurse_payable` `19805000`); the
**booking converts/confirms** (`pending_payment → confirmed`, b9). Verify Σdebit = Σcredit for the group.
3. **Replaying the same webhook event is a no-op** — POST the **same** `external_event_id` again → `200`, but
**no second confirm and no second ledger group** (the `payment_webhook_events` upsert short-circuits).
Query the ledger: still exactly one capture group; `payment_webhook_events` still one row.
4. **`GetNursePayableBalance` reflects the accrual** — `GET api/v1/nurses/{nurseId}/payable_balance` →
`19805000` (the credited `nurse_payable`, signed by direction). It is computed from the ledger, not a column.
5. **A second `succeeded` transaction for the same booking is blocked** — attempt to confirm a *different*
transaction for the same booking (or initiate again after capture) → blocked by the filtered
`UNIQUE(booking_id) WHERE status='succeeded'` (`409`/idempotent no-op), never a second capture.
6. **Unverified callback mutates nothing** — POST a webhook the mock verifier marks `signature_valid=false` →
stored with `processing_status='ignored'`, **no transaction flip, no ledger rows**.
7. **Encrypted gateway config** — inspect `payment_gateways.config_json` in the DB → ciphertext, not plaintext;
the active `standard` gateway is selected by `type` + `priority`.
## 8. Hand off & document (close the phase)
- **Docs to update:** the **Project map** in [`server/CLAUDE.md`](../../../server/CLAUDE.md) (add the
`Features/Payments/**` area, the four payments-core tables, the **append-only `ledger_entries`** note, and
the four new seams + where they're registered). If you decide/confirm a rule the `product/` docs don't yet
capture (e.g. the exact "upsert webhook event first, then re-verify server-side, then confirm" ordering, or
treating a unique-violation on confirm as an idempotent no-op), record it in
[`../../../product/business/08-payments-and-escrow.md`](../../../product/business/08-payments-and-escrow.md)
or [`../../../product/payments/escrow-ledger.md`](../../../product/payments/escrow-ledger.md) — don't invent
rules. Note the new `IPaymentProvider`/`IWebhookVerifier`/`ISettlementSplitProvider`/`IDistributedLock`
pattern in `server/CONVENTIONS.md` if it establishes a reusable money-path shape (lock-then-DB-constraint).
- **Contract to write:** **`dev/contracts/domains/payments.md`** (per
[`../../contracts/domains/_TEMPLATE.md`](../../contracts/domains/_TEMPLATE.md)) — document
`POST api/v1/bookings/{bookingId}/payments` (auth, **idempotency key**, rate-limited; request/redirect
response), `POST api/v1/webhooks/payments/{provider}` (signature auth, at-least-once/idempotent, the
`processing_status` enum), `GET api/v1/nurses/{nurseId}/payable_balance` (derived IRR `BIGINT` balance,
authorization); the `payment` status enum (`pending`/`succeeded`/`failed`), the `account_type` set, the
`gateway.type` enum (`standard`/`bnpl`); state that **money is IRR `BIGINT` serialized as a string of
digits**, that the **card-capture ledger group is balanced**, and that **internal account types are never
exposed to the customer** (the checkout UI shows gross + the commission/VAT breakdown only). Republish the
`swagger.json` snapshot per [`../../contracts/openapi/README.md`](../../contracts/openapi/README.md). This is
what **f9-b10** consumes (Summary & pay (C6), card payment redirect, confirmation).
- **Handoff & report:** write `dev/shared-working-context/backend/handoff/after-backend-phase-10.md` (the money
core is live — initiate → webhook confirm → balanced capture → booking confirm; what **f9** can now build —
checkout summary with commission/VAT/escrow notice (C6), card payment via the mock redirect, the
succeeded/confirmed state; which endpoints/contract are live; that the PSP/تسهیم/webhook-verify/lock are
**mocked behind seams**; that refunds (b11), BNPL settle (b12), and payouts (b13) post against this ledger
next). Append to `backend/STATUS.md`, write `dev/shared-working-context/reports/backend-phase-10-report.md`
(what was built, **what is now testable and exactly how** per §7, what is mocked + how to make it real, the
`account_type`s reserved for b11b13, contracts produced, follow-ups), and update
`dev/shared-working-context/reports/mocks-registry.md` (the four new rows → 🟡).
- **Memory:** save a `project` memory note for the non-obvious decisions this phase fixes — the
**upsert-webhook-event-first-then-no-op** idempotency ordering; the **two filtered uniques** on
`payment_transactions` as the anti-double-capture backstop; the **balanced card-capture posting** (DEBIT
`escrow_held` gross = CREDIT `platform_revenue` + `nurse_payable`) and the six `account_type`s; the
**append-only, derive-balances-by-filter** ledger discipline; the **lock-first / DB-constraint-backstop**
pattern via `IDistributedLock`; and the four money-path seams (PSP / تسهیم / webhook-verify / lock,
mock-now/real-later) — with a one-line pointer in `MEMORY.md`.
+405
View File
@@ -0,0 +1,405 @@
# Backend Phase 11 — Refunds, invoices & nurse clawbacks
> **Mission:** make money flow *backwards* correctly. Build the admin-only refund engine that reverses a
> captured booking payment across **both fee legs** (platform commission vs nurse payout), posts the
> balanced reversal into the append-only ledger, and forks hard on one question — *has the nurse already
> been paid?* Pre-payout it is a clean `nurse_payable` reversal; post-payout it opens a first-class
> **`nurse_clawbacks`** receivable, because an Iranian IBAN transfer is effectively irreversible. Same
> phase adds the minimal **`invoices`** record (VAT on the commission line, sequential number, optional
> مودیان submission behind a seam). Refunds are admin-initiated, ticket-linked, channel-aware (card vs
> BNPL revert), and never customer self-service. After this phase, a cancellation can actually return
> money and the books stay balanced.
>
> **Track:** backend · **Depends on:** [b10](./backend-phase-10.md) (ledger / transactions / webhook idempotency / capture), [b9](./backend-phase-9.md) (cancellation policies / bookings / dispute window), [b1](./backend-phase-1.md) (VAT config / typed config accessor) · **Unlocks:** payout clawback netting ([b13](./backend-phase-13.md)); frontend **f10-b11**
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
This is **backend phase b11**, the reversal leg of the payments arc (b10 ledger/capture → **b11
refunds·invoices·clawbacks** → b12 BNPL → b13 payouts). The platform never custodies cash: "escrow" is an
internal **double-entry ledger state** ([`product/payments/escrow-ledger.md`](../../../product/payments/escrow-ledger.md)),
and a booking's money already sits posted as `escrow_held` / `platform_revenue` / `nurse_payable` from
b10's capture. A refund un-does some or all of that. The hard problem is **timing**: if the nurse has not
yet been paid (the common case, because b13 gates payout on `dispute_window_ends_at`), the refund simply
reverses the `nurse_payable` accrual — nothing leaves Balinyaar. If the nurse *has* been paid, the money
is already gone to an irreversible bank transfer, so the refund becomes platform-funded and opens a
**clawback receivable** the next payout batch nets out. This phase also issues the minimal commission
**invoice** with config-driven VAT, because Iranian commission marketplaces owe VAT on *their commission*
(the Snapp/Tapsi precedent), not on the nurse's earnings.
**What this phase does *not* do:** it does **not** build the cancellation policy resolver or the
`CancelBooking` flow (that is b9 — this phase *consumes* the resolved policy snapshot); it does **not**
build the card/BNPL provider adapters (b10/b12 — this phase *calls* their refund/revert methods through
seams); it does **not** net or recover clawbacks into a payout (that is b13 — this phase only *opens* the
receivable + posts its ledger leg).
**What already exists (do not rebuild) — built by prior phases:**
- **The ledger, transactions & webhook idempotency** — [b10](./backend-phase-10.md) built
`ledger_entries` (append-only, balanced, `transaction_group_id`, the account types incl.
`escrow_held`, `platform_revenue`, `nurse_payable`, `refund_payable`, `nurse_clawback_receivable`),
`payment_transactions` (the `succeeded` capturing row, filtered `UNIQUE(booking_id) WHERE
status='succeeded'`, `UNIQUE(gateway_reference_code)`), `payment_webhook_events`
(`UNIQUE(provider_code, external_event_id)`), the **card-capture posting** (`DEBIT escrow_held` gross /
`CREDIT platform_revenue` commission + `nurse_payable` payout), the **ledger posting helper**, the
`IPaymentProvider` seam (incl. `RefundAsync`), the `IWebhookVerifier` seam, and the `IDistributedLock`
Redis-lock pattern on the money path. **Reuse the ledger posting helper, the webhook idempotency path,
the `IPaymentProvider` seam, and the lock — do not re-implement them.**
- **Bookings, cancellation policies & the dispute window** — [b9](./backend-phase-9.md) built `bookings`
(the three-amount split `gross_price_irr = balinyaar_commission_irr + nurse_payout_amount`,
`platform_fee_rate` snapshot, `dispute_window_ends_at`), `booking_sessions` (`visit_payout_amount`,
`payout_eligible_at`, `cancellation_event_id`), `cancellation_policies` (config-driven tiers by lead
time × actor, `code`, `is_active`), and the `CancelBooking` / `CancelSession` commands that **resolve
the applicable policy and snapshot `cancellation_policy_code` + the resolved refund percentage** onto
the cancellation event. **This phase reads that resolved policy snapshot to populate the refund's
`cancellation_policy_code` / `refund_percentage_applied`; it does not re-resolve policy from live
config.**
- **VAT config & the typed config accessor** — [b1](./backend-phase-1.md)'s `platform_configs` table with
a typed, cached accessor (behind `ICacheService`). The `vat_rate` key (default `0.10`) and any
refund-ETA config are read **through that accessor**, never hardcoded. b1 also built `notifications` +
the `INotificationDispatcher` real in-app write, and `support_alerts`.
- **The b0 foundation:** the REST surface, `BaseController`, `OperationResult<T>`, CQRS via
**`martinothamar/Mediator`** (`ISender`/`ICommand`/`IQuery`, `internal sealed` handlers),
`IFieldEncryptor`, `ICurrentUser` + audit interceptor, rate limiting, `IDateTimeProvider`,
`IObjectStorage` (for the invoice PDF key, optional), and the mock-report discipline.
**What this phase introduces:** the three tables (`refunds`, `nurse_clawbacks`, `invoices`), the
refund/clawback/invoice capabilities, and **one new seam — `IMoadianClient`** (the mocked سامانه مودیان
e-invoicing rail). The BNPL revert path *targets* the `IBnplProvider.RevertAsync` seam introduced in
**b12**; until b12 lands, the `bnpl_revert` channel is exercised through the same ledger legs with the
BNPL provider call behind its seam (see §3.6 + §4).
> **Forward dependency (tickets):** refunds **must** link a `ticket_id`, but the `tickets` table arrives
> in [b15](./backend-phase-15.md). Make `refunds.ticket_id` a **nullable FK now** with the column +
> index in place, enforce "ticket required" as a **validator/handler rule that is config-gated off until
> b15** (so admin refunds are testable today), and note the forward-dep in the report. b15 wires the
> real FK target and flips the rule on. **Do not invent a `tickets` table in this phase.**
## 2. Required reading (do this first)
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md) —
especially the *Performance, caching, money, idempotency* block (IRR `BIGINT`, append-only balanced
ledger, idempotent money writes, Redis lock on the money path, config read through the typed accessor).
- [`product/business/07-cancellation-and-refunds.md`](../../../product/business/07-cancellation-and-refunds.md) —
**the business rules**: tiered/snapshotted policy, **admin-only + ticket-linked** refunds, fee-leg
decomposition, per-session (un-started only), pre- vs post-payout fork, BNPL-via-provider-revert-only,
MVP vs DEFERRED (automated nurse-no-show penalty is a manual admin action; self-service partial-refund
UI and holiday overrides are DEFERRED).
- [`product/payments/cancellation-and-payout.md`](../../../product/payments/cancellation-and-payout.md) —
**Q1 the BNPL refund unwind**: money *always* flows `customer ↔ provider ↔ Balinyaar`, never direct;
`revert` (full) vs `update` (partial, strictly-lower amount); the async **710 business-day** customer
window surfaced as `expected_customer_refund_eta`; `refund_status = processing` until reconciled; the
**nullable** `provider_commission_reversed_amount` (do not hardcode whether the provider returns its
commission); the same fee-leg decomposition applies.
- [`product/data-model/06-payments-ledger-and-refunds.md`](../../../product/data-model/06-payments-ledger-and-refunds.md) —
**the canonical schema** for `refunds` (the new 1:N cardinality, `platform_fee_refunded_irr` /
`nurse_payout_refunded_irr`, `refund_channel`, `external_revert_reference`,
`expected_customer_refund_eta`, `cancellation_policy_code` / `refund_percentage_applied`),
`nurse_clawbacks` (`status`, `original_payout_id`, `recovered_in_payout_id`), `invoices`
(`invoice_number` UNIQUE, `platform_commission_irr` the VAT-relevant line, `vat_rate`/`vat_irr`,
`moadian_reference_number`/`moadian_status`), and the **canonical postings** table. Mirror these field
names exactly.
- [`product/payments/escrow-ledger.md`](../../../product/payments/escrow-ledger.md) — the **refund and
clawback postings** in depth (pre-payout reversal; the `refund_payable``escrow_held` confirm step;
the clawback receivable leg).
- **Code to mirror:** b10's ledger posting helper + `IDistributedLock` usage + `payment_webhook_events`
idempotency + the `IPaymentProvider`/`IWebhookVerifier` seams + the `Features/Payments/**` command
structure; b9's `bookings`/`cancellation_policies` configs and the policy-snapshot fields; b1's typed
config accessor and `INotificationDispatcher`; b0's `IFieldEncryptor`/`IObjectStorage` + seam
registration via `ServiceConfiguration/` extensions.
- **Contract conventions:** [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
and [`money-and-types.md`](../../contracts/conventions/money-and-types.md) (IRR `BIGINT`, money as a
digit-string on the wire, the `refund_channel` enum, masking, the envelope).
- **Prior handoffs:** `dev/shared-working-context/backend/handoff/after-backend-phase-10.md`, `…-9.md`,
`…-1.md`, and `reports/mocks-registry.md` (seam rows you reuse / the one you add).
## 3. Scope — build this
All money is IRR `long` / `BIGINT` — no floats anywhere. Features live under
`Baya.Application/Features/Refunds/{Commands|Queries}/<Name>/` (refunds + clawbacks) and
`Baya.Application/Features/Invoices/{Commands|Queries}/<Name>/`; entities in
`Baya.Domain/Entities/Refunds/` and `…/Invoices/`; one `IEntityTypeConfiguration<T>` per entity in
`Persistence/Configuration/RefundsConfig/` and `…/InvoicesConfig/`; one EF migration for the three tables.
### 3.1 Entities + migration
**`refunds`** [CORE] — admin-initiated, ticket-linked, **1:N per `payment_transaction`**, fee-leg
decomposed, channel-aware.
- Fields (baseline + new, mirror [data-model 06](../../../product/data-model/06-payments-ledger-and-refunds.md)):
`id`, `payment_transaction_id` (FK `payment_transactions`), `booking_id` (FK `bookings`),
`requested_by_customer_id` (FK `customer_profiles` — the customer the refund is *for*, not the actor),
`ticket_id` (**FK NULLABLE — forward-dep on `tickets` in b15**, see §1 callout), `amount` (BIGINT, the
total refunded = fee leg + payout leg), `refund_percentage` (resolved %), `reason_category`,
`reason_notes`, `status`, approval/rejection fields (`approved_by_admin_id`, `rejected_reason`),
`gateway_refund_reference` (the PSP card-refund ref), `processed_at` (nullable), `admin_notes`, audit
fields; **plus the new decomposition/channel fields:**
- `platform_fee_refunded_irr` (BIGINT) — the portion of `balinyaar_commission_irr` being reversed.
- `nurse_payout_refunded_irr` (BIGINT) — the portion of `nurse_payout_amount` being reversed (drives a
clawback if the nurse was already paid).
- `refund_channel` (enum) — `psp_card` | `bnpl_revert` | `manual` (the data-model also writes
`manual_bank`; **use `manual` as the canonical wire code** per
[`money-and-types.md`](../../contracts/conventions/money-and-types.md), and document the mapping).
- `external_revert_reference` (NVARCHAR(200) NULL) — the BNPL provider revert id.
- `expected_customer_refund_eta` (DATE NULL) — the ~710 business-day BNPL window, surfaced in
UI/reconciliation; null for instant card refunds.
- `cancellation_policy_code` (NVARCHAR NULL) + `refund_percentage_applied` (DECIMAL NULL) — **snapshot**
of the policy that produced this refund (read from b9's cancellation event; never re-resolved live).
- **`refund_status`** enum (`status`): `requested` | `approved` | `processing` | `succeeded` | `failed` |
`rejected`. (`processing` is the state a BNPL revert sits in until the reconciliation job confirms the
customer cash-back.)
- **Cardinality / invariant:** **1:N** per `payment_transaction`. The app invariant
**`Σ refunded ≤ captured`** is enforced **in the handler** (sum of prior succeeded/processing refund
`amount` for the transaction + this one ≤ the captured `payment_transactions.amount`) — it is *not* a
single-row DB CHECK. Likewise `amount = platform_fee_refunded_irr + nurse_payout_refunded_irr` (handler
invariant + a CHECK where SQL Server allows).
- Relations: N:1 → `payment_transactions`, `bookings`, `customer_profiles`, `tickets` (nullable); 1:1 →
`nurse_clawbacks` (only when refunding a booking whose nurse was already paid).
**`nurse_clawbacks`** [CORE] — first-class receivable when a booking is refunded/disputed **after** the
nurse was already paid.
- Fields (mirror [data-model 06](../../../product/data-model/06-payments-ledger-and-refunds.md)): `id`,
`nurse_id` (FK `nurse_profiles`), `booking_id` (FK `bookings`), `refund_id` (FK `refunds`),
`original_payout_id` (FK `nurse_payouts` **NULL**`nurse_payouts` arrives in b13, so this FK is
nullable now and the column/index are in place; the *value* is set once b13 exists),
`amount_irr` (BIGINT — equals the `nurse_payout_refunded_irr` leg), `status`, `recovered_in_payout_id`
(FK `nurse_payouts` **NULL** — set by **b13** when a batch nets it; this phase only ever leaves it
null/`pending`), `created_at`, `resolved_at` (nullable), audit fields.
- **`clawback_status`** enum (`status`): `pending` | `recovered` | `written_off`. **This phase only ever
creates rows in `pending`** (and supports an admin `write_off`); **`recovered` is set by b13's payout
netting — do not implement recovery here.**
- Relations: N:1 → `nurse_profiles`, `bookings`; 1:1 → `refunds`; → `nurse_payouts` (original +
recovering, both nullable until b13).
**`invoices`** [MVP] — minimal official receipt per booking; **VAT on the commission line only**.
- Fields (mirror [data-model 06](../../../product/data-model/06-payments-ledger-and-refunds.md)): `id`,
`booking_id` (FK `bookings`), `invoice_number` (NVARCHAR(40) **UNIQUE** — official, **sequential**),
`issuing_entity_type` (`platform` | `partner_center`), `gross_irr` (BIGINT),
`platform_commission_irr` (BIGINT — **the VAT-relevant line**), `bnpl_commission_irr` (BIGINT NULL),
`vat_rate` (DECIMAL(5,4) — read from config, default `0.10`), `vat_irr` (BIGINT — computed
`round(platform_commission_irr * vat_rate)`, integer-only), `moadian_reference_number` (NVARCHAR(40)
NULL — the 22-digit سامانه مودیان ref when issued), `moadian_status` (NVARCHAR(20) NULL —
`pending` | `submitted` | `registered` | `failed`), `pdf_storage_key` (NVARCHAR(512) NULL — an
`IObjectStorage` key), `issued_at` (DATETIME2), audit fields.
- **`invoice_number` is UNIQUE and sequential** — generate it from a gap-free, concurrency-safe sequence
(a dedicated DB sequence / a locked counter row), **never** a random or timestamp-derived value. Relate
1:1 → `bookings`; N:1 → `partner_centers` (when the issuer is a partner center).
### 3.2 Commands & queries (CQRS, `OperationResult`, never throw for expected failures)
| Capability | Type | Route (admin/customer) | What it does |
| --- | --- | --- | --- |
| **`CreateRefundCommand`** | Command | `POST api/v1/admin_refunds` | Admin-only. Validates the booking has a captured (`succeeded`) `payment_transaction`; **requires a `ticket_id`** (config-gated off until b15, see §1); reads the **resolved cancellation policy snapshot** from b9's cancellation event for the booking to populate `cancellation_policy_code` / `refund_percentage_applied`; **decomposes** the refund into `platform_fee_refunded_irr` + `nurse_payout_refunded_irr` (pro-rata of the booking's `balinyaar_commission_irr` / `nurse_payout_amount` at the resolved %, or admin-supplied legs that must still sum to `amount`); enforces **`Σ refunded ≤ captured`**; picks `refund_channel` from the original payment type (`psp_card` for card, `bnpl_revert` for BNPL, `manual` for an out-of-band bank refund); creates the `refunds` row in `requested`/`approved`. Under `lock(booking:{id}:refund)`. Then dispatches the channel execution + ledger posting (below). |
| **`ExecuteRefundChannelCommand`** | Command (internal step) | — | Calls the channel: **card**`IPaymentProvider.RefundAsync(gatewayReferenceCode, amountIrr, idempotencyKey, ct)` (channel `psp_card`), storing `gateway_refund_reference`, status → `succeeded` (card refunds are effectively immediate, `expected_customer_refund_eta = null`); **BNPL**`IBnplProvider.RevertAsync(...)` (b12 seam; **full** = `revert`, **partial/shortened** = `update` with a strictly-lower amount), storing `external_revert_reference`, setting `expected_customer_refund_eta = now + config(bnpl_refund_eta_business_days, 10)` (business-day shifted), status stays **`processing`** until the reconciliation job/webhook confirms cash-back; **manual** → records the admin-entered bank ref, status `processing`/`succeeded` per admin. **Carries an `idempotencyKey`** so a retried call never double-refunds. |
| **`PostRefundLedgerCommand`** | Command (internal step) | — | **Pre-payout path** (nurse not yet paid): posts the balanced reversal in one `transaction_group_id``DEBIT platform_revenue platform_fee_refunded_irr` + `DEBIT nurse_payable nurse_payout_refunded_irr`, `CREDIT refund_payable (sum)`. When the provider confirms the customer cash-back (card immediately; BNPL via reconciliation), a **second** balanced posting **clears `refund_payable` ↔ `escrow_held`** (`DEBIT refund_payable` / `CREDIT escrow_held`). Uses b10's posting helper; append-only; Σdebit = Σcredit. |
| **`CreateClawbackCommand`** | Command (internal step) | — | **Post-payout path** (nurse already paid — detected via b13's `nurse_payout_booking_links` for the booking, or, until b13 exists, a config/flag indicating the booking was paid): instead of debiting `nurse_payable`, posts `DEBIT nurse_clawback_receivable nurse_payout_refunded_irr` (+ the `DEBIT platform_revenue` fee leg) / `CREDIT refund_payable`, and **creates a `nurse_clawbacks` row in `pending`** (`nurse_id`, `booking_id`, `refund_id`, `amount_irr = nurse_payout_refunded_irr`, `original_payout_id` when available). Raises a `support_alert` (b1) on every clawback. **Does not net or recover it — that is b13.** |
| **`WriteOffClawbackCommand`** | Command | `POST api/v1/admin_clawbacks/{id}/write_off` | Admin marks a `pending` clawback `written_off` (uncollectable) with a reason; posts the balancing ledger correction (`DEBIT bad_debt` / `CREDIT nurse_clawback_receivable`) and sets `resolved_at`. (Recovery via payout netting is b13.) |
| **`IssueInvoiceCommand`** | Command | `POST api/v1/admin_invoices` (and reused on confirmation) | Creates an `invoices` row for a booking: **sequential `invoice_number`** from the safe sequence; copies `gross_irr` / `platform_commission_irr` / `bnpl_commission_irr` from the booking; reads **`vat_rate` from config** (default `0.10`); computes `vat_irr = round(platform_commission_irr * vat_rate)` (integer-only, VAT **on the commission only**, never the nurse's earnings — set `vat_irr = 0` when a medical-service exemption sets `vat_rate = 0`); attempts **`IMoadianClient.SubmitAsync`** which (mock) returns no ref → `moadian_reference_number = null`, `moadian_status = pending`. Idempotent per booking (one issued invoice per booking; re-issue returns the existing). |
| **`ListRefundsQuery`** | Query | `GET api/v1/admin_refunds?booking_id=&status=&page=&page_size=` | Admin refund worklist: projected (AsNoTracking + `.Select`) + paginated; surfaces channel, decomposed legs, status, `expected_customer_refund_eta`, the policy snapshot. |
| **`GetRefundStatusQuery`** | Query | `GET api/v1/refunds/{id}/status` (customer-visible, tenancy-scoped) | The customer-facing status of *their* refund: `status`, `refund_channel`, `amount`, and **`expected_customer_refund_eta`** (the BNPL 710-business-day window) — so f10 can show "on its way, ~N days". Tenancy-scoped to the booking's customer via `ICurrentUser`. |
| **`GetInvoiceQuery`** | Query | `GET api/v1/invoices/{booking_id}` (customer/admin) | The booking's invoice: `invoice_number`, `gross_irr`, `platform_commission_irr`, `vat_rate`, `vat_irr`, `moadian_status`, and a `pdf_storage_key`-derived download URL when present. Tenancy-scoped. |
- **Cancellation integration (b9 → refund):** b9's `CancelBooking` / `CancelSession` resolves the policy
and computes the refundable amount per un-started session. **This phase exposes `CreateRefundCommand` as
the money-side of that flow** — b9 (or admin) calls it with the booking/session, the resolved %, and the
ticket. Do **not** duplicate the policy resolver; consume its snapshot.
- **Controllers:** `AdminRefundsController` (admin policy; refund endpoints **rate-limited**
refund-sensitive per `CONVENTIONS.md` §11), `AdminClawbacksController` (admin policy),
`AdminInvoicesController` (admin policy), and a customer-facing `RefundsController` / `InvoicesController`
(authenticated, tenancy-scoped) for `GetRefundStatusQuery` / `GetInvoiceQuery`. All `sealed :
BaseController`, inject `ISender`, return `base.OperationResult(...)`, snake_case `[controller]` /
`[action]` routes, `CancellationToken` threaded.
- **Validators:** FluentValidation on `CreateRefundCommand` (positive `amount`; legs sum to `amount`;
`amount > 0`; `ticket_id` required when the gate is on; channel matches the transaction type) and the
id-bearing commands.
### 3.3 DEFERRED (build the seam/flag, not the feature)
- **Clawback *recovery / netting* into a payout** — DEFERRED to [b13](./backend-phase-13.md). This phase
only opens the `pending` receivable + supports `write_off`. Leave `recovered_in_payout_id` /
`original_payout_id` as the (nullable) join points b13 fills.
- **Automated nurse no-show penalty / forfeiture** — a **manual admin action** at launch per
[`product/business/07-cancellation-and-refunds.md`](../../../product/business/07-cancellation-and-refunds.md) §(c);
do not automate. The admin uses `CreateRefundCommand` (full customer refund) and records the nurse
penalty manually.
- **Self-service partial-refund UI** and **holiday-specific cancellation overrides** — DEFERRED (no
customer refund-initiation path; the policy override model is out of scope).
- **Real مودیان automation** — DEFERRED; the seam returns a null/pending ref now (see §4). The
reconciliation job that flips `moadian_status` to `registered` and the BNPL-revert reconciliation job
that clears `refund_payable ↔ escrow_held` are **thin/manual-trigger** now; note the cron in the report.
## 4. Mocks & seams in this phase
| Seam | Owner | Mock behaviour | Registry |
| --- | --- | --- | --- |
| **`IMoadianClient`** | **introduced here** | `SubmitAsync(InvoiceSubmission, ct)` → leaves `moadian_reference_number = null`, returns `moadian_status = pending` (no external call). A config switch can force a deterministic `registered` (with a fake 22-digit ref) so the reconciliation/`registered` path is testable. The real سامانه مودیان adapter is a drop-in. | **add a new row** (🟡) |
| `IPaymentProvider` | reuse from **b10** | `RefundAsync(gatewayReferenceCode, amountIrr, idempotencyKey, ct)` → deterministic `gateway_refund_reference`, instant `Succeeded`, echoes amount; channel `psp_card`. | reuse row |
| `IBnplProvider` | reuse from **b12** | `RevertAsync` / `UpdateAsync` → echoes the reverted/new amount, returns an `external_revert_reference` + nullable `provider_commission_reversed_amount`, `settledAt`-style lag; channel `bnpl_revert`. **Until b12 lands**, register a thin local mock behind this interface so the `bnpl_revert` path is exercised; b12 owns the real seam definition. | reuse row (note pre-b12) |
| `IWebhookVerifier` | reuse from **b10** | verifies the async BNPL cash-back/reconciliation callback that flips a `processing` refund to `succeeded` and posts the `refund_payable ↔ escrow_held` clearing leg. | reuse row |
| `IDistributedLock` | reuse from **b10** | in-memory mock lock; `lock(booking:{id}:refund)` around the whole refund money-path so a cancellation-driven and a webhook-driven refund can't both fire (keeps `Σ refunded ≤ captured`). | reuse row |
| `IFieldEncryptor` | reuse from **b0** | local symmetric key; never logs plaintext. | reuse row |
| `INotificationDispatcher` | reuse from **b1** | in-app write; notifies the customer on refund issued/completed; raises a `support_alert` on every clawback. | reuse row |
| `ICacheService` | reuse from **b0/b1** | in-memory; behind the typed config accessor (`vat_rate`, ETA config). | reuse row |
| `IObjectStorage` | reuse from **b0** | local-disk/in-memory; stores the optional invoice `pdf_storage_key`. | reuse row |
The mock lives behind a **DI-registered interface** in Infrastructure (real impl is a drop-in later);
provider/مودیان selection is **config-driven, never** an `if (mock)` branch in a handler. Append the
`IMoadianClient` row to
[`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md)
(seam, file, what's faked, config keys, **step-by-step how to make it real** — سامانه مودیان enrollment,
the معاملات/invoice submission API, the 22-digit reference shape, the `pending → submitted → registered`
reconciliation callback). Confirm the BNPL `IBnplProvider` row notes the pre-b12 local stub if you add one.
## 5. Critical rules you must not get wrong
**Money correctness is sacred — the following must hold verbatim:**
- **Money is IRR `BIGINT`, no floats, ever.** Every amount (`amount`, `platform_fee_refunded_irr`,
`nurse_payout_refunded_irr`, `amount_irr`, `gross_irr`, `platform_commission_irr`, `vat_irr`) is
`long`/`BIGINT`. VAT is `round(platform_commission_irr * vat_rate)` computed integer-only; **no float
path**. Toman conversion happens only inside a provider adapter at its boundary.
- **Gross = commission + payout.** A refund **decomposes across both fee legs**
`amount = platform_fee_refunded_irr + nurse_payout_refunded_irr`, derived pro-rata from the booking's
`balinyaar_commission_irr` / `nurse_payout_amount` at the resolved %. The two legs are never conflated
and must always sum to the refunded `amount`.
- **`Σ refunded ≤ captured` (handler invariant).** Refunds are **1:N** per `payment_transaction`; the sum
of all succeeded/processing refunds for a transaction may never exceed the captured amount. Enforce in
the handler under `lock(booking:{id}:refund)`; the lock is the fast first line, the summed check is the
authoritative backstop.
- **Append-only, balanced ledger.** Every refund/clawback posts balanced legs (Σdebit = Σcredit) under one
`transaction_group_id`, via b10's helper. **Never UPDATE/DELETE a ledger row;** corrections (e.g. a
write-off) are *new* balancing postings. Balances are derived by filtering `ledger_entries`, never a
stored column.
- **Refund-before-payout is a clean reversal; refund-after-payout drives a `nurse_clawbacks` receivable.**
Pre-payout: `DEBIT platform_revenue` + `DEBIT nurse_payable` / `CREDIT refund_payable`. Post-payout:
`DEBIT nurse_clawback_receivable` (+ `DEBIT platform_revenue`) / `CREDIT refund_payable` **and** a
`pending` `nurse_clawbacks` row — **because an Iranian IBAN transfer is irreversible**, so the money is
already gone and must be recorded as owed-back, never silently absorbed. **Gate payout on
`dispute_window_ends_at`** (b9/b13) so the pre-payout path is the common one; the clawback is the
fallback, not the plan.
- **Refunds are admin-only (no customer self-service) and must link a `ticket_id`.** There is no
customer refund-initiation path — only `GetRefundStatusQuery` is customer-visible. The `ticket_id`
requirement is enforced (config-gated until b15 ships `tickets`); the FK is nullable now only for that
forward-dep.
- **VAT applies to the platform COMMISSION only — never the nurse's earnings.** `vat_irr` is computed on
`platform_commission_irr` with a **config-driven rate (default `0.10`)**; the nurse is the taxable
seller of the care service (Snapp/Tapsi precedent). A `vat_rate = 0` exemption sets `vat_irr = 0`.
Never apply VAT to `nurse_payout_amount`.
- **`invoice_number` is unique + sequential.** Generate gap-free from a concurrency-safe sequence/locked
counter — never random or timestamp-derived. One issued invoice per booking (idempotent).
- **Card refund and `bnpl_revert` post the SAME ledger legs.** The only differences are `refund_channel`,
the external reference (`gateway_refund_reference` vs `external_revert_reference`), and the ETA
(`expected_customer_refund_eta` null for card vs ~710 business days for BNPL, with `status =
processing` until reconciled). Do not branch the *ledger* on channel — only the execution + metadata.
- **BNPL refunds go through the provider revert API only.** Money *always* flows
`customer ↔ provider ↔ Balinyaar`**never** nurse→customer or Balinyaar→customer direct. Full =
`revert`, partial/shortened = `update` (strictly-lower amount). The provider's own commission reversal
is `provider_commission_reversed_amount`**nullable, reconciled from the response, never hardcoded.**
- **Idempotency on the money path.** Channel calls carry an `idempotencyKey`; the async cash-back
confirmation flows through `payment_webhook_events` (`UNIQUE(provider_code, external_event_id)`) so a
replayed "refunded"/"reverted" callback can't double-clear `refund_payable` or double-post. The refund
`status` state machine (`requested → approved → processing → succeeded|failed`) is forward-only.
- **Tenancy & scope.** `GetRefundStatusQuery` / `GetInvoiceQuery` are scoped to the booking's customer via
`ICurrentUser`; a customer can never read another customer's refund/invoice. All create/write endpoints
sit behind the **admin** policy and are rate-limited.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] The three tables (`refunds`, `nurse_clawbacks`, `invoices`) exist via one migration, each with its
`IEntityTypeConfiguration<T>`: `refunds` with the **nullable `ticket_id` FK**, the decomposition
columns, `refund_channel`, the `amount = fee_leg + payout_leg` CHECK where possible; `nurse_clawbacks`
with nullable `original_payout_id` / `recovered_in_payout_id`; `invoices` with **`invoice_number`
UNIQUE** + the sequential generator and `vat_irr` on the commission line; soft-delete/audit wiring.
- [ ] All §3.2 commands/queries implemented (CQRS, `OperationResult`, projected + paginated reads,
validators), with the admin + customer controllers.
- [ ] **`IMoadianClient`** introduced (Application interface, Infrastructure mock, DI registration via a
`ServiceConfiguration/` extension, config-selected). No `if (mock)` in handlers. `IBnplProvider`
reused (with a noted pre-b12 local stub if b12 isn't merged), `IPaymentProvider`/`IWebhookVerifier`/
`IDistributedLock`/`INotificationDispatcher` reused.
- [ ] Refund decomposition + `Σ refunded ≤ captured` correct; the **pre-payout reversal** and the
**post-payout clawback** both post balanced ledger groups; the `refund_payable ↔ escrow_held`
clearing posts on confirm; the invoice computes `vat_irr` from config on the commission with a
sequential number.
- [ ] Handler unit tests (NSubstitute) for: pre-payout balanced reversal; partial-refund leg decomposition
+ `Σ refunded ≤ captured` rejection; post-payout clawback creation + receivable leg; invoice VAT
computed from config + sequential numbering; channel parity (card vs bnpl_revert same legs). ≥1
`WebApplicationFactory` integration test per controller (happy path, 401, validation 400, 409 on
over-refund). `dotnet build Baya.sln` zero new warnings; `dotnet test Baya.sln` green.
- [ ] The `Baya.Application/Features/Refunds/**` + `…/Invoices/**` areas are reflected in the **Project
map** in `server/CLAUDE.md`; the `IMoadianClient` seam noted where seams are documented; the
`tickets` forward-dep and the `manual`/`manual_bank` channel-code decision recorded.
- [ ] The contract `dev/contracts/domains/refunds-invoices.md` written and the `swagger.json` snapshot
republished.
## 7. How to test (what a human can verify after this phase)
Seed (or reuse from prior phases) a booking with a **captured card payment** (b10) and a resolved
cancellation policy snapshot (b9), plus one booking flagged as **already-paid-to-nurse** and one **BNPL**
booking.
1. **Pre-payout full refund (clean reversal)**`POST api/v1/admin_refunds` for the captured card
booking with the resolved % and a ticket → a `refunds` row with `refund_channel = psp_card`, decomposed
`platform_fee_refunded_irr` + `nurse_payout_refunded_irr` summing to `amount`; the **ledger** shows a
balanced `DEBIT platform_revenue` + `DEBIT nurse_payable` / `CREDIT refund_payable`, and on confirm a
`DEBIT refund_payable` / `CREDIT escrow_held` clearing leg (Σdebit = Σcredit); status → `succeeded`.
2. **Partial refund + over-refund guard** — issue a **partial** refund (e.g. 50%): legs decompose
correctly and sum to the partial `amount`; `Σ refunded` for the transaction stays ≤ captured. Then
attempt a second refund that would push the total **over** the captured amount → rejected with `409`
(or validation `400`); no ledger posting occurs.
3. **Issue an invoice**`POST api/v1/admin_invoices` for the booking → an `invoices` row with a
**sequential `invoice_number`**, `vat_irr = round(platform_commission_irr * 0.10)` (verify it is
computed from config on the **commission**, not the nurse payout, and `vat_irr = 0` when `vat_rate` is
set to 0); `moadian_reference_number = null`, `moadian_status = pending`. Issue a second invoice for a
second booking → the number is the **next** in sequence (gap-free, unique).
4. **Refund on an already-paid booking (clawback)**`POST api/v1/admin_refunds` for the
already-paid-to-nurse booking → instead of debiting `nurse_payable`, the ledger posts `DEBIT
nurse_clawback_receivable` (+ `DEBIT platform_revenue`) / `CREDIT refund_payable`, **a `nurse_clawbacks`
row is created in `pending`** (`amount_irr = nurse_payout_refunded_irr`), and a `support_alert` is
raised. Confirm it is **not** auto-recovered (recovery is b13).
5. **BNPL revert (channel parity + ETA)**`POST api/v1/admin_refunds` for the BNPL booking →
`refund_channel = bnpl_revert`, `IBnplProvider.RevertAsync` called, `external_revert_reference` stored,
`expected_customer_refund_eta` ≈ now + 10 business days, status `processing`; the **ledger legs are
identical** to the card case. `GET api/v1/refunds/{id}/status` as the customer shows the ETA window.
6. **Write-off**`POST api/v1/admin_clawbacks/{id}/write_off` → the `pending` clawback → `written_off`
with a balancing `DEBIT bad_debt` / `CREDIT nurse_clawback_receivable` and `resolved_at` set.
7. **Admin worklist + tenancy**`GET api/v1/admin_refunds?status=processing` lists channel/legs/ETA;
`GET api/v1/refunds/{id}/status` as a **different** customer is **not** visible (403/404).
## 8. Hand off & document (close the phase)
- **Docs to update:** the **Project map** in [`server/CLAUDE.md`](../../../server/CLAUDE.md) (add the
`Features/Refunds/**` + `Features/Invoices/**` areas + the `IMoadianClient` seam). If you discover/confirm
a rule the product docs don't capture — e.g. the canonical `manual` vs `manual_bank` channel code, the
`bnpl_refund_eta_business_days` default, the `vat_rate = 0` exemption behaviour, or the
`ticket_id`-config-gate until b15 — record it in
[`product/business/07-cancellation-and-refunds.md`](../../../product/business/07-cancellation-and-refunds.md)
/ [`product/data-model/06-payments-ledger-and-refunds.md`](../../../product/data-model/06-payments-ledger-and-refunds.md)
(and regenerate the HTML view per `product/CLAUDE.md`). **Don't invent rules.**
- **Contract to write:** **`dev/contracts/domains/refunds-invoices.md`** (per
[`../../contracts/domains/_TEMPLATE.md`](../../contracts/domains/_TEMPLATE.md)) — the admin refund/
clawback/invoice endpoints (create refund, write-off clawback, issue invoice, list refunds) and the
customer-facing `refunds/{id}/status` + `invoices/{booking_id}`; the `refund_status` /
`refund_channel` / `clawback_status` / `moadian_status` enums; the refund/invoice DTO shapes (IRR
`BIGINT` as digit-strings, the decomposed legs, **masked** references, `expected_customer_refund_eta`);
auth/rate-limit/idempotency notes; the admin-only + ticket-link + dispute-window/clawback side-effects.
Republish the `swagger.json` snapshot per
[`../../contracts/openapi/README.md`](../../contracts/openapi/README.md). This is what **f10-b11**
consumes.
- **Handoff & report:** write `dev/shared-working-context/backend/handoff/after-backend-phase-11.md` (the
refund/clawback/invoice engine is live, what f10 can now build — admin refund console + the
customer-facing cancellation/refund-status + invoice views — which endpoints/contracts are live, that
مودیان is mocked behind `IMoadianClient`, that clawback *recovery* waits on b13 and `tickets` on b15),
append to `backend/STATUS.md`, write
`dev/shared-working-context/reports/backend-phase-11-report.md` (what was built, **what is now testable
and exactly how** per §7, what is mocked + how to make it real, contracts produced, follow-ups: the
مودیان reconciliation cron, the BNPL-revert reconciliation cron clearing `refund_payable ↔ escrow_held`,
clawback netting in b13, the `tickets` FK wire-up in b15), and update
`dev/shared-working-context/reports/mocks-registry.md` (the `IMoadianClient` row → 🟡; reconfirm the
reused seam rows).
- **Memory:** save a `project` memory note for the non-obvious decisions this phase fixes — the
fee-leg/payout-leg decomposition, the **pre-payout reversal vs post-payout clawback fork** (and *why*
Iranian transfers are irreversible), the `refund_payable ↔ escrow_held` two-step clearing, channel
parity (card vs bnpl_revert post the same legs), VAT-on-commission-only with a config rate, the
sequential `invoice_number` generator, and the `tickets`/`nurse_payouts` forward-dep nullable FKs — with
a one-line pointer in `MEMORY.md`.
+361
View File
@@ -0,0 +1,361 @@
# Backend Phase 12 — BNPL: provider-financed installments (mocked)
> **Mission:** let a family pay for a booking with a provider-financed BNPL plan (SnappPay / Digipay /
> Tara / Torob Pay) — and record it correctly. The decisive, verified truth is that an Iranian BNPL order
> **settles the full booking amount to Balinyaar in one inbound lump, net of the provider's merchant
> commission**, and the provider owns the customer's installments and **100% of default risk**. So in our
> books a BNPL order is **a card payment that lands net-of-fee**: one `bnpl_transactions` row (1:1 with its
> `payment_transaction`) that drives an idempotent `eligible → token_issued → verified → settled` state
> machine, a settle that posts the **card-capture ledger legs plus a `bnpl_fee_expense` leg** so escrow
> reflects the *net* cash actually received, and a provider-mediated revert path. We **do not** model the
> customer's repayment schedule or default — that subsystem was deleted. The nurse's payout is **invariant
> to payment method**.
>
> **Track:** backend · **Depends on:** [b10](./backend-phase-10.md) (`payment_transactions`, `ledger_entries`, `payment_webhook_events`, the card-capture posting, `IWebhookVerifier`, `IDistributedLock`), [b11](./backend-phase-11.md) (`refunds` 1:N, fee/payout decomposition, `refund_channel`) · **Unlocks:** BNPL checkout; frontend **f11-b12**
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
This is **backend phase b12**, the third leg of the payments arc (b10 ledger/txn/webhook/capture → b11
refunds/clawbacks/invoices → **b12 BNPL** → b13 payouts). The platform never custodies cash: "escrow" is
an internal **double-entry ledger state** ([`product/payments/escrow-ledger.md`](../../../product/payments/escrow-ledger.md)),
and BNPL is **not a new money model** — it collapses to the existing inbound-capture rail with one extra
fact: the cash that lands is **net of the provider's merchant discount**. This phase records that single
inbound settlement, the provider's commission (a **platform expense**, never the nurse's), and the
provider-mediated reversal — nothing about the customer's 4-installment repayment, which the provider owns
end to end.
**What already exists (do not rebuild) — built by prior phases:**
- **The ledger, transactions & webhook idempotency** — [b10](./backend-phase-10.md) built
`ledger_entries` (append-only, balanced, `transaction_group_id`, the six `account_type`s incl.
`escrow_held`, `platform_revenue`, `nurse_payable`, `refund_payable`, **`bnpl_fee_expense`**,
`nurse_clawback_receivable`), `payment_transactions` (filtered `UNIQUE(gateway_reference_code) WHERE NOT
NULL` and `UNIQUE(booking_id) WHERE status='succeeded'`), **`payment_webhook_events`**
(`UNIQUE(provider_code, external_event_id)` — the idempotency anchor), the **card-capture ledger
posting** (`DEBIT escrow_held` gross / `CREDIT platform_revenue` commission + `CREDIT nurse_payable`
payout), the **`IWebhookVerifier`** seam, and the **`IDistributedLock`** Redis-lock pattern on the money
path (`lock(booking:{id}:payment)`, `lock(booking:{id}:refund)`). **Reuse the ledger posting helper, the
webhook-event dedup, the lock, and `IWebhookVerifier` — do not re-implement any of them.**
- **The card-capture posting structure** — b10's `ConfirmPaymentAndPostLedger` posts the card-capture
group. **The BNPL settle is that same group PLUS a `bnpl_fee_expense` leg** — extend/reuse the helper,
do not fork it.
- **Refunds** — [b11](./backend-phase-11.md) built `refunds` (1:N per `payment_transaction`, fee-leg vs
payout-leg decomposition, `refund_channel``psp_card`|`bnpl_revert`|`manual`,
`external_revert_reference`, `expected_customer_refund_eta`, ticket-linked, admin-only) and the
refund ledger posting. **The BNPL revert path creates a `refund` row with `refund_channel='bnpl_revert'`
and posts the refund ledger legs via b11's helper** — it does not redefine refunds.
- **Bookings & the three-amount split** — [b9](./backend-phase-9.md)'s `bookings` carry
`gross_price_irr = balinyaar_commission_irr + nurse_payout_amount` and `platform_fee_rate`. **The BNPL
`order_amount_irr` is the booking's `gross_price_irr`**; the nurse's payout is computed from the
booking split, never from `settled_amount_irr`.
- **`payment_gateways`** — [b10](./backend-phase-10.md)'s per-provider config (encrypted `config_json`,
`type` selects flow). BNPL providers are rows with `type='bnpl'`; provider selection is config-driven.
- **The platform config accessor** — [b1](./backend-phase-1.md)'s typed, cached `platform_configs`
reader. Read the mock commission %, settlement-timing class, and currency through it; **never hardcode**.
- The b0 foundation: REST surface, `BaseController`, `OperationResult<T>`, CQRS via
**`martinothamar/Mediator`**, `IFieldEncryptor`, `ICurrentUser` + audit interceptor, rate limiting,
`IDateTimeProvider`, `ICacheService`.
**What this phase introduces:** the `bnpl_transactions` table + its status state machine, the
eligibility/initiate/verify/settle/revert/callback/status capabilities, and **two new seams —
`IBnplProvider`** (the mocked provider, one impl per `provider_code`) and **`ICurrencyNormalizer`**
(Toman→IRR at the boundary). `bnpl_settlement_entries` (tranched settlement) is **DEFERRED** — do not
build it.
## 2. Required reading (do this first)
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md) —
especially the *Performance, caching, money, idempotency* block (IRR `BIGINT`, append-only balanced
ledger, idempotent money writes, webhook dedup, Redis lock on the money path).
- [`product/business/09-installments-bnpl.md`](../../../product/business/09-installments-bnpl.md) —
**the business rules**: full-upfront provider-financed settlement; a BNPL order is a card payment that
lands net-of-fee; **do not track customer installments / per-installment webhooks / default
propagation**; refunds flow **only** customer ↔ provider ↔ Balinyaar; the nurse's payout is **unchanged
by BNPL**; MVP vs DEFERRED (no in-house credit, single provider, no tranched settlement).
- [`product/payments/bnpl-landscape.md`](../../../product/payments/bnpl-landscape.md) — **the provider
mechanics**: the SnappPay verb set (eligibility → token → verify → settle → revert/cancel/update),
commission-as-config (anecdotal 715%; Torob Pay's published 6.6%; **read the actual deducted amount
from the settlement, never hardcode**), **settlement timing is NOT instant** (daily/T+13/weekly/15-day,
per-transaction `settled_at`), Toman↔Rial conversion at the boundary, and the async ~710-business-day
customer refund window.
- [`product/data-model/08-bnpl.md`](../../../product/data-model/08-bnpl.md) — **the canonical schema** for
`bnpl_transactions` (every column + the state machine) and the `bnpl_settlement_entries` DEFERRED note.
Mirror these field names exactly.
- [`product/payments/escrow-ledger.md`](../../../product/payments/escrow-ledger.md) — the **BNPL-settle
ledger posting** (card-capture legs PLUS `DEBIT bnpl_fee_expense` / `CREDIT escrow_held` for the
commission, so escrow reflects net cash) and the refund/revert legs.
- **Code to mirror:** b10's `Features/Payments/**` command structure, the `ConfirmPaymentAndPostLedger`
ledger helper, the `payment_webhook_events` upsert-first-then-mutate idempotency pattern, the
`IWebhookVerifier` usage, and the `IDistributedLock` lock helper; b11's `Features/Refunds/**`,
`refund_channel`, and the refund ledger posting; b9's booking three-amount split; b1's typed config
accessor.
- **Contract conventions:** [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
and [`money-and-types.md`](../../contracts/conventions/money-and-types.md) (IRR `BIGINT` as a string on
the wire, the envelope, `refund_channel` enum, Toman is display-only).
- **Prior handoffs:** `dev/shared-working-context/backend/handoff/after-backend-phase-10.md` and
`…-11.md`, and `reports/mocks-registry.md` (the `IWebhookVerifier`/`IPaymentProvider`/`IDistributedLock`
rows you reuse, the new rows you add).
## 3. Scope — build this
All money is IRR `long` / `BIGINT`. Features live under
`Baya.Application/Features/Bnpl/{Commands|Queries}/<Name>/`; the entity in
`Baya.Domain/Entities/Bnpl/`; one `IEntityTypeConfiguration<T>` in `Persistence/Configuration/BnplConfig/`;
one EF migration for the single table.
### 3.1 Entity + migration
**`bnpl_transactions`** [MVP] — one row per BNPL order, **1:1 with its `payment_transaction`**; the single
inbound settlement to reconcile, plus the revert path. (Replaces the deleted `installment_plans`; there is
nothing to amortize on our side.)
- Fields (mirror [`product/data-model/08-bnpl.md`](../../../product/data-model/08-bnpl.md) exactly):
- `id` (BIGINT PK).
- `payment_transaction_id` (BIGINT FK → `payment_transactions`) **`UNIQUE`** — the strict 1:1 guard.
- `provider_code` (NVARCHAR(50)) — `snapppay` | `digipay` | `tara` | `torobpay` (selects the provider impl).
- `merchant_of_record` (NVARCHAR(40)) — Balinyaar entity or partner center.
- `external_payment_token` (NVARCHAR(200)) — for verify/settle/revert; issued at initiate.
- `external_transaction_id` (NVARCHAR(200), nullable) — the provider's order/txn id.
- `eligibility_status` (NVARCHAR(30), nullable) — recorded by the eligibility check.
- `order_amount_irr` (BIGINT) — gross order = the booking's `gross_price_irr`.
- `settled_amount_irr` (BIGINT, nullable) — **net of provider commission actually received** (set at settle).
- `bnpl_commission_irr` (BIGINT, nullable) — the provider's merchant discount = **platform expense**, set at settle.
- `currency` (NVARCHAR(5)) — `IRR`/`TOMAN` at the boundary; **normalized to IRR on the way in**.
- `installment_count` (TINYINT, default 4) — **informational only** (owned by the provider).
- `status` (NVARCHAR(30)) — the state machine (see §3.2.0).
- `settled_at` (DATETIME2, **nullable**) — **per-transaction**, contract-defined (daily/T+13/weekly); never assume instant.
- `revert_transaction_id` (NVARCHAR(200), nullable), `reverted_amount_irr` (BIGINT, nullable),
`reverted_at` (DATETIME2, nullable) — the reversal path.
- `provider_commission_reversed_amount` (BIGINT, **nullable**) — the provider's own commission reversal,
reconciled **from the provider response**; **do not hardcode** (may be null/partial).
- `refund_channel` (NVARCHAR(20), nullable) — `bnpl_revert` on a reversal.
- `callback_payload_json` (NVARCHAR(MAX), nullable) — raw verify/settle/revert payload.
- audit + soft-delete fields per conventions.
- **Constraints / invariants:**
- `payment_transaction_id` **UNIQUE** (strict 1:1) — the structural one-BNPL-row-per-order guard.
- **State-machine guard on `status`** (forward-only; see §3.2.0) — illegal transitions are rejected; a
replayed `settle`/`revert` is a no-op, not a double-post.
- Money invariant (handler, on settle): `settled_amount_irr = order_amount_irr bnpl_commission_irr`;
all amounts ≥ 0.
- Relations: 1:1 → `payment_transactions`; shares `payment_webhook_events` for callback idempotency;
the revert creates a `refunds` row (b11).
### 3.2 Status state machine & commands/queries (CQRS, `OperationResult`, never throw for expected failures)
#### 3.2.0 The status state machine (the idempotency spine)
Define `BnplStatus` as a proper enum (persist as its stable string code):
`eligible` | `token_issued` | `verified` | `settled` | `reverted` | `cancelled` | `failed`.
Allowed forward transitions — enforce centrally (a `TransitionTo` guard on the entity / a small transition
table), **reject anything else, and treat an already-in-target-state transition as an idempotent no-op**:
```
eligible → token_issued | failed | cancelled
token_issued → verified | failed | cancelled
verified → settled | failed | reverted
settled → reverted
(any active) → cancelled (before settle)
```
A replayed callback that would re-drive a completed transition **must not** re-post the ledger — the guard
plus the `payment_webhook_events` dedup are the two backstops.
#### 3.2.1 Capabilities
| Capability | Type | Route | What it does |
| --- | --- | --- | --- |
| **`CheckBnplEligibilityQuery`** | Query | `POST api/v1/checkout_bnpl/eligibility` | Calls `IBnplProvider.CheckEligibilityAsync(customerMobile, order_amount_irr, ct)` for the chosen `provider_code` and records `eligibility_status` (and `status='eligible'`) on a created/updated `bnpl_transactions` row tied to the booking's `payment_transaction`. Returns `eligible`/`not_eligible`/`ceiling_exceeded` + the plan summary (default 4 installments, "0% interest, provider-financed") so the client can show the plan or fall back to card. Amount comes from the booking's `gross_price_irr`. |
| **`InitiateBnplOrderCommand`** | Command | `POST api/v1/checkout_bnpl/initiate` | Creates the `bnpl_transactions` row **1:1** with a `payment_transaction` (under the `UNIQUE(payment_transaction_id)` guard), normalizes `order_amount_irr` to IRR via **`ICurrencyNormalizer`**, calls `IBnplProvider.CreatePaymentTokenAsync(...)` to issue `external_payment_token`, transitions `eligible → token_issued`, and returns the token + provider redirect URL. Under `lock(booking:{id}:payment)` (reuse b10's lock). Carries an `idempotencyKey`. |
| **`VerifyBnplOrderCommand`** | Command | (driven by `HandleBnplCallback`, also `POST api/v1/admin_bnpl/{id}/verify`) | Calls `IBnplProvider.VerifyAsync(token, expected order_amount_irr, ct)`, re-checks amount + reference (**never trust the callback alone**), persists `callback_payload_json`, transitions `token_issued → verified`. Idempotent via the state guard. |
| **`SettleBnplOrderCommand`** | Command | (driven by `HandleBnplCallback`, also `POST api/v1/admin_bnpl/{id}/settle`) | Calls `IBnplProvider.SettleAsync(token, idempotencyKey, ct)`; records `settled_amount_irr`, `bnpl_commission_irr`, `settled_at` (**nullable — read from the provider response, never assume now**) from the **actual settlement**; **posts the BNPL-settle ledger group** (§5) — the card-capture legs **plus** `DEBIT bnpl_fee_expense = bnpl_commission_irr` / `CREDIT escrow_held = bnpl_commission_irr` so escrow reflects **net** cash — via b10's helper; transitions `verified → settled` and confirms the parent `payment_transaction` (`succeeded`, under b10's filtered-unique guard) which triggers the booking conversion. Under `lock(booking:{id}:payment)`; carries an `idempotencyKey`. **A replayed settle is a no-op** (state guard + webhook dedup). |
| **`RevertBnplOrderCommand`** | Command | `POST api/v1/admin_bnpl/{id}/revert` | Full reversal via the stored token: calls `IBnplProvider.RevertAsync(token, idempotencyKey, ct)` (partial/shortened-visit maps to `UpdateAsync(newAmount strictly-lower)`), writes `revert_transaction_id`, `reverted_amount_irr`, `reverted_at`, `provider_commission_reversed_amount` (from the provider response, nullable), sets `refund_channel='bnpl_revert'`, **creates a `refunds` row** (b11) with `refund_channel='bnpl_revert'`, `external_revert_reference`, `expected_customer_refund_eta` (~710 business days), **posts the refund ledger legs** (b11's helper — fee-leg + payout-leg decomposition; if the nurse was already paid, a clawback), and transitions `… → reverted`. Under `lock(booking:{id}:refund)`. Money **always** flows customer ↔ provider ↔ Balinyaar — **never** direct-to-customer or nurse→customer. |
| **`HandleBnplCallbackCommand`** | Command | `POST api/v1/webhooks_bnpl/{provider}` | The inbound provider-callback entry point. **`IWebhookVerifier`** (reuse, b10) validates signature + extracts `(externalEventId, eventType, payload)`; **upsert `payment_webhook_events` keyed `UNIQUE(provider_code, external_event_id)` FIRST, no-op on duplicate, inside the same DB transaction that mutates state**; stores `callback_payload_json`; dispatches to `VerifyBnplOrderCommand`/`SettleBnplOrderCommand`/`RevertBnplOrderCommand` per `eventType`, all gated by the status state machine so a re-delivered callback never double-settles or double-posts. Rate-limited. |
| **`GetBnplOrderStatusQuery`** | Query | `GET api/v1/admin_bnpl/{id}` (+ tenancy-scoped customer view of their own order) | Surfaces status, `order_amount_irr`, `settled_amount_irr`, `bnpl_commission_irr`, **settlement timing** (`settled_at` / the contract-defined class, "not instant"), and revert audit (`reverted_amount_irr`, `external_revert_reference`, `expected_customer_refund_eta`). Projected (`AsNoTracking` + `.Select`). |
- **Controllers:** `CheckoutBnplController` (customer policy, tenancy-scoped, checkout endpoints
**rate-limited**), `WebhooksBnplController` (anonymous but signature-verified + rate-limited), and
`AdminBnplController` (admin policy, payout/refund-sensitive endpoints rate-limited). All
`sealed : BaseController`, inject `ISender`, return `base.OperationResult(...)`, snake_case
`[controller]`/`[action]` routes, `CancellationToken` threaded.
- **Validators:** FluentValidation on `InitiateBnplOrderCommand` (valid `provider_code`, positive amount,
booking in `pending_payment`) and the id-bearing commands; `RevertBnplOrderCommand` validates a
partial/update amount is **strictly lower** than the settled amount.
### 3.3 DEFERRED (build the seam/flag, not the feature)
- **`bnpl_settlement_entries`** — tranched-settlement child rows, only needed if a future provider pays the
platform over time. **Modeled-but-inactive: do not build the table.** Note in the report that adding it
later is a purely additive migration. (Ref [`product/data-model/08-bnpl.md`](../../../product/data-model/08-bnpl.md).)
- **Customer installment tracking** (`installment_entries` / `installment_plans`) — **cut entirely**; the
provider owns the schedule and 100% default risk. **Never reintroduce.** `installment_count` is
informational only.
- **Multiple-provider BNPL routing / failover** — DEFERRED; this phase ships the mock with one impl per
`provider_code` and config-driven selection, but the active route is a single provider. Note in the
report.
- The **BNPL `settled_at`-gates-payout** coupling lives in **b13** (the `require_bnpl_settlement_for_payout`
config flag) — **do not** couple payout to BNPL settlement here; just record `settled_at` faithfully.
## 4. Mocks & seams in this phase
| Seam | Owner | Mock behaviour | Registry |
| --- | --- | --- | --- |
| **`IBnplProvider`** | **introduced here** | The SnappPay-superset verb set: `CheckEligibilityAsync` (always **eligible**), `CreatePaymentTokenAsync` (**fixed deterministic** `external_payment_token` + redirect URL), `VerifyAsync` (instant **verified**, echoes amount), `SettleAsync` (instant **settled**: returns `settledAmountIrr = order commission`, `bnplCommissionIrr` from a **configurable mock commission %**, `settledAt = now`), `RevertAsync`/`UpdateAsync`/`CancelAsync` (echo amounts, drive the reversal), `GetStatusAsync`. **Drives the full `eligible → token_issued → verified → settled → reverted/cancelled` state machine with no network.** One impl **per `provider_code`** (`snapppay`/`digipay`/`tara`/`torobpay`), selected by config / a `provider_code`-keyed resolver. | **add a new row** (🟡) |
| **`ICurrencyNormalizer`** | **introduced here** | Toman↔IRR at the boundary: mock multiplies Toman ×10 → IRR (and back for display). Config-driven. **Conversion happens ONLY here, at the provider boundary — never internally.** | **add a new row** (🟡) |
| `IWebhookVerifier` | reuse from **b10** | signature `valid=true`, extracts a test `externalEventId`/`eventType` from the body; lets tests replay duplicate callbacks to prove idempotency. | reuse row |
| `IDistributedLock` | reuse from **b10** | in-memory mock lock; `lock(booking:{id}:payment)` on initiate/verify/settle, `lock(booking:{id}:refund)` on revert. | reuse row |
| `IFieldEncryptor` | reuse from **b0** | local symmetric key; for any PII echoed in the callback payload — never log plaintext. | reuse row |
| `ICacheService` | reuse from **b0/b1** | in-memory; behind the typed config accessor (commission %, currency, timing class). | reuse row |
The mocks live behind **DI-registered interfaces** in Infrastructure (real impl is a drop-in later); a real
`SnappPayBnplProvider` / `DigipayBnplProvider` selection is config-driven, **never** an `if (mock)` branch
in a handler. Append the `IBnplProvider` and `ICurrencyNormalizer` rows to
[`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md)
(seam, file, what's faked, config keys, **step-by-step how to make it real** — for `IBnplProvider`:
SnappPay OAuth `api/online/v1/oauth/token` + `offer/v1/eligible` + `payment/v1/token|verify|settle|revert|
cancel|update|status`, or Digipay UPG `tickets/business?type=13` + `purchases/verify` +
`purchases/deliver?type=13` + `refunds`/`reverse`; credentials from the encrypted `payment_gateways.config_json`;
Toman↔Rial conversion; per-contract commission read from the settle response; **warn: do not use the
unrelated Canadian `SnapPayInc/open-api-java-sdk`**).
## 5. Critical rules you must not get wrong
**Money correctness is sacred — the following must hold verbatim:**
- **Money is IRR `BIGINT`, no floats, ever.** Every amount (`order_amount_irr`, `settled_amount_irr`,
`bnpl_commission_irr`, `reverted_amount_irr`, `provider_commission_reversed_amount`) is `long`/`BIGINT`.
No float path. **Currency is normalized to IRR at the provider boundary** (`ICurrencyNormalizer`) — the
provider speaks Toman; conversion happens **only** in the adapter, never internally.
- **A BNPL order is, in our books, a card payment landing net-of-fee.** **Do NOT model the customer's
repayment schedule or default risk** — the provider owns the installments and 100% default risk; the
`installment_entries` subsystem was deleted. `installment_count` is informational only.
- **`bnpl_commission_irr` is the provider's merchant discount = a PLATFORM EXPENSE** (the `bnpl_fee_expense`
leg) and **NEVER touches the nurse's payout.** The settle ledger reflects **NET cash** — escrow shows
`settled_amount_irr`, **not** `order_amount_irr`.
- **The nurse's payout is invariant to payment method** — computed from `gross_price_irr
balinyaar_commission_irr` (the booking split), **never** from `settled_amount_irr`. (b13 pays the
identical amount whether the family paid by card or BNPL.)
- **The settle ledger group (balanced, append-only, one `transaction_group_id`, Σdebit = Σcredit)** — the
card-capture legs **plus** the provider-fee leg, posted once via b10's helper:
```
DEBIT escrow_held order_amount_irr (= gross_price_irr)
CREDIT platform_revenue balinyaar_commission_irr
CREDIT nurse_payable nurse_payout_amount
DEBIT bnpl_fee_expense bnpl_commission_irr
CREDIT escrow_held bnpl_commission_irr (escrow reflects NET cash received)
```
Never UPDATE/DELETE a ledger row; corrections are new balancing postings.
- **`settled_amount_irr = order_amount_irr bnpl_commission_irr`**, and the commission + settlement timing
are read from the **actual settlement record**, **never hardcoded**.
- **`settled_at` is per-transaction and contract-defined (daily/T+13/weekly) — never assume instant.**
Model it nullable; "full amount" does not mean "instant cash." Do not let b13 assume BNPL cash funds a
payout (payout is decoupled).
- **Idempotency:** every callback upserts `payment_webhook_events` (`UNIQUE(provider_code, external_event_id)`)
**first, inside the money-mutating DB transaction, and no-ops on duplicate**; the **status state machine
is forward-only** so a **replayed settle must not double-count or double-post the ledger**, and a
replayed revert must not double-refund. Redis `lock(booking:{id}:payment)`/`lock(booking:{id}:refund)`
is the fast first line; the webhook UNIQUE + state machine are the authoritative backstop.
- **Strict 1:1:** `bnpl_transactions.payment_transaction_id` is **UNIQUE** — exactly one BNPL row per order.
Do not drop it.
- **Refund routing:** BNPL refunds flow **only** customer ↔ provider ↔ Balinyaar via `RevertAsync`
(full) / `UpdateAsync` (partial, **strictly lower** amount) using the **stored token** — **never**
nurse→customer or Balinyaar→customer directly. The refund still decomposes across the platform-fee and
nurse-payout legs in the ledger (b11), `refund_channel='bnpl_revert'`, and the customer's cash-back is
**async ~710 business days** (surface `expected_customer_refund_eta`).
- **Escrow is a ledger, not a status flag** — every BNPL inbound/reversal is double-entry `ledger_entries`.
- **Never trust the callback alone** — `SettleBnplOrderCommand`/`VerifyBnplOrderCommand` re-check amount +
reference server-side against the stored `order_amount_irr` before posting money.
- **Tenancy:** the customer view of `GetBnplOrderStatusQuery` is scoped to `ICurrentUser`; a customer can
never read another's BNPL order. Admin/webhook endpoints sit behind their policies and are rate-limited.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] `bnpl_transactions` exists via one migration, with its `IEntityTypeConfiguration<T>`, the
`UNIQUE(payment_transaction_id)` 1:1 guard, the `BnplStatus` state-machine enum + central transition
guard, the `settled_amount_irr = order_amount_irr bnpl_commission_irr` invariant, nullable
`settled_at`/`provider_commission_reversed_amount`, and soft-delete/audit wiring per conventions.
- [ ] All §3.2 commands/queries implemented (CQRS, `OperationResult`, projected + paginated reads,
validators), with `CheckoutBnplController` + `WebhooksBnplController` + `AdminBnplController`.
- [ ] **`IBnplProvider`** (one impl per `provider_code`) and **`ICurrencyNormalizer`** introduced
(Application interfaces, Infrastructure mocks, DI registration via a `ServiceConfiguration/`
extension, config-selected). No `if (mock)` in handlers.
- [ ] The settle posts the **net-of-fee ledger group including the `bnpl_fee_expense` leg** via b10's
helper; a **replayed settle webhook is a no-op** (webhook dedup + state guard); the revert posts the
reversal via b11's helper with `refund_channel='bnpl_revert'`.
- [ ] **The `nurse_payable` accrual equals the card-path amount** (payout invariant to method) — covered
by a test that settles a BNPL order and asserts `nurse_payable` matches the card-capture path.
- [ ] Handler unit tests (NSubstitute) for eligibility, the initiate→verify→settle posting (incl. the
`bnpl_fee_expense` leg and the payout-invariance assertion), the replayed-settle no-op, the
revert/reversal posting, and the strict-1:1 + state-machine guards; ≥1 `WebApplicationFactory`
integration test per controller (happy path, 401/403, validation 400). `dotnet build Baya.sln` zero
new warnings; `dotnet test Baya.sln` green.
- [ ] The `Baya.Application/Features/Bnpl/**` area is reflected in the **Project map** in
`server/CLAUDE.md`; the `IBnplProvider` + `ICurrencyNormalizer` seams noted where seams are documented.
- [ ] The contract `dev/contracts/domains/bnpl.md` written and the `swagger.json` snapshot republished.
## 7. How to test (what a human can verify after this phase)
Seed (or reuse from prior phases) a **`pending_payment`** booking with a known three-amount split
(`gross_price_irr`, `balinyaar_commission_irr`, `nurse_payout_amount`) and a `payment_gateways` row with
`type='bnpl'`, `provider_code='snapppay'`. Set the mock commission % (config) to a known value (e.g. 10%).
1. **Eligibility** — `POST api/v1/checkout_bnpl/eligibility` for the booking → `eligible` with the plan
summary (4 installments, 0% interest, provider-financed); a `bnpl_transactions` row exists with
`eligibility_status` set and `status='eligible'`.
2. **Initiate** — `POST api/v1/checkout_bnpl/initiate` → `status='token_issued'`, a deterministic
`external_payment_token` + redirect URL returned; the row is 1:1 with the `payment_transaction`; a
second initiate for the same `payment_transaction` is rejected by the `UNIQUE` guard.
3. **Verify → settle (the ledger)** — drive the callback `POST api/v1/webhooks_bnpl/snapppay` (or the admin
settle) → `status` walks `verified → settled`; `settled_amount_irr = order_amount_irr bnpl_commission_irr`
(e.g. 10% commission), `bnpl_commission_irr` and `settled_at` recorded; the **ledger** shows the
balanced group: `DEBIT escrow_held` gross / `CREDIT platform_revenue` commission + `CREDIT nurse_payable`
payout **plus** `DEBIT bnpl_fee_expense` commission / `CREDIT escrow_held` commission — so the net
`escrow_held` equals `settled_amount_irr`.
4. **Payout invariance** — assert the `nurse_payable` credited equals `gross_price_irr
balinyaar_commission_irr`, i.e. **identical to the card path** and **independent of** `settled_amount_irr`
/ the BNPL commission.
5. **Replayed settle is a no-op** — re-deliver the same settle callback (same `external_event_id`) → the
`payment_webhook_events` dedup + the state guard reject it; **no second ledger group**, balances unchanged.
6. **Revert** — `POST api/v1/admin_bnpl/{id}/revert` → `status='reverted'`, `reverted_amount_irr`/
`revert_transaction_id`/`reverted_at` set; a `refunds` row appears with `refund_channel='bnpl_revert'`,
`external_revert_reference`, and `expected_customer_refund_eta` (~710 business days); the **reversal
ledger legs** post (fee-leg + payout-leg; clawback if the nurse was already paid).
7. **Status** — `GET api/v1/admin_bnpl/{id}` → surfaces settlement amount/commission, the non-instant
`settled_at`, and the revert audit; the customer can read **only their own** order (another customer's
is 403/not visible).
## 8. Hand off & document (close the phase)
- **Docs to update:** the **Project map** in [`server/CLAUDE.md`](../../../server/CLAUDE.md) (add the
`Features/Bnpl/**` area + the `IBnplProvider` / `ICurrencyNormalizer` seams); if you discover/confirm a
rule the product docs don't capture (e.g. the mock commission % config key, the `provider_code`-keyed
resolver, the exact transition table), record it in
[`product/business/09-installments-bnpl.md`](../../../product/business/09-installments-bnpl.md) or
[`product/data-model/08-bnpl.md`](../../../product/data-model/08-bnpl.md) — don't invent rules.
- **Contract to write:** **`dev/contracts/domains/bnpl.md`** (per
[`../../contracts/domains/_TEMPLATE.md`](../../contracts/domains/_TEMPLATE.md)) — the checkout endpoints
(eligibility, initiate), the webhook endpoint, the admin verify/settle/revert/status endpoints; the
`BnplStatus` and `refund_channel` enums; the `bnpl_transactions` DTO shape (IRR `BIGINT` as a string,
nullable `settled_at`, the revert fields); auth/rate-limit/idempotency notes; the net-of-fee settle and
the customer ↔ provider ↔ Balinyaar refund routing as documented side effects; the async refund-ETA copy.
Republish the `swagger.json` snapshot per
[`../../contracts/openapi/README.md`](../../contracts/openapi/README.md). This is what **f11-b12** consumes.
- **Handoff & report:** write `dev/shared-working-context/backend/handoff/after-backend-phase-12.md` (BNPL
checkout is live, what f11 can now build — the "pay with installments" option, eligibility/plan states,
provider handoff, declined→fall-back-to-card, the admin BNPL revert path with the ~710-day ETA — which
endpoints/contracts are live, that the provider + currency are mocked behind `IBnplProvider` /
`ICurrencyNormalizer`), append to `backend/STATUS.md`, write
`dev/shared-working-context/reports/backend-phase-12-report.md` (what was built, **what is now testable
and exactly how** per §7, what is mocked + how to make it real, contracts produced/consumed, follow-ups:
tranched settlement `bnpl_settlement_entries`, multi-provider routing, the b13 `settled_at` payout
guard), and update `dev/shared-working-context/reports/mocks-registry.md` (the `IBnplProvider` +
`ICurrencyNormalizer` rows → 🟡).
- **Memory:** save a `project` memory note for the non-obvious decisions this phase fixes — a BNPL order is
a net-of-fee card payment (no installment tracking), the `bnpl_fee_expense` settle leg so escrow shows
net cash, the payout-invariant-to-method rule, the forward-only state machine + webhook dedup
idempotency, the strict 1:1 `payment_transaction_id` UNIQUE, the customer↔provider↔Balinyaar revert
routing, and the `IBnplProvider` (per-`provider_code`) + `ICurrencyNormalizer` seams — with a one-line
pointer in `MEMORY.md`.
+305
View File
@@ -0,0 +1,305 @@
# Backend Phase 13 — Weekly nurse payouts (mocked bank transfer)
> **Mission:** pay nurses what they have earned. Build the weekly payout engine that aggregates
> payout-**eligible**, unpaid bookings/sessions into a `nurse_payout_batches` run, fans them out to **one
> `nurse_payouts` row per nurse** (netting any pending clawback the nurse owes back), links each booking
> under a **`UNIQUE` guard so it can never be paid twice**, snapshots the nurse's verified primary IBAN,
> submits the transfers through a **mocked PAYA/SATNA bank rail**, and posts the outbound `nurse_payable`
> ledger movement — all **holiday-aware** so a Nowruz-landing batch shifts off bank-closed days. This is
> the last money-out phase; after this a nurse's earnings are real.
>
> **Track:** backend · **Depends on:** [b10](./backend-phase-10.md) (ledger / `nurse_payable`), [b11](./backend-phase-11.md) (clawbacks), [b9](./backend-phase-9.md) (booking/session eligibility, dispute window), [b3](./backend-phase-3.md) (nurse bank accounts), [b1](./backend-phase-1.md) (`iranian_holidays`) · **Unlocks:** nurse earnings; frontend **f12-b13**
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
This is **backend phase b13**, the final money-out leg of the payments arc (b10 ledger → b11
refunds/clawbacks/invoices → b12 BNPL → **b13 payouts**). The platform never custodies cash: "escrow" is
an internal **double-entry ledger state** ([`product/payments/escrow-ledger.md`](../../../product/payments/escrow-ledger.md)),
and a nurse's owed balance lives in `ledger_entries` as the `nurse_payable` account. This phase **drains
that accrual to a real bank transfer**, once per booking, only after the dispute window has closed — the
one irreversible step in the whole money flow. Because an Iranian PAYA/SATNA transfer cannot be charged
back, eligibility gating and the clawback fallback (b11) are what protect the platform from overpaying.
**What already exists (do not rebuild) — built by prior phases:**
- **The ledger & `nurse_payable` accrual** — [b10](./backend-phase-10.md) built `ledger_entries`
(append-only, balanced, `transaction_group_id`, the six `account_type`s incl. `escrow_held`,
`nurse_payable`, `nurse_clawback_receivable`), `payment_transactions`, `payment_webhook_events`,
the card-capture posting (`DEBIT escrow_held` / `CREDIT platform_revenue` + `nurse_payable`), the
`IDistributedLock` Redis-lock pattern on the money path, and `GetNursePayableBalance` (sum of
`nurse_payable` legs). **Reuse the ledger posting helper and the lock — do not re-implement them.**
- **Clawbacks** — [b11](./backend-phase-11.md) built `nurse_clawbacks` (`nurse_id`, `booking_id`,
`refund_id`, `original_payout_id`, `amount_irr`, `status``pending|recovered|written_off`,
`recovered_in_payout_id`, `resolved_at`) and the `nurse_clawback_receivable` ledger leg. This phase
**nets `pending` clawbacks into a payout and marks them `recovered`** — it does not create them.
- **Bookings, sessions & the dispute window** — [b9](./backend-phase-9.md) built `bookings` (the
three-amount split `gross_price_irr = balinyaar_commission_irr + nurse_payout_amount`),
`booking_sessions` (per-visit `visit_payout_amount`, `payout_eligible_at`), `visit_verifications`
(EVV), and the `dispute_window_ends_at` set on completion (`completed_at + config(dispute_window_hours, 72)`).
**The `payout_released` boolean was deliberately CUT — never reintroduce it.**
- **Nurse bank accounts** — [b3](./backend-phase-3.md) built `nurse_bank_accounts` (`iban` enc,
`iban_hash` UNIQUE, `is_primary` filtered-UNIQUE per nurse, `is_verified`, `matched_national_id`,
`account_holder_from_bank`, `ownership_vendor_ref`) and the `IBankAccountOwnershipVerifier` seam. This
phase **reads the verified primary account and snapshots its IBAN** — it does not register or verify.
- **`iranian_holidays`** — [b1](./backend-phase-1.md) seeded the holiday calendar (`holiday_date`,
`is_bank_closed`) behind the **`IHolidayCalendar`** seam. **Reuse `IHolidayCalendar`** for date shifting.
- **The platform config accessor** — [b1](./backend-phase-1.md)'s typed, cached `platform_configs`
reader. Read `dispute_window_hours` (already used by b9) and any payout-window config through it; never
hardcode.
- The b0 foundation: REST surface, `BaseController`, `OperationResult<T>`, CQRS via
**`martinothamar/Mediator`**, `IFieldEncryptor` (for `iban_snapshot`), `ICurrentUser` + audit
interceptor, rate limiting, `IDateTimeProvider`.
**What this phase introduces:** the three payout tables, the eligibility/build/link/execute capabilities,
and **one new seam — `IBankTransferProvider`** (the mocked PAYA/SATNA rail). The weekly **cron scheduler is
DEFERRED** — batches are triggered manually by an admin endpoint now (see §3, `SchedulePayoutJob` DEFERRED).
## 2. Required reading (do this first)
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md) —
especially the *Performance, caching, money, idempotency* block (IRR `BIGINT`, append-only balanced
ledger, idempotent money writes, Redis lock on the money path).
- [`product/business/10-payouts.md`](../../../product/business/10-payouts.md) — **the business rules**:
weekly batches, EVV + dispute-window gating, one-payout-per-booking, clawback netting, holiday-aware
scheduling, verified-primary-IBAN destination, MVP vs DEFERRED (no on-demand withdrawal).
- [`product/payments/cancellation-and-payout.md`](../../../product/payments/cancellation-and-payout.md) —
**Q2 "who pays the nurse, and when"**: the nurse payout is `gross_price_irr balinyaar_commission_irr`,
identical and on the identical weekly timing whether the family paid by card or BNPL; the BNPL
provider's commission **never** touches the nurse; the optional `settled_at` timing guard.
- [`product/data-model/07-payouts.md`](../../../product/data-model/07-payouts.md) — **the canonical
schema** for `nurse_payout_batches`, `nurse_payouts` (incl. the `gross_earnings_irr` /
`clawback_applied_irr` / `net_amount_irr` additions), and `nurse_payout_booking_links` (the `booking_id`
UNIQUE guard). Mirror these field names exactly.
- [`product/payments/escrow-ledger.md`](../../../product/payments/escrow-ledger.md) — the **payout ledger
posting** (`DEBIT nurse_payable` / `CREDIT escrow_held` for `nurse_payout_amount`) and the clawback leg.
- **Code to mirror:** b10's ledger posting helper + `IDistributedLock` usage, the `payment_webhook_events`
idempotency pattern, and any `Features/Payments/**` command structure; b11's `nurse_clawbacks` config &
`Features/Refunds/**`; b9's `bookings`/`booking_sessions` configs and the eligibility columns; b3's
`nurse_bank_accounts` config; b1's `IHolidayCalendar` and the typed config accessor.
- **Contract conventions:** [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
and [`money-and-types.md`](../../contracts/conventions/money-and-types.md) (IRR `BIGINT`, envelope).
- **Prior handoffs:** `dev/shared-working-context/backend/handoff/after-backend-phase-10.md`, `…-11.md`,
`…-9.md`, `…-3.md`, `…-1.md`, and `reports/mocks-registry.md` (seam rows you reuse/add).
## 3. Scope — build this
All money is IRR `long` / `BIGINT`. Features live under
`Baya.Application/Features/Payouts/{Commands|Queries}/<Name>/`; entities in
`Baya.Domain/Entities/Payouts/`; one `IEntityTypeConfiguration<T>` per entity in
`Persistence/Configuration/PayoutsConfig/`; one EF migration for the three tables.
### 3.1 Entities + migration
**`nurse_payout_batches`** [CORE] — weekly aggregation, admin/job-initiated, holiday-aware.
- Fields: `id`, `period_start`, `period_end` (holiday-shifted off `is_bank_closed` days),
`processing_date` (holiday-shifted), `total_amount` (BIGINT), `payout_count` (int),
`status` (enum, see below), `initiated_by_admin_id` (FK `users`), `processed_at` (nullable),
`failure_notes` (nullable), audit fields.
- **CHECK / invariant:** `total_amount = Σ(nurse_payouts.net_amount_irr)` for the batch — enforce in the
handler when materializing rows; add a DB CHECK where SQL Server allows (else a verified invariant in
`ExecutePayoutBatch`). `payout_count = COUNT(nurse_payouts)`.
- Relations: 1:N → `nurse_payouts`.
**`nurse_payouts`** [CORE] — one row per nurse per batch.
- Fields: `id`, `batch_id` (FK), `nurse_id` (FK `nurse_profiles`), `bank_account_id` (FK
`nurse_bank_accounts`), `iban_snapshot` (**encrypted** via `IFieldEncryptor`, frozen at build time),
`gross_earnings_irr` (BIGINT — Σ eligible booking/session payouts), `clawback_applied_irr` (BIGINT —
pending clawbacks netted this batch, ≥ 0), `net_amount_irr` (BIGINT — `gross_earnings_irr
clawback_applied_irr`), `amount` (BIGINT — actually transferred net; equals `net_amount_irr` on
success), `booking_count` (int), `status` (enum), `transfer_reference` (nullable — the bank track id),
`paid_at` (nullable), `failure_reason` (nullable), audit fields.
- **Invariant (handler + CHECK where possible):** `net_amount_irr = gross_earnings_irr
clawback_applied_irr`; all amounts ≥ 0; `net_amount_irr ≥ 0` (a nurse whose clawback exceeds earnings
nets to **zero this batch with the remainder staying `pending`** — see §5; never produce a negative
transfer).
- Relations: N:1 → `nurse_payout_batches`, `nurse_profiles`, `nurse_bank_accounts`; 1:N →
`nurse_payout_booking_links`; referenced by `nurse_clawbacks.recovered_in_payout_id`.
**`nurse_payout_booking_links`** [CORE] — the structural anti-double-pay guard.
- Fields: `id`, `payout_id` (FK `nurse_payouts`), `booking_id` (FK `bookings`) **`UNIQUE`**,
`session_id` (nullable FK `booking_sessions` — set when paying per-session accrual),
`payout_amount_irr` (BIGINT — the portion of this booking/session in this payout), audit fields.
- **The `booking_id` UNIQUE index is the hard guard** — a booking can be linked to exactly one payout
across all batches, ever. **Do not drop it.** (When paying per-session, the unique guard is on
`(booking_id, session_id)` so each *session* is paid once — confirm against b9's session model; the
booking-level `booking_id` UNIQUE still holds for single-session bookings.)
- Relations: N:1 → `nurse_payouts`; 1:1 → `bookings` (and per-session → `booking_sessions`).
**Status enums** (define as proper enums, persist as string/byte per project convention):
- `PayoutBatchStatus`: `draft` | `processing` | `partially_failed` | `completed` | `failed`.
- `PayoutStatus`: `pending` | `submitted` | `paid` | `failed`.
### 3.2 Commands & queries (CQRS, `OperationResult`, never throw for expected failures)
| Capability | Type | Route (admin/nurse) | What it does |
| --- | --- | --- | --- |
| **`ComputeEligibleEarningsQuery`** | Query | `GET api/v1/admin_payouts/eligible?period_start=&period_end=` | Projects (AsNoTracking + `.Select`) the **payout-eligible, unpaid** bookings/sessions for the window and groups them by nurse, returning a preview (per-nurse `gross_earnings_irr`, pending `clawback_applied_irr`, `net_amount_irr`, booking count). Eligible = `status='completed'` **AND** `dispute_window_ends_at < now` (per-session: `payout_eligible_at < now`) **AND no open dispute AND** not already in a `nurse_payout_booking_links` row. Paginated. |
| **`GeneratePayoutBatchCommand`** | Command | `POST api/v1/admin_payouts/batches` | Opens a `nurse_payout_batches` row in `draft`. Computes `period_end`/`processing_date` and **shifts them off `is_bank_closed` days via `IHolidayCalendar`** to the next business day. Selects the eligible set (same predicate as the query) under a `lock(payout:batch)` so two runs can't grab the same bookings. Orchestrates `BuildNursePayouts` + `LinkPayoutBookings` inside one unit of work. Returns the draft batch with materialized payouts for admin preview. **Idempotent:** re-running for an overlapping window cannot re-select an already-linked booking (the UNIQUE link is the backstop). |
| **`BuildNursePayouts`** | Command (internal step) | — | Groups the eligible bookings per nurse; computes `gross_earnings_irr = Σ(gross_price_irr balinyaar_commission_irr)` (per-session: `Σ visit_payout_amount`); reads the nurse's **`pending` `nurse_clawbacks`**, nets them into `clawback_applied_irr` (capped at `gross_earnings_irr`), sets `net_amount_irr` and `amount`; **snapshots `iban_snapshot`** from the nurse's **verified primary** `nurse_bank_accounts` (`is_primary=1 AND is_verified=1 AND matched_national_id=1`). A nurse with no verified primary account is **skipped with a recorded reason** (not silently dropped). |
| **`LinkPayoutBookings`** | Command (internal step) | — | Inserts `nurse_payout_booking_links` rows under the `booking_id` UNIQUE constraint. **A duplicate-key violation is the already-paid guard** — catch it, treat that booking as not-eligible, and continue (never let it abort the batch or double-pay). |
| **`ExecutePayoutBatchCommand`** | Command | `POST api/v1/admin_payouts/batches/{id}/process` | Transitions `draft → processing`. Under `lock(payout:batch)` (and `lock(nurse:{id}:payout)` per row), submits the batch to **`IBankTransferProvider.SubmitPayoutBatchAsync`** with one `PayoutInstruction` per payout (nurse IBAN, `net_amount_irr`, PAYA/SATNA method), stores each `transfer_reference`, transitions payouts to `submitted` then `paid`, **posts the payout ledger group** (`DEBIT nurse_payable` / `CREDIT escrow_held` for `nurse_payout_amount`, per payout, balanced, append-only) via b10's helper, and **marks each netted clawback `recovered`** with `recovered_in_payout_id` + `resolved_at`. Batch ends `completed` (all paid) or `partially_failed` (some failed). **Idempotent:** carries an `idempotencyKey` so a retried call never re-submits an already-`paid` payout or double-posts the ledger. |
| **`RetryFailedPayoutCommand`** | Command | `POST api/v1/admin_payouts/{payout_id}/retry` | Re-submits a single `failed` payout (holiday-aware: won't submit on a closed day), updating `transfer_reference`/`status`. Idempotent on the same key. |
| **`MarkPayoutFailedCommand`** | Command | `POST api/v1/admin_payouts/{payout_id}/mark_failed` | Records `failure_reason`/`failure_notes`, sets `status='failed'`. Used on a reconciled bank rejection. Does **not** post a ledger movement (no money left). |
| **`GetBatchDetailQuery`** | Query | `GET api/v1/admin_payouts/batches/{id}` | Batch header + its payouts (status, net, transfer_reference) + per-payout linked bookings. Projected, paginated. |
| **`ListPayoutBatchesQuery`** | Query | `GET api/v1/admin_payouts/batches?status=&page=&page_size=` | Admin reconciliation list. Projected + paginated. |
| **`GetNursePayoutHistoryQuery`** | Query | `GET api/v1/nurse_payouts/history?page=&page_size=` | The **nurse's own** payouts (tenancy-scoped to `ICurrentUser`): status, `net_amount_irr`, `transfer_reference`, `paid_at`, masked IBAN, any clawback applied. Projected + paginated. Feeds f12's earnings screen. |
- **Controllers:** `AdminPayoutsController` (admin policy, payout-sensitive endpoints **rate-limited**)
and `NursePayoutsController` (nurse policy, tenancy-scoped). Both `sealed : BaseController`, inject
`ISender`, return `base.OperationResult(...)`, snake_case `[controller]`/`[action]` routes,
`CancellationToken` threaded.
- **Validators:** FluentValidation on `GeneratePayoutBatchCommand` (period_start ≤ period_end, not in the
future) and the id-bearing commands.
### 3.3 DEFERRED (build the seam/flag, not the feature)
- **`SchedulePayoutJob`** — the recurring **weekly cron trigger** (PAYA-aligned). DEFERRED: batches are
admin-triggered now. Leave a clean entry point (the `GeneratePayoutBatchCommand` the cron will call) and
a config key for the cadence; note it in the report. (Roadmap: a hosted scheduler later.)
- **On-demand / instant nurse withdrawal**, **per-nurse configurable payout frequency**, **automated
clawback recovery beyond next-batch netting** — DEFERRED per [`product/business/10-payouts.md`](../../../product/business/10-payouts.md) §(c).
- The **optional BNPL `settled_at` timing guard** (don't pay before BNPL cash is actually received) —
expose it as a **config flag** (`require_bnpl_settlement_for_payout`, default off) and apply it in the
eligibility predicate when set; do not hard-couple payouts to BNPL settlement. Note in the report.
## 4. Mocks & seams in this phase
| Seam | Owner | Mock behaviour | Registry |
| --- | --- | --- | --- |
| **`IBankTransferProvider`** | **introduced here** | `SubmitPayoutBatchAsync(PayoutBatchId, IReadOnlyList<PayoutInstruction>, idempotencyKey, ct)` returns a deterministic `externalBatchRef` + a per-instruction `transfer_reference`, status `Submitted` then `Paid` for all rows (**no money moves**); `GetPayoutStatusAsync(externalBatchRef, ct)` echoes `Paid`. **PAYA vs SATNA selection:** mock honours the `method` on each `PayoutInstruction` (choose SATNA for high-value rows above a config threshold, else PAYA) and records it. A config switch can force a deterministic **failure** (closed-day / insufficient-provider-balance) so `partially_failed`/retry paths are testable. | **add a new row** (🟡) |
| `IHolidayCalendar` | reuse from **b1** | static seeded `iranian_holidays`; used to shift `period_end`/`processing_date` off `is_bank_closed` days. | reuse row |
| `IFieldEncryptor` | reuse from **b0** | local symmetric key; encrypts `iban_snapshot`, never logs plaintext. | reuse row |
| `IDistributedLock` | reuse from **b10** | in-memory mock lock; `lock(payout:batch)` + `lock(nurse:{id}:payout)`. | reuse row |
| `ICacheService` | reuse from **b0/b1** | in-memory; behind the typed config accessor. | reuse row |
The mock lives behind a **DI-registered interface** in Infrastructure (real impl is a drop-in later); a
real `JibitBankTransferProvider` / `VandarPayoutProvider` selection is config-driven, **never** an
`if (mock)` branch in a handler. Append the `IBankTransferProvider` row to
[`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md)
(seam, file, what's faked, config keys, **step-by-step how to make it real** — Jibit transferor / Vandar
payout API, registered source settlement account, each nurse's verified Sheba, PAYA-vs-SATNA selection,
batch caps/minimums, the reconciliation callback that flips `submitted → paid/failed`).
## 5. Critical rules you must not get wrong
**Money correctness is sacred — the following must hold verbatim:**
- **Money is IRR `BIGINT`, no floats, ever.** Every amount (`gross_earnings_irr`, `clawback_applied_irr`,
`net_amount_irr`, `amount`, `payout_amount_irr`, `total_amount`) is `long`/`BIGINT`. No float path.
- **One payout per booking — `nurse_payout_booking_links.booking_id` UNIQUE is the hard guard; never pay
a booking in two batches.** A duplicate insert is the already-paid signal; treat it as not-eligible and
continue. Do not bypass the constraint across batches.
- **Payout eligibility requires `dispute_window_ends_at` (or per-session `payout_eligible_at`) passed AND
no open dispute — never pay on `completed` alone.** EVV completion alone is not enough; the dispute
window must have closed.
- **Net prior clawbacks before transfer:** `net_amount = gross_earnings clawback`, and **mark recovered
clawbacks** (`status='recovered'`, `recovered_in_payout_id`, `resolved_at`). Don't overpay a nurse who
owes money back. If pending clawbacks exceed this batch's earnings, net to **zero** (never negative) and
leave the remainder `pending` for the next batch.
- **`'paid'` derives from a `nurse_payout_booking_links` link row + a ledger movement out of
`nurse_payable` — the `payout_released` boolean is gone.** Never reintroduce it; never infer paid-state
from a status flag alone.
- **Append-only, balanced ledger.** The payout posts `DEBIT nurse_payable nurse_payout_amount` /
`CREDIT escrow_held nurse_payout_amount` per payout, in one `transaction_group_id`, Σdebit = Σcredit,
via b10's helper. Never UPDATE/DELETE a ledger row; corrections are new balancing postings. The nurse's
payable balance is **derived from the ledger and may go negative** (clawbacks) — don't clamp it to zero.
- **Holiday-aware shifting:** a Nowruz batch must move off bank-closed days. Shift `period_end` and
`processing_date` to the next `is_bank_closed=0` day via `IHolidayCalendar`, or PAYA/SATNA fails.
- **`total_amount = Σ payouts`** must hold per batch (CHECK / verified invariant); `payout_count =
COUNT(payouts)`.
- **Gross = commission + payout:** the payout amount is `gross_price_irr balinyaar_commission_irr` (the
booking's own split), **never** a BNPL provider's `settled_amount_irr`; `bnpl_commission_irr` is a
platform expense and **never touches the nurse**. The nurse's pay is invariant to payment method.
- **Webhook / transfer idempotency:** the bank submit carries an `idempotencyKey` and the payout `status`
state machine (`pending → submitted → paid`) is forward-only, so a retried `ExecutePayoutBatchCommand`
**never double-sends an irreversible transfer** or double-posts the ledger. Redis `lock(payout:batch)`
is the fast first line; the status state machine + the link UNIQUE are the authoritative backstop.
- **First payout is gated on the nurse's `matched_national_id`** (b3): only a verified primary IBAN
(`is_primary=1 AND is_verified=1 AND matched_national_id=1`) may receive a transfer. Snapshot that IBAN
into `iban_snapshot` (encrypted) and store the `transfer_reference` for reconciliation.
- **Real bank transfers are effectively irreversible** — which is *why* payout is dispute-window-gated and
refund-after-payout falls back to a clawback (b11), not a transfer reversal. Treat the execute step as
the point of no return.
- **Tenancy:** `GetNursePayoutHistoryQuery` is scoped to the authenticated nurse via `ICurrentUser`; a
nurse can never read another nurse's payouts. Admin endpoints sit behind the admin policy and are
rate-limited.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] The three tables (`nurse_payout_batches`, `nurse_payouts`, `nurse_payout_booking_links`) exist via
one migration, each with its `IEntityTypeConfiguration<T>`, the `booking_id` UNIQUE index, the
`net_amount_irr = gross_earnings_irr clawback_applied_irr` and `total_amount = Σ payouts`
invariants, encrypted `iban_snapshot`, and soft-delete/audit wiring per conventions.
- [ ] All §3.2 commands/queries implemented (CQRS, `OperationResult`, projected + paginated reads,
validators), with `AdminPayoutsController` + `NursePayoutsController`.
- [ ] **`IBankTransferProvider`** introduced (Application interface, Infrastructure mock, DI registration
via a `ServiceConfiguration/` extension, config-selected). No `if (mock)` in handlers.
- [ ] Eligibility predicate is correct (completed + dispute-window/`payout_eligible_at` passed + no open
dispute + not already linked); clawback netting + `recovered` marking works; the payout ledger group
posts balanced out of `nurse_payable`; holiday shifting works.
- [ ] Handler unit tests (NSubstitute) for eligibility selection, clawback netting, the duplicate-link
guard, ledger posting, and holiday shifting; ≥1 `WebApplicationFactory` integration test per
controller (happy path, 401, validation 400). `dotnet build Baya.sln` zero new warnings;
`dotnet test Baya.sln` green.
- [ ] The `Baya.Application/Features/Payouts/**` area is reflected in the **Project map** in
`server/CLAUDE.md`; the `IBankTransferProvider` seam noted where seams are documented.
- [ ] The contract `dev/contracts/domains/payouts.md` written and the `swagger.json` snapshot republished.
## 7. How to test (what a human can verify after this phase)
Seed (or reuse from prior phases) a few **completed** bookings: some with `dispute_window_ends_at` in the
**past** (eligible), some in the **future** (not yet), one **disputed**, and one with a **pending
clawback** on the nurse. Ensure one nurse has a verified primary IBAN and one does not.
1. **Eligibility preview** — `GET api/v1/admin_payouts/eligible?period_start=…&period_end=…` →
**only** the completed-and-dispute-window-closed, unpaid bookings appear, grouped by nurse; the
future-window and disputed bookings are **excluded**; the nurse without a verified IBAN is flagged.
2. **Generate a batch** — `POST api/v1/admin_payouts/batches` → a `draft` batch with one `nurse_payouts`
row per eligible nurse; the nurse with a **pending clawback** shows `clawback_applied_irr > 0` and
`net_amount_irr = gross_earnings_irr clawback_applied_irr`; `total_amount = Σ net_amount_irr`;
`iban_snapshot` populated (encrypted).
3. **Double-pay guard** — attempt to generate a second batch covering the **same** bookings → those
bookings are not re-selected (the `booking_id` UNIQUE link blocks it); no booking appears in two
payouts.
4. **Holiday shift** — set `processing_date` to land on a seeded `is_bank_closed=1` Nowruz day → the batch
`period_end`/`processing_date` is shifted to the next business day.
5. **Execute** — `POST api/v1/admin_payouts/batches/{id}/process` → payouts go `submitted → paid` with a
`transfer_reference`; the **ledger** shows a balanced `DEBIT nurse_payable` / `CREDIT escrow_held` per
payout (verify `GetNursePayableBalance` drops by the paid amount); the **netted clawback is marked
`recovered`** with `recovered_in_payout_id` set.
6. **Idempotency** — re-`process` the same batch → no second transfer, no second ledger posting (statuses
already `paid`).
7. **Failure / retry** — flip the mock to force a failure → batch ends `partially_failed`; `POST
…/{payout_id}/retry` re-submits and (with the mock back to success) flips to `paid`.
8. **Nurse history** — `GET api/v1/nurse_payouts/history` as the nurse → their payouts with masked IBAN,
net amount, transfer reference, and clawback explanation; **another nurse's payouts are not visible**.
## 8. Hand off & document (close the phase)
- **Docs to update:** the **Project map** in [`server/CLAUDE.md`](../../../server/CLAUDE.md) (add the
`Features/Payouts/**` area + the `IBankTransferProvider` seam); if you discover/confirm a rule the
product docs don't capture (e.g. the clawback-exceeds-earnings → net-to-zero behaviour, or the
`require_bnpl_settlement_for_payout` flag default), record it in
[`product/business/10-payouts.md`](../../../product/business/10-payouts.md) — don't invent rules.
- **Contract to write:** **`dev/contracts/domains/payouts.md`** (per
[`../../contracts/domains/_TEMPLATE.md`](../../contracts/domains/_TEMPLATE.md)) — the admin payout
endpoints (eligible/preview, create batch, process, retry, mark-failed, batch detail, list) and the
nurse `…/history` endpoint; the `PayoutBatchStatus` / `PayoutStatus` enums; the batch/payout/link DTO
shapes (IRR `BIGINT`, **masked** `iban_snapshot`); auth/rate-limit/idempotency notes; the
one-payout-per-booking and dispute-window-gating side-effects. Republish the `swagger.json` snapshot per
[`../../contracts/openapi/README.md`](../../contracts/openapi/README.md). This is what **f12-b13**
consumes.
- **Handoff & report:** write `dev/shared-working-context/backend/handoff/after-backend-phase-13.md` (the
payout engine is live, what f12 can now build — nurse earnings/payout history, admin payout console —
which endpoints/contracts are live, that the bank rail is mocked behind `IBankTransferProvider`), append
to `backend/STATUS.md`, write `dev/shared-working-context/reports/backend-phase-13-report.md` (what was
built, **what is now testable and exactly how** per §7, what is mocked + how to make it real,
contracts produced, follow-ups: the cron scheduler, the BNPL `settled_at` guard, on-demand withdrawal),
and update `dev/shared-working-context/reports/mocks-registry.md` (the `IBankTransferProvider` row → 🟡).
- **Memory:** save a `project` memory note for the non-obvious decisions this phase fixes — the
one-payout-per-booking UNIQUE guard, the clawback-netting + `recovered_in_payout_id` flow, the
`'paid'`-derives-from-link+ledger rule (no `payout_released`), holiday-aware shifting, and the
`IBankTransferProvider` seam — with a one-line pointer in `MEMORY.md`.
+339
View File
@@ -0,0 +1,339 @@
# Backend Phase 14 — Reviews, ratings & patient care records
> **Mission:** close the trust loop and the continuity-of-care loop. Let a customer leave **one
> moderated review per completed booking**, run that review through a moderation pipeline, and keep the
> nurse's public rating **honest** by recomputing it from source on *every* status transition — so hiding
> a 1-star never leaves an inflated average. Auto-raise an internal safety alert on low ratings. Separately,
> let nurses author **encrypted, patient-scoped clinical notes** that accumulate into a longitudinal care
> history a new nurse can read before taking over — under strict clinical access control. This is a
> brand-survival area: buyers are vulnerable people cared for unobserved at home.
>
> **Track:** backend · **Depends on:** [backend-phase-9](backend-phase-9.md) (completed bookings + dispute window), [backend-phase-3](backend-phase-3.md) (profiles/patients), [backend-phase-1](backend-phase-1.md) (`support_alerts`, `platform_configs`, `audit_logs`, notifications), [backend-phase-7](backend-phase-7.md) (search aggregates) · **Unlocks:** the reviews UI ([frontend-phase-13-b14](../frontend/frontend-phase-13-b14.md))
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
This is the trust-and-continuity phase. By now bookings can reach a **completed/closed** state (b9), the
nurse and customer profiles + patients exist (b3), the platform can raise internal `support_alerts` and
read config (b1), and the search index carries a denormalized nurse rating/count that must stay current
(b7). This phase turns those pieces into the two things families actually judge a marketplace on after the
visit: **a trustworthy public rating** and **clinical continuity across nurses**.
Two distinct sub-domains live here, and they must not be conflated:
1. **Reviews & ratings** — public, social-proof, moderated, aggregate-driving.
2. **Patient care records** — private, clinical, encrypted, **patient-scoped** (not booking-scoped),
accessed only by people with a clinical right to see them.
**What already exists (do not rebuild):**
- **Completed bookings + dispute window** — [backend-phase-9](backend-phase-9.md) built `bookings` with the
3-amount split, the lifecycle that reaches a **completed/closed** status, `booking_sessions`,
`booking_care_instructions` (the two-stage clinical disclosure gate), `visit_verifications` (EVV), and set
`dispute_window_ends_at = completed_at + dispute_window_hours` on completion. Read these statuses and
relations; do not re-model booking lifecycle.
- **Profiles & patients** — [backend-phase-3](backend-phase-3.md) built `customer_profiles`,
`nurse_profiles` (including the denormalized **aggregate rating/count fields** you recompute here), and
`patients` (with customer tenancy). The aggregate columns on `nurse_profiles` are *owned* by the nurse
domain but **written by this phase** on every review transition.
- **Platform signals & config** — [backend-phase-1](backend-phase-1.md) built `support_alerts` (the
internal-only staff worklist + `RaiseSupportAlert` API), `platform_configs` (the typed cached accessor —
including `min_rating_for_support_alert`), `audit_logs` (append-only, written by the SaveChanges
interceptor on sensitive entities), and the in-app `notifications` write. **Reuse all of these** — you
*raise* alerts, you do not define the table.
- **Search aggregates** — [backend-phase-7](backend-phase-7.md) built `nurse_search_index` behind the
`INurseSearch` seam, with **maintenance hooks** to refresh a nurse's denormalized rating/count. Every
review transition that changes the nurse aggregate must trigger that refresh — do not write the search
index directly; call the b7 maintenance hook.
- **Cross-cutting seams** — [backend-phase-0](backend-phase-0.md) introduced `IFieldEncryptor` (the field
encryptor you use for clinical notes), `ICacheService`, `IDateTimeProvider`, and `INotificationDispatcher`.
**Reuse `IFieldEncryptor`** for `patient_care_records`; do not introduce a new encryption seam.
> The **ticket/messaging** system (`tickets`, `ticket_participants`, `ticket_messages`), `partner_centers`,
> and the admin support-alert *worklist console* land in [backend-phase-15](backend-phase-15.md). This phase
> *raises* alerts and *consumes* the existing `support_alerts` raise API; it does **not** build the ticket
> system or the alert worklist UI. **(DEFERRED → b15.)**
## 2. Required reading (do this first)
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md).
- **Product — business rules (source of truth):**
[`product/business/11-reviews-trust-and-safety.md`](../../../product/business/11-reviews-trust-and-safety.md)
— the one-per-completed-booking rule, recompute-on-every-transition, the configurable low-rating threshold,
the "patient is not the sole information source" principle, and why this is a brand-survival area.
- **Product — data model (source of truth):**
[`product/data-model/10-reviews-and-records.md`](../../../product/data-model/10-reviews-and-records.md) —
`reviews` (rating 15 CHECK, body, moderation status + fields; 1:1 → `bookings`, N:1 →
`customer_profiles`/`nurse_profiles`), `review_tags_master`/`review_tag_links` (N:N), and
`patient_care_records` (nurse-authored, encrypted, **patient-scoped**, strict access). Read the **"Why"**
notes — they encode the guards you must enforce.
- **Booking statuses you gate on** — re-read the `bookings` status enum and `dispute_window_ends_at` from
[backend-phase-9](backend-phase-9.md)'s contract (`dev/contracts/domains/bookings.md`) so you key review
eligibility off the *exact* completed/closed status values, not a guess.
- **Config & alerts you reuse** — [backend-phase-1](backend-phase-1.md)'s handoff and
`dev/contracts/domains/platform-signals.md` (or equivalent) for the `RaiseSupportAlert` signature, the
`support_alerts` shape, and the typed config accessor for `min_rating_for_support_alert`.
- **Search refresh you trigger** — [backend-phase-7](backend-phase-7.md)'s handoff for the
`INurseSearch` maintenance hook that refreshes a nurse's aggregate rating/count in `nurse_search_index`.
- **Code to mirror (existing patterns):** an existing feature folder under
`Baya.Application/Features/<Area>/{Commands|Queries}/<Name>/` (request `record` + `internal sealed`
handler + `OperationResult`), an `IEntityTypeConfiguration<T>` under
`Persistence/Configuration/<Area>Config/`, a controller under `Baya.Web.Api/Controllers/V1/`
(`sealed`, `BaseController`, `ISender`, `base.OperationResult(...)`), and how prior phases call
`IFieldEncryptor` for encrypted columns (b3 IBAN, b9 care instructions).
- **Contract conventions:** [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
(envelope, routes, status codes) and [`money-and-types.md`](../../contracts/conventions/money-and-types.md)
(this phase has **no money**, but follow the type/format rules for ids/enums/timestamps).
## 3. Scope — build this
A vertical slice per capability: entity + EF config + migration → command/query handler(s) → controller
endpoint → contract. Everything async with `CancellationToken`; reads are `AsNoTracking()` + `.Select()`
projection + pagination; writes go through `IUnitOfWork` with a single `CommitAsync`.
### 3.1 Entities, configs & migration
Add these tables (exact names below) as a single additive EF Core migration. One
`IEntityTypeConfiguration<T>` per entity in `Persistence/Configuration/ReviewsConfig/`.
- **`reviews`** — one review per **completed** booking.
- Columns: `id` (BIGINT PK), `booking_id` (FK → `bookings`, **UNIQUE** — enforces 1:1),
`customer_profile_id` (FK → `customer_profiles`), `nurse_profile_id` (FK → `nurse_profiles`),
`rating` (TINYINT/INT, **CHECK `rating BETWEEN 1 AND 5`**), `body` (NVARCHAR, nullable free text),
`moderation_status` (enum: `pending_moderation` | `published` | `hidden` | `rejected`; default
`pending_moderation`), `moderation_reason` (NVARCHAR, nullable — set on hide/reject),
`moderated_by_id` (FK → users, nullable), `moderated_at` (datetimeoffset, nullable), plus the audit
fields stamped by the interceptor (`CreatedAt/ModifiedAt/CreatedById/ModifiedById`) and soft-delete.
- Indexes: unique on `booking_id`; index on `(nurse_profile_id, moderation_status)` for the public
published-only list and the recompute query; index on `moderation_status` for the moderation queue.
- Soft-delete query filter (`!IsDeleted`).
- **`review_tags_master`** — standardized tag vocabulary.
- Columns: `id` (BIGINT PK), `code` (e.g. `punctual`, `professional`, `clean`, `kind`; UNIQUE),
`label_fa` (NVARCHAR), `label_en` (NVARCHAR), `is_active` (BIT), display order. **Seed** a starter
vocabulary (at minimum: `punctual`, `professional`, `clean`, `kind`, `communicative`).
- **`review_tag_links`** — N:N join `reviews``review_tags_master`.
- Columns: `id` (BIGINT PK) or composite key, `review_id` (FK), `review_tag_master_id` (FK),
**UNIQUE `(review_id, review_tag_master_id)`** (no duplicate tag on a review).
- **`patient_care_records`** — nurse-authored, **encrypted**, **patient-scoped** clinical notes.
- Columns: `id` (BIGINT PK), `patient_id` (FK → `patients`, **the scoping key — not `booking_id`**),
`booking_id` (FK → `bookings`, **nullable**, provenance only — which visit produced the note),
`nurse_profile_id` (FK → `nurse_profiles`, the author), `body_encrypted` (VARBINARY/NVARCHAR holding
the `IFieldEncryptor`-encrypted clinical note — **never plaintext**), optional encrypted structured
fields (e.g. `vitals_encrypted`) if the digest schema carries them, `recorded_at` (datetimeoffset),
plus audit + soft-delete.
- Indexes: `(patient_id, recorded_at DESC)` for the longitudinal history read.
> **Aggregate columns are not yours to add.** `nurse_profiles` already carries the denormalized
> `average_rating` / `review_count` (or equivalently named) columns from b3. You **write** them on every
> transition; you do not re-create them. If they are missing, add them to the b3 entity in place and note
> it in your report (do not fork a parallel aggregate table).
### 3.2 Reviews — commands & queries
Feature folder `Baya.Application/Features/Reviews/`.
- **`SubmitReviewCommand`** (`Commands/SubmitReview/`) — customer submits `rating` (15) + `body` (+ optional
tag codes) for a booking.
- Guards (return `OperationResult.FailureResult`/`NotFoundResult`, **never throw**): the booking exists
and is owned by the calling customer (tenancy via `ICurrentUser``customer_profiles`); the booking is
in a **completed/closed** status (reject `cancelled`/`expired`/in-progress); **no review already exists**
for that booking (1:1). Insert as `pending_moderation`. If tag codes were passed, also write
`review_tag_links` in the same transaction (validate codes against `review_tags_master`).
- FluentValidation: `rating` in 1..5, `body` length bound.
- On create, if `rating <= min_rating_for_support_alert` (config, default 2), **call the b1
`RaiseSupportAlert`** with a `low_rating` type and the `review_id`/`booking_id` linkage (see §3.4).
- **`ModerateReviewCommand`** (`Commands/ModerateReview/`) — admin/AI transition: `publish` | `hide` |
`reject` | `unpublish`. Sets `moderation_status`, `moderation_reason` (on hide/reject), `moderated_by_id`,
`moderated_at`. **In the same transaction**: (a) recompute the nurse aggregate from source (§3.3); (b) the
audit interceptor writes the transition to `audit_logs`. After commit, trigger the b7 search-index refresh
for that nurse (§3.4). Optionally notify the review author of the outcome via `INotificationDispatcher`.
- The AI verdict (auto pre-screen) runs through `IReviewModerationService` on `SubmitReview` (§4); the
*decision authority* is still this command — a verdict can pre-set `pending_moderation` with a flag, or
auto-`published`/auto-`hidden` per config, but the human path must always be able to override.
- **`AttachReviewTagsCommand`** (`Commands/AttachReviewTags/`) — add/replace `review_tag_links` for a review
the caller owns (or admin). Enforce the unique `(review_id, review_tag_master_id)`.
- **`ListReviewsForNurseQuery`** (`Queries/ListReviewsForNurse/`) — **public**, paginated, returns
**`published` only** + the nurse aggregate (avg rating + count). `AsNoTracking()` + `.Select()` projection;
cache the aggregate read through `ICacheService` with invalidation on transition.
- **`GetReviewModerationQueueQuery`** (`Queries/GetReviewModerationQueue/`) — **admin**, paginated, filter by
`moderation_status` (default `pending_moderation`), sortable, includes any linked low-rating alert id.
- **`GetTagAggregatesQuery`** (`Queries/GetTagAggregates/`) — per-nurse tag rollup ("% punctual" = links for
that tag over published reviews of that nurse). Paginated/bounded; from published reviews only.
### 3.3 Aggregate recompute (internal domain service)
- **`RecomputeNurseRating`** — an internal application service (not an endpoint), invoked by **every**
`ModerateReviewCommand` transition *and* on `SubmitReview` only insofar as a brand-new review is
`pending_moderation` and therefore must **not** yet count. Recompute `nurse_profiles.average_rating` and
`review_count` **from the source** — i.e. `AVG(rating)`/`COUNT(*)` over the nurse's **currently
`published`** reviews — never by incremental `+delta`/`-delta`. This is the fix for inflated-rating-after-
hide drift: hiding a 1-star *lowers* the count and re-derives the average from what remains public. Do it
inside the same transaction as the status change.
### 3.4 Patient care records — commands & queries
Feature folder `Baya.Application/Features/PatientCareRecords/`.
- **`WritePatientCareRecordCommand`** (`Commands/WritePatientCareRecord/`) — a nurse authors a note for a
**patient** (optionally tagged with the `booking_id` that produced it). Encrypt `body` via
`IFieldEncryptor.Encrypt(...)` before persisting to `body_encrypted`. Guard: the calling nurse must have a
**confirmed** (or active/completed) booking for that patient — a nurse cannot write notes for a patient
they were never assigned to.
- **`GetPatientHistoryQuery`** (`Queries/GetPatientHistory/`) — patient-scoped longitudinal history,
paginated, ordered `recorded_at DESC`. Decrypt each `body_encrypted` via `IFieldEncryptor.Decrypt(...)`
**only after** the access check passes. **Strict access (§5):** the owning customer (patient's
`customer_profile`), any nurse with a **confirmed** booking for that patient, and admin — *nobody else*.
### 3.5 REST endpoints
Controllers under `Baya.Web.Api/Controllers/V1/` (`sealed`, `BaseController`, inject `ISender`,
`[controller]`/`[action]` snake_case tokens, `base.OperationResult(...)`, narrowest authorize policy, OTP/
refund-grade rate limiting not required here but keep public review-read sensibly limited):
| Verb & route | Maps to | Auth |
| --- | --- | --- |
| `POST /v1/bookings/{booking_id}/review` | `SubmitReviewCommand` | customer (owns booking) |
| `POST /v1/reviews/{id}/tags` | `AttachReviewTagsCommand` | review owner / admin |
| `PATCH /v1/reviews/{id}/status` | `ModerateReviewCommand` | admin / moderator |
| `GET /v1/nurses/{nurse_profile_id}/reviews` | `ListReviewsForNurseQuery` | public |
| `GET /v1/nurses/{nurse_profile_id}/review-tags` | `GetTagAggregatesQuery` | public |
| `GET /v1/admin/reviews/moderation-queue` | `GetReviewModerationQueueQuery` | admin |
| `POST /v1/patients/{patient_id}/care-records` | `WritePatientCareRecordCommand` | nurse (confirmed booking) |
| `GET /v1/patients/{patient_id}/care-records` | `GetPatientHistoryQuery` | owning customer / nurse w/ confirmed booking / admin |
### 3.6 Out of scope (DEFERRED — build the seam/hook, not the feature)
- Two-way (nurse-reviews-customer) double-blind reviews with timed reveal — **(DEFERRED)**, see
`product/business/11-reviews-trust-and-safety.md` (c).
- First-class `incidents` entity + ML fraud scoring — **(DEFERRED)**; manual suspension + `support_alerts`
cover it now.
- The ticket system, partner centers, and the admin **support-alert worklist console**
**(DEFERRED → [backend-phase-15](backend-phase-15.md))**. You *raise* alerts here; b15 builds the worklist.
- `SuspendNurse` / `ResolveSupportAlert` / `FlagConcern` admin actions — **(DEFERRED → b15)** with the
support backoffice.
## 4. Mocks & seams in this phase
| Seam | Owner | Mock behaviour | Registry |
| --- | --- | --- | --- |
| **`IReviewModerationService`** (AI moderation) — **INTRODUCED here** | this phase | `Task<ModerationVerdict> ScreenAsync(string reviewText, CancellationToken)` returning a verdict (`Approve`/`Flag`/`Reject` + reason). Mock = a **keyword filter / pass-through**: clean text → `Approve` (or "needs human review" per config), banned-word hit → `Flag`. No external call. Selection by config/registration. | **add row** |
| `IFieldEncryptor` (field encryption) — **REUSE from [b0](backend-phase-0.md)** | b0 | local symmetric key; `Encrypt`/`Decrypt`. Clinical notes go through it. Do not redefine. | reuse |
| `INurseSearch` maintenance hook — **REUSE from [b7](backend-phase-7.md)** | b7 | refreshes the nurse aggregate in `nurse_search_index`. Call it after every aggregate-changing transition. | reuse |
| `support_alerts` `RaiseSupportAlert`**REUSE from [b1](backend-phase-1.md)** | b1 | inserts an internal alert row. Call it on low ratings. | reuse |
| `INotificationDispatcher`**REUSE from [b0](backend-phase-0.md)/[b1](backend-phase-1.md)** | b0/b1 | in-app write (no push at MVP). Optional review-outcome notice. | reuse |
Register `IReviewModerationService` (interface in `Application/Contracts/`, mock impl in Infrastructure) via
a `ServiceConfiguration/` extension — never inline in `Program.cs`. Record it in
[`mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) with: seam, file, what's faked,
config keys (e.g. banned-word list, auto-approve toggle), how to make it real (point `ScreenAsync` at a real
text classifier/LLM endpoint without touching `ModerateReviewCommand`), status 🟡.
## 5. Critical rules you must not get wrong
- **Review eligibility — completed/closed bookings only.** Create a review **only** for a booking in the
completed/closed status, owned by the calling customer. **Never** for `cancelled`/`expired`/in-progress
bookings, non-existent bookings, or another customer's booking. (Anti-fraud, brand integrity.)
- **Enforce 1:1 — no duplicate reviews.** A unique constraint on `reviews.booking_id` is the authoritative
backstop; the handler also checks first and returns a clean `OperationResult` failure, never a raw DB
exception, on a second submit.
- **Recompute the nurse aggregate on EVERY transition, FROM SOURCE.** Publish, hide, reject, **and**
unpublish must all recompute `nurse_profiles.average_rating`/`review_count` from the nurse's **currently
`published`** reviews — `AVG`/`COUNT` over the source set, **not** an incremental `+delta`/`-delta`. This
is the explicit fix for the inflated-rating-after-hide drift: hiding a 1-star lowers the count and
re-derives the average. Do it transactionally with the status change, then refresh the b7 search index.
- **Publish gate — `pending_moderation` is NEVER public.** Reviews default to `pending_moderation` and must
never be rendered by any public/customer-facing query. `ListReviewsForNurseQuery` returns **`published`
only**; the aggregate counts **`published` only**. Filter at the query layer, not just the UI.
- **`patient_care_records` is PATIENT-scoped, not booking-scoped.** A new nurse taking over **must** read the
prior history before accepting — do not silo notes per booking. The scoping key is `patient_id`;
`booking_id` is nullable provenance only.
- **Strict clinical access + encrypted at rest.** `patient_care_records` are readable **only** by: the
owning customer (the patient's `customer_profile`), nurses with a **confirmed** booking for that patient,
and admin. Enforce in the authorization layer (not just the route policy). All clinical fields are
**encrypted via `IFieldEncryptor`** — never store, log, or project plaintext clinical content; decrypt only
after the access check passes. A nurse **without** a confirmed booking for that patient is **denied** read
and write.
- **Low-rating → `support_alerts` must fire reliably.** It is a **safety signal**. On a review at/below
`min_rating_for_support_alert` (config, default 2), raise the alert in the same flow; a failure to raise
must surface (it is not a best-effort fire-and-forget that can be silently swallowed).
- **`support_alerts` are internal-only.** They must never appear in any user-facing response or join. You
*raise* them; their worklist UI is b15.
- **Append-only audit.** Every review transition writes `audit_logs` via the interceptor — never mutate or
delete prior audit rows. The low-rating threshold is **config-driven** (read via the b1 typed accessor),
never hard-coded.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] `reviews`, `review_tags_master`, `review_tag_links`, `patient_care_records` exist via one additive
migration with the constraints in §3.1 (unique `booking_id`, `rating` CHECK 15, unique
`(review_id, review_tag_master_id)`); `review_tags_master` is seeded.
- [ ] `SubmitReview`/`ModerateReview`/`AttachReviewTags`/`ListReviewsForNurse`/`GetReviewModerationQueue`/
`GetTagAggregates`/`WritePatientCareRecord`/`GetPatientHistory` are implemented as CQRS features with
validators and the §3.5 endpoints, returning the standard `OperationResult` envelope.
- [ ] Every moderation transition recomputes `nurse_profiles` **from source** and triggers the b7 search
refresh; the recompute is covered by a test that proves hide lowers both count and average.
- [ ] Low rating raises a `support_alerts` row using the b1 API and the config threshold; verified by a test.
- [ ] `patient_care_records` are encrypted at rest via `IFieldEncryptor` and gated by the strict clinical
access rule; a nurse without a confirmed booking is denied (tested).
- [ ] `IReviewModerationService` is introduced behind a DI seam with a keyword/pass-through mock and a
registry row.
- [ ] `dotnet build Baya.sln` zero new warnings; `dotnet test Baya.sln` green including this phase's tests.
- [ ] The contract `dev/contracts/domains/reviews-records.md` is written and the `swagger.json` snapshot is
refreshed; the `server/CLAUDE.md` *Project map* notes the two new feature areas + the
`IReviewModerationService` seam.
## 7. How to test (what a human can verify after this phase)
Run the API (`dotnet run --project src/API/Baya.Web.Api/...`) against a reachable SQL Server; use Swagger or
curl. Expected results below become the "what can be tested" section of your report.
1. **Submit on a completed booking → accepted.** As the owning customer, `POST /v1/bookings/{completed_id}/review`
with `rating: 5`. → `200`, review created with `moderation_status: pending_moderation`. It does **not**
appear in `GET /v1/nurses/{id}/reviews` yet (publish gate).
2. **Submit on a cancelled booking → rejected.** Same call against a `cancelled`/`expired` booking → an
`OperationResult` failure (not a 500). A second submit on the already-reviewed booking → failure (1:1).
3. **Moderate publish recomputes up.** `PATCH /v1/reviews/{id}/status` `publish` → review appears in the
public list; `GET /v1/nurses/{id}/reviews` aggregate `review_count` increments and `average_rating`
reflects it.
4. **Moderate hide recomputes down.** Publish a 5★ and a 1★, then `hide` the 1★ → public list drops it and
the aggregate `average_rating` rises / `review_count` decrements (re-derived from source — not stale).
5. **Low rating raises an alert.** Submit `rating: 1` → a `support_alerts` row of the low-rating type exists
(visible only on the admin/internal path, never in any user response).
6. **Write + read a care record with access control.** As a nurse **with a confirmed booking** for patient
P, `POST /v1/patients/{P}/care-records` with a clinical note → `200`; the stored column is ciphertext (not
plaintext). As the owning customer or that nurse, `GET /v1/patients/{P}/care-records` → decrypted note
returned, newest first.
7. **Unauthorized nurse denied.** As a nurse **without** any confirmed booking for patient P, both the write
and the read of P's care records → `403`/access-denied `OperationResult`.
## 8. Hand off & document (close the phase)
- **Docs to update (same change):** `server/CLAUDE.md` *Project map* — add the `Features/Reviews` and
`Features/PatientCareRecords` areas, the four new tables + their config folder, and the
`IReviewModerationService` seam (where it's registered). If you had to add the aggregate columns to
`nurse_profiles`, note it. If you discovered/decided any business rule not already in the product docs,
reflect it in [`product/business/11-reviews-trust-and-safety.md`](../../../product/business/11-reviews-trust-and-safety.md)
or [`product/data-model/10-reviews-and-records.md`](../../../product/data-model/10-reviews-and-records.md)
(no invented rules — record decisions, and regenerate the HTML view per `product/CLAUDE.md` if you touched
Markdown).
- **Contract to write:** publish **`dev/contracts/domains/reviews-records.md`** (the §3.5 routes, request/
response shapes, the `moderation_status` enum, the `review_tag` codes, the care-record access-rule matrix,
status codes, examples) per [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md),
and refresh the `swagger.json` snapshot per [`../../contracts/openapi/README.md`](../../contracts/openapi/README.md)
so [frontend-phase-13-b14](../frontend/frontend-phase-13-b14.md) can derive its types (it does not guess shapes).
- **Handoff & report:** write `shared-working-context/backend/handoff/after-backend-phase-14.md` (reviews +
care-records endpoints are live; what f13 can now build; what's mocked — the AI moderation seam; the
publish-gate and clinical-access rules the frontend must respect), append your phase summary to
`shared-working-context/backend/STATUS.md`, write `reports/backend-phase-14-report.md` (what was built,
what is now testable and exactly how — the §7 steps — what is mocked + how to make it real, contracts
produced, follow-ups for b15), and update
[`reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) with the
`IReviewModerationService` row → 🟡.
- **Memory:** save a `project`-type memory note for the non-obvious decisions here — the **recompute-from-
source (not delta)** rule and where it's invoked, the **patient-scoped (not booking-scoped)** care-record
access matrix, and the `IReviewModerationService` seam selection — with a one-line `MEMORY.md` pointer.
+507
View File
@@ -0,0 +1,507 @@
# Backend Phase 15 — Messaging (tickets), partner centers & admin backoffice
> **Mission:** close the platform's operational loop. Build the **ticket system** that is the *only*
> sanctioned post-booking communication channel — nurse and customer coordinate under full admin
> visibility, with admin-only **internal notes** that can never leak to users and **no direct
> nurse↔customer side-channel**. Stand up **`partner_centers`** — the licensed home-nursing center that
> sponsors nurses and, when it is the **merchant-of-record**, becomes the legal invoice issuer and
> settlement target (not the platform). Finally, **consolidate the admin backoffice**: tie the verification
> queue (b6), refund tooling (b11), payout dashboard (b13), review moderation queue (b14), config/holiday/
> audit (b1), and the support-alert worklist (b1) into one RBAC-gated, fully audited admin surface. This is
> the last backend phase — it makes the support, admin, and partner UIs buildable.
>
> **Track:** backend · **Depends on:** [backend-phase-3](backend-phase-3.md) (users/profiles/`nurse_profiles`), [backend-phase-1](backend-phase-1.md) (`notifications`, `support_alerts`, `platform_configs`, `audit_logs`, RBAC, holidays), [backend-phase-11](backend-phase-11.md) (`refunds` + the ticket-link hook, `invoices`), [backend-phase-13](backend-phase-13.md) (payout batches/dashboard), [backend-phase-14](backend-phase-14.md) (review moderation queue) · **Unlocks:** the support + admin + partner UIs ([frontend-phase-14-b15](../frontend/frontend-phase-14-b15.md), [frontend-phase-15-b15](../frontend/frontend-phase-15-b15.md))
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
This is the **final backend phase**. Every domain the admin backoffice acts on already exists; every signal
the support team triages is already being raised. This phase adds the three remaining pieces and then
*wires them together*:
1. **Messaging (tickets)** — the structured, admin-readable communication channel. A booking-scoped
coordination ticket lets the nurse and customer arrange logistics under admin visibility; tickets also
anchor refund conversations and support requests. There is deliberately **no live chat and no direct
nurse↔customer messaging** — this is an anti-disintermediation and patient-safety design.
2. **Partner centers** — the licensed center (Asanism-style) that **sponsors** nurses and, at launch, is
plausibly the **merchant-of-record**. When it is, *it* is the legal invoice issuer and the IPG settles to
*its* IBAN — invoices and settlement follow `partner_centers`, never a hardcoded platform.
3. **Admin backoffice consolidation** — the verification queue, refunds, payouts, review moderation, config/
holiday/audit, and the support-alert worklist are each already built in their own phases. This phase does
**not** rebuild them; it exposes them under a single **RBAC-gated, audited** admin surface and adds the
one missing admin worklist — the **support-alert console** (raise API exists since b1; the assign/resolve/
list worklist is built here).
**What already exists (do not rebuild):**
- **Users, roles & profiles** — [backend-phase-3](backend-phase-3.md) built `nurse_profiles`,
`customer_profiles`, `patients`, and `nurse_bank_accounts`. **`nurse_profiles.partner_center_id`** is the
sponsorship FK this phase *sets* (add the column to the b3 entity in place if it is not already there, and
note it in your report — do **not** fork a parallel table). [backend-phase-2](backend-phase-2.md) built
`users` (+ gender, national_id) and sessions; [backend-phase-1](backend-phase-1.md) built the admin **RBAC**
(`roles`/`user_roles`, scopes `super_admin`/`admin`/`support`/`finance`/`moderator`) you authorize every
admin endpoint against.
- **Platform signals, config, audit, holidays** — [backend-phase-1](backend-phase-1.md) built
**`notifications`** (typed in-app write + list/unread-count/mark-read + 90-day retention),
**`support_alerts`** (the internal-only worklist *table* + the `RaiseSupportAlert` raise API),
`platform_configs` (typed cached accessor — incl. `platform_fee_rate`, `vat_rate`),
**`audit_logs`** (append-only, written by the SaveChanges interceptor on sensitive entities), and
`iranian_holidays`. **Reuse all of these.** This phase *consumes* them — it builds the `support_alerts`
assign/resolve/list **worklist** and the audit **viewer**, it does not redefine the tables.
- **Refunds, invoices & the ticket-link hook** — [backend-phase-11](backend-phase-11.md) built `refunds`
(admin-only, ticket-linked, fee/payout decomposition, channel-aware), `nurse_clawbacks`, and `invoices`
(VAT on commission, `issuing_entity_type`). b11 created `refunds.ticket_id` and the *expectation* that a
ticket anchors every admin refund; **this phase ships the ticket system that link points at** and wires
`OpenTicket` into the refund flow (§3.2). The `invoices.issuing_entity_type` / center-issuer resolution
that `partner_centers` drives is consumed by b11's `GenerateCommissionInvoice` — you provide
`GetCenterForBooking` (§3.4) as the resolver.
- **Payouts** — [backend-phase-13](backend-phase-13.md) built `nurse_payout_batches`, `nurse_payouts`,
`nurse_payout_booking_links` (booking_id UNIQUE), the weekly holiday-aware batch, clawback netting, and the
payout dashboard queries. The admin backoffice **surfaces** these read models — it does not re-implement
payout logic.
- **Review moderation** — [backend-phase-14](backend-phase-14.md) built `GetReviewModerationQueueQuery` +
`ModerateReviewCommand` and raises low-rating `support_alerts`. The backoffice **surfaces** the moderation
queue and the alerts it raises.
- **Verification** — [backend-phase-6](backend-phase-6.md) built `nurse_verifications` + the admin review
queue (`ListVerificationQueue`, the guarded `is_verified` flip). The backoffice **surfaces** that queue.
- **Cross-cutting seams** — [backend-phase-0](backend-phase-0.md) introduced **`IFieldEncryptor`** (used here
for `partner_centers.settlement_iban`), `ICacheService`, `IDateTimeProvider`, and
**`INotificationDispatcher`**; [backend-phase-3](backend-phase-3.md) introduced
**`IBankAccountOwnershipVerifier`** (IBAN ownership — reused if you verify a center's settlement IBAN).
**Reuse these; do not redefine them.**
> **DEFERRED — modeled-but-inactive, do NOT build/migrate in this phase** (they are pure additive migrations
> for future features and must stay unreferenced by launch flows): `organizations`, `organization_nurses`
> (the future *employer* model — distinct from `partner_centers`, the launch *sponsor*), `fraud_flags` (ML
> output; rule-based `support_alerts` `fraud_signal` covers it manually), `recurring_booking_schedules`
> (`booking_sessions` already meets the concrete multi-day need). See
> [`product/data-model/13-partner-centers-and-future.md`](../../../product/data-model/13-partner-centers-and-future.md).
## 2. Required reading (do this first)
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md).
- **Product — business rules (source of truth):**
- [`product/business/12-messaging-and-emergencies.md`](../../../product/business/12-messaging-and-emergencies.md)
— the no-direct-channel rule, the auto-created booking-coordination ticket, internal notes, tickets as the
**mandatory anchor for admin refunds**, and the on-site **emergency playbook** (call the surfaced
emergency contact, then open a ticket — operational, not a schema feature).
- [`product/business/14-notifications-and-admin.md`](../../../product/business/14-notifications-and-admin.md)
— the RBAC scopes that gate verify/refund/payout/moderate, the append-only audit requirement, the
support-alert console, and the holiday-aware backoffice reasoning.
- [`product/business/13-tax-invoicing-and-legal.md`](../../../product/business/13-tax-invoicing-and-legal.md)
— the seller split (platform invoices its **commission** only), the **partner licensed-center** launch
vehicle, merchant-of-record resolution, and why operating outside a licensed vehicle is the real legal
risk.
- **Product — data model (source of truth):**
- [`product/data-model/09-messaging.md`](../../../product/data-model/09-messaging.md) — `tickets` /
`ticket_participants` / `ticket_messages`, `is_internal`, `reference_code`, `UNIQUE(ticket_id, user_id)`,
optional `bookings`/`refunds` links.
- [`product/data-model/13-partner-centers-and-future.md`](../../../product/data-model/13-partner-centers-and-future.md)
— the exact `partner_centers` field list (read the **"Why"** notes — they encode the merchant-of-record
and partner-vs-organization guards), and the four DEFERRED tables that stay inactive.
- **Prior contracts you consume / extend:**
- [backend-phase-11](backend-phase-11.md)'s contract `dev/contracts/domains/payments-refunds.md` (or
equivalent) for the `refunds.ticket_id` link and `invoices.issuing_entity_type` you resolve.
- [backend-phase-1](backend-phase-1.md)'s handoff + contract for the `RaiseSupportAlert` signature, the
`support_alerts` shape, the `notifications` write, RBAC scopes, and the typed config accessor.
- [backend-phase-6](backend-phase-6.md) / [backend-phase-13](backend-phase-13.md) /
[backend-phase-14](backend-phase-14.md) handoffs for the verification-queue, payout-dashboard, and
moderation-queue read models the backoffice surfaces.
- **Code to mirror (existing patterns):** an existing feature folder under
`Baya.Application/Features/<Area>/{Commands|Queries}/<Name>/` (request `record` + `internal sealed`
handler + `OperationResult` — never throw), an `IEntityTypeConfiguration<T>` under
`Persistence/Configuration/<Area>Config/`, a controller under `Baya.Web.Api/Controllers/V1/` (`sealed`,
`BaseController`, inject `ISender`, `[controller]`/`[action]` snake_case tokens, `base.OperationResult(...)`,
narrowest authorize policy), how prior phases call `IFieldEncryptor` for encrypted columns (b3 IBAN, b9 care
instructions), and how b14 *raised* a `support_alert` (you build the worklist that resolves them).
- **Contract conventions:** [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
(envelope, routes, status codes, pagination) and
[`money-and-types.md`](../../contracts/conventions/money-and-types.md) (the merchant-of-record / settlement
fields touch money — keep IRR `BIGINT`, no floats, and follow the id/enum/timestamp format rules).
## 3. Scope — build this
A vertical slice per capability: entity + EF config + migration → command/query handler(s) → controller
endpoint → contract. Everything async with `CancellationToken`; reads are `AsNoTracking()` + `.Select()`
projection + pagination; writes go through `IUnitOfWork` with a single `CommitAsync`. Admin endpoints are
**RBAC-gated and audited** (the SaveChanges interceptor writes `audit_logs` on every state change).
### 3.1 Entities, configs & migration
Add these tables as a single additive EF Core migration. One `IEntityTypeConfiguration<T>` per entity, in
`Persistence/Configuration/MessagingConfig/` and `Persistence/Configuration/PartnerCentersConfig/`.
- **`tickets`** — root of all post-booking communication.
- Columns: `id` (BIGINT PK), `reference_code` (NVARCHAR, **UNIQUE**, human-facing support id —
stable once minted, quoted to users), `subject` (NVARCHAR, nullable), `status` (enum:
`open` | `closed`; default `open`), `category`/`type` (enum, e.g. `coordination` | `support` |
`refund` | `emergency`), `booking_id` (FK → `bookings`, **nullable** — optional link),
`refund_id` (FK → `refunds`, **nullable** — optional link), `opened_by_id` (FK → users),
`closed_at` (datetimeoffset, nullable), `closed_by_id` (FK → users, nullable), plus audit fields
(`CreatedAt/ModifiedAt/CreatedById/ModifiedById`) and soft-delete.
- Indexes: unique on `reference_code`; index on `status`; index on `booking_id` and `refund_id` for the
"tickets for this booking/refund" lookups; the admin list filters by `(status, CreatedAt)`.
- Soft-delete query filter (`!IsDeleted`).
- **`ticket_participants`** — who is on a thread.
- Columns: `id` (BIGINT PK), `ticket_id` (FK → `tickets`), `user_id` (FK → users), `role_on_ticket`
(enum: `customer` | `nurse` | `admin`, nullable/derived), `added_by_id` (FK → users, nullable),
`removed_at` (datetimeoffset, nullable — soft-remove, or hard delete per your soft-delete convention),
plus audit.
- **UNIQUE `(ticket_id, user_id)`** — the authoritative backstop against adding a user twice.
- Index on `(user_id, ticket_id)` for `ListMyTickets`.
- **`ticket_messages`** — individual messages.
- Columns: `id` (BIGINT PK), `ticket_id` (FK → `tickets`), `sender_id` (FK → users), `body` (NVARCHAR),
**`is_internal` (BIT, default 0)** — admin-only note; the **hard visibility boundary**, `sent_at`
(datetimeoffset), plus audit and soft-delete.
- Index on `(ticket_id, sent_at)` for the thread read.
- **`partner_centers`** — the licensed sponsor center (fields from
[`product/data-model/13-partner-centers-and-future.md`](../../../product/data-model/13-partner-centers-and-future.md),
exact):
- `id` (BIGINT PK), `name` (NVARCHAR(300)), `legal_entity_type` (NVARCHAR(30)),
`moh_establishment_permit_no` (NVARCHAR(100) — پروانه تأسیس),
`technical_director_nurse_user_id` (BIGINT FK → users, **NULL** — مسئول فنی),
`technical_director_license_no` (NVARCHAR(100), NULL), `enamad_code` (NVARCHAR(100), NULL — نماد اعتماد
الکترونیکی), **`settlement_iban` (NVARCHAR(34), encrypted via `IFieldEncryptor`, NULL)** — only when
merchant-of-record, `is_merchant_of_record` (BIT), `commission_rate` (DECIMAL(5,4), NULL — the **center's
cut**, separate from `platform_fee_rate`), `admin_user_id` (BIGINT FK → users — the center's dashboard
account), `is_active` (BIT), `verified_at` (datetimeoffset, nullable), plus audit + soft-delete.
- Relations: 1:N → `nurse_profiles` (sponsors, via `nurse_profiles.partner_center_id`), `bookings` (legally
covered by), `invoices` (issuer). Index on `is_active`; index on `admin_user_id` for the center portal
scope.
- **`nurse_profiles.partner_center_id`** — FK → `partner_centers`, **NULL** (NULL once Balinyaar holds its
own permit). Add to the b3 entity in place; note it in your report.
> **Do NOT create** `organizations`, `organization_nurses`, `fraud_flags`, or `recurring_booking_schedules`
> in this phase. They are DEFERRED and must stay unreferenced. (See §1.)
### 3.2 Messaging — commands & queries
Feature folder `Baya.Application/Features/Messaging/`.
- **`OpenTicketCommand`** (`Commands/OpenTicket/`) — create a ticket, **mint a unique stable
`reference_code`** (e.g. a collision-checked short code; the UNIQUE index is the backstop), attach the
optional `booking_id`/`refund_id` (handle **null** — both links are optional), set `category`, and add the
**opener as the first `ticket_participant`**. Used by: the customer/nurse "Contact support" flow, the
refund flow (b11 anchors its `refunds.ticket_id` here), and `LogEmergencyTicket` (below).
- FluentValidation: subject/body length bounds; if `booking_id`/`refund_id` supplied, they must exist and
the opener must be a party to them (tenancy).
- **`AutoCreateCoordinationTicketCommand`** (`Commands/AutoCreateCoordinationTicket/`) — on **booking
confirmation** (invoked by the b9 confirmation flow, or by a domain hook), auto-create a
`category=coordination` ticket linked to the `booking_id` and add **nurse + customer** as participants. One
coordination ticket per booking (idempotent — re-confirmation must not create a second).
- **`PostMessageCommand`** (`Commands/PostMessage/`) — a participant appends a `ticket_message`. **Admins may
set `is_internal=true`**; a **non-admin caller can never set `is_internal` and can never post to a closed
ticket**. Guard: only ticket participants (or admins) may post. Optionally raise an in-app notification to
the *other* (non-internal) participants via `INotificationDispatcher` (reuse — no push at MVP).
- **`AddParticipantCommand`** / **`RemoveParticipantCommand`** (`Commands/AddParticipant/`,
`Commands/RemoveParticipant/`) — admin (or the ticket owner, per policy) attaches/detaches a user. Enforce
the **`UNIQUE(ticket_id, user_id)`**: a duplicate add returns a clean `OperationResult` failure, never a raw
DB exception. Admin may attach to any ticket for full read.
- **`CloseTicketCommand`** / **`ReopenTicketCommand`** (`Commands/CloseTicket/`, `Commands/ReopenTicket/`) —
status transitions `open``closed`/`closed``open`, stamping `closed_at`/`closed_by_id`; the owner trail
comes from the audit interceptor.
- **`LogEmergencyTicketCommand`** (`Commands/LogEmergencyTicket/`) — convenience path: after an emergency
call, a nurse opens a `category=emergency` ticket and **optionally raises a `support_alerts` row** (reuse
the b1 raise API). This is the *operational* side of the emergency playbook — it does **not** dial anyone
and does **not** expose a phone number (the emergency contact is surfaced from encrypted
`booking_care_instructions`, owned by b9; see §5).
- **`GetTicketThreadQuery`** (`Queries/GetTicketThread/`) — returns the ordered messages for a ticket the
caller may see. **Role-aware at the QUERY layer:** the **USER view filters out every `is_internal`
message**; the **ADMIN view returns all**. This filter lives in the query projection, not the UI — an
internal note must never appear in any user-facing payload (see §5). `AsNoTracking()` + `.Select()`.
- **`ListMyTicketsQuery`** (`Queries/ListMyTickets/`) — paginated tickets where the caller is a participant,
filterable by `status` and searchable by `reference_code`, newest first.
- **`ListTicketsForAdminQuery`** (`Queries/ListTicketsForAdmin/`) — **admin**, paginated global queue;
filter by `status`/`category`, search by `reference_code`, optional `booking_id`/`refund_id`.
### 3.3 Partner centers — commands & queries
Feature folder `Baya.Application/Features/PartnerCenters/`.
- **`CreatePartnerCenterCommand`** / **`UpdatePartnerCenterCommand`** (`Commands/CreatePartnerCenter/`,
`Commands/UpdatePartnerCenter/`) — capture `name`, `legal_entity_type`, `moh_establishment_permit_no`,
`technical_director_nurse_user_id` + `technical_director_license_no`, `enamad_code`,
**`settlement_iban` (encrypt via `IFieldEncryptor` before persisting — never plaintext)**,
`is_merchant_of_record`, **`commission_rate`** (the center's cut), `admin_user_id`. Admin-only. If a
settlement IBAN is provided and the center is merchant-of-record, optionally verify ownership via the
reused **`IBankAccountOwnershipVerifier`** (b3) before activation.
- FluentValidation: `commission_rate` in `[0, 1)`; `settlement_iban` required when
`is_merchant_of_record=1`; `moh_establishment_permit_no` non-empty.
- **`VerifyPartnerCenterCommand`** (`Commands/VerifyPartnerCenter/`) — sets `verified_at` and `is_active`.
At MVP the licensing check (eNamad / MoH establishment-permit) is a **manual admin approval** behind the
new **`ILicenseVerificationService`** seam (§4) — the command records the decision; the seam call is the
swap point for a real registry/API later.
- **`SponsorNurseCommand`** (`Commands/SponsorNurse/`) — set `nurse_profiles.partner_center_id` to link a
nurse to its sponsoring center (and a corresponding unlink/`null` path once Balinyaar holds its own
permit). Admin-only. The center's dashboard account (`admin_user_id`) may sponsor within its own center.
- **`GetCenterForBookingQuery`** (`Queries/GetCenterForBooking/`) — resolve **which center legally covers a
booking** (via the booking's nurse → `nurse_profiles.partner_center_id`, falling back to platform when
`NULL`). Returns the issuer/settlement decision: `issuing_entity_type` (`platform` | `partner_center`),
the center id (when applicable), and whether the center is merchant-of-record. **This is the resolver b11's
`GenerateCommissionInvoice` calls** to set the invoice issuer and the settlement target — invoices and
settlement follow `partner_centers`, never a hardcoded platform.
- **`ListPartnerCentersQuery`** / **`GetPartnerCenterByIdQuery`** (`Queries/ListPartnerCenters/`,
`Queries/GetPartnerCenterById/`) — admin, paginated, with the **sponsored-nurse count**. The detail view
**never returns the plaintext or full `settlement_iban`** — mask it (last 4) per the money-and-types masking
convention.
- **Center dashboard read models** (`Queries/GetCenterDashboard/` and friends, scoped to the center's
`admin_user_id`): list **sponsored nurses**, **sponsored bookings** (bookings whose nurse the center
sponsors), and the **settlement/invoice view** (the center's invoices when it is merchant-of-record). These
surface b13 payout / b11 invoice read models filtered to the center — they do not re-implement that logic.
### 3.4 Refund↔ticket link (wire b11 to the ticket system)
b11 created `refunds.ticket_id` and the rule that **every admin refund hangs off a ticket** (dispute paper
trail). This phase ships the ticket system that link targets. Wire it: when a refund is initiated (b11's
`InitiateRefund`), ensure a ticket exists (open a `category=refund` ticket via `OpenTicketCommand` if the
flow didn't already, or attach to the existing one) and set `refunds.ticket_id`. **Do not move refund money
logic into this phase** — only provide/confirm the ticket the refund anchors to. If b11 already opens the
ticket itself, this phase just provides the `OpenTicketCommand` it calls and confirms the link is non-null.
### 3.5 Support-alert worklist (build the console on b1's raise API)
Feature folder `Baya.Application/Features/SupportAlerts/`. b1 built the `support_alerts` *table* and the
`RaiseSupportAlert` API (and b14/b6/b9/b13 are the producers). Build the **admin worklist**:
- **`ListSupportAlertsQuery`** (`Queries/ListSupportAlerts/`) — **admin/support**, paginated, filter by
`type`/`status`/`owner`. `AsNoTracking()` + `.Select()`. **Internal-only** — these must never appear in any
user-facing endpoint or join.
- **`AssignSupportAlertCommand`** (`Commands/AssignSupportAlert/`) — set the owner.
- **`ResolveSupportAlertCommand`** (`Commands/ResolveSupportAlert/`) — set status + resolution trail.
### 3.6 Admin backoffice consolidation (surface, don't rebuild)
Expose the existing read models / commands under one **admin RBAC** surface (the b1 scopes). **Do not
re-implement** any of the underlying logic — these endpoints delegate to handlers built in prior phases:
| Backoffice area | Surfaces (built in) | RBAC scope |
| --- | --- | --- |
| Verification review queue | `ListVerificationQueue` + approve/reject ([b6](backend-phase-6.md)) | `admin` (verify) |
| Refund tooling | `InitiateRefund`/`ApproveRefund`/`RejectRefund` ([b11](backend-phase-11.md)) | `finance` (refund) |
| Payout dashboard | `ListPayoutBatches`/`GetPayoutBatch` ([b13](backend-phase-13.md)) | `finance` (payout) |
| Review moderation queue | `GetReviewModerationQueue` + `ModerateReview` ([b14](backend-phase-14.md)) | `moderator` |
| Config / holiday / audit | config CRUD, holiday calendar, **audit-log viewer** ([b1](backend-phase-1.md)) | `super_admin`/`admin` |
| Support-alert worklist | §3.5 (this phase) | `support`/`admin` |
| Tickets (admin) | §3.2 admin queries (this phase) | `support`/`admin` |
| Partner centers | §3.3 (this phase) | `admin`/`super_admin` |
Add the **audit-log viewer** query if b1 did not already expose it: **`ListAuditLogsQuery`**
(`Queries/ListAuditLogs/`) — read-only, paginated, filter by `entity_type`/`entity_id`/`actor`/date. (If b1
already shipped it, reuse it and note so — do not duplicate.)
### 3.7 REST endpoints
Controllers under `Baya.Web.Api/Controllers/V1/` (`sealed`, `BaseController`, inject `ISender`,
`[controller]`/`[action]` snake_case tokens, `base.OperationResult(...)`, narrowest authorize policy). Admin
endpoints are **internal-only** (admin RBAC) and audited.
| Verb & route | Maps to | Auth |
| --- | --- | --- |
| `POST /v1/tickets` | `OpenTicketCommand` | authenticated |
| `POST /v1/tickets/{id}/messages` | `PostMessageCommand` | participant (admin may set internal) |
| `POST /v1/tickets/{id}/participants` | `AddParticipantCommand` | admin / owner |
| `DELETE /v1/tickets/{id}/participants/{user_id}` | `RemoveParticipantCommand` | admin / owner |
| `POST /v1/tickets/{id}/close` | `CloseTicketCommand` | participant / admin |
| `POST /v1/tickets/{id}/reopen` | `ReopenTicketCommand` | participant / admin |
| `POST /v1/tickets/emergency` | `LogEmergencyTicketCommand` | nurse (assigned) |
| `GET /v1/tickets` | `ListMyTicketsQuery` | authenticated (own) |
| `GET /v1/tickets/{id}` | `GetTicketThreadQuery` (**user view: internal stripped**) | participant |
| `GET /v1/admin/tickets` | `ListTicketsForAdminQuery` | `support`/`admin` |
| `GET /v1/admin/tickets/{id}` | `GetTicketThreadQuery` (**admin view: all**) | `support`/`admin` |
| `POST /v1/admin/partner-centers` | `CreatePartnerCenterCommand` | `admin`/`super_admin` |
| `PATCH /v1/admin/partner-centers/{id}` | `UpdatePartnerCenterCommand` | `admin`/`super_admin` |
| `POST /v1/admin/partner-centers/{id}/verify` | `VerifyPartnerCenterCommand` | `admin`/`super_admin` |
| `POST /v1/admin/partner-centers/{id}/sponsor-nurse` | `SponsorNurseCommand` | `admin`/`super_admin` |
| `GET /v1/admin/partner-centers` | `ListPartnerCentersQuery` | `admin`/`super_admin` |
| `GET /v1/admin/partner-centers/{id}` | `GetPartnerCenterByIdQuery` (**IBAN masked**) | `admin`/`super_admin` |
| `GET /v1/centers/{id}/dashboard` | `GetCenterDashboardQuery` | center `admin_user_id` |
| `GET /v1/internal/bookings/{booking_id}/center` | `GetCenterForBookingQuery` | internal/admin |
| `GET /v1/admin/support-alerts` | `ListSupportAlertsQuery` | `support`/`admin` |
| `PATCH /v1/admin/support-alerts/{id}/assign` | `AssignSupportAlertCommand` | `support`/`admin` |
| `PATCH /v1/admin/support-alerts/{id}/resolve` | `ResolveSupportAlertCommand` | `support`/`admin` |
| `GET /v1/admin/audit-logs` | `ListAuditLogsQuery` | `super_admin`/`admin` |
> Verification-queue, refund, payout-dashboard, moderation-queue, and config/holiday endpoints are the ones
> their own phases already published — surface them under the admin route group with the RBAC scope in §3.6;
> do **not** redefine their handlers.
### 3.8 Out of scope (DEFERRED — do not build/migrate)
- **`organizations` / `organization_nurses`** (future employer model) — **(DEFERRED)**; distinct from
`partner_centers`. See [`product/data-model/13-partner-centers-and-future.md`](../../../product/data-model/13-partner-centers-and-future.md).
- **`fraud_flags`** (ML fraud output) — **(DEFERRED)**; rule-based `support_alerts` `fraud_signal` covers it.
- **`recurring_booking_schedules`** (RFC-5545 recurrence) — **(DEFERRED)**; `booking_sessions` meets the need.
- **Real-time chat / SLA-bearing `incidents` entity / push delivery** — **(DEFERRED)**; emergencies stay an
operational playbook ([`product/business/12-messaging-and-emergencies.md`](../../../product/business/12-messaging-and-emergencies.md) (c)).
- **Real eNamad / MoH establishment-permit / مودیان integrations** — mocked behind seams (this phase
introduces `ILicenseVerificationService`; `IMoadianClient` belongs to [b11](backend-phase-11.md)).
## 4. Mocks & seams in this phase
| Seam | Owner | Mock behaviour | Registry |
| --- | --- | --- | --- |
| **`ILicenseVerificationService`** (eNamad / MoH establishment-permit) — **INTRODUCED here** | this phase | e.g. `Task<LicenseVerdict> VerifyEstablishmentPermitAsync(string permitNo, CancellationToken)` and `VerifyENamadAsync(string enamadCode, …)` returning a verdict (`Valid`/`Invalid`/`NeedsManualReview` + reason). Mock = **manual admin approve** (returns `NeedsManualReview`, so `VerifyPartnerCenter` records the human decision). No external call. Selection by config/registration. | **add row** |
| `IFieldEncryptor`**REUSE from [b0](backend-phase-0.md)** | b0 | local symmetric key; `Encrypt`/`Decrypt`. Used for `partner_centers.settlement_iban`. Do not redefine. | reuse |
| `IBankAccountOwnershipVerifier`**REUSE from [b3](backend-phase-3.md)** | b3 | IBAN ownership inquiry; optionally used to verify a center's `settlement_iban` when merchant-of-record. | reuse |
| `INotificationDispatcher`**REUSE from [b0](backend-phase-0.md)/[b1](backend-phase-1.md)** | b0/b1 | in-app write (no push at MVP). New-ticket-message alerts to the other participant. | reuse |
| `RaiseSupportAlert` (on `support_alerts`) — **REUSE from [b1](backend-phase-1.md)** | b1 | inserts an internal alert row; used by `LogEmergencyTicket`. The worklist that resolves them is built here. | reuse |
Register `ILicenseVerificationService` (interface in `Application/Contracts/`, mock impl in Infrastructure)
via a `ServiceConfiguration/` extension — never inline in `Program.cs`. Record it in
[`mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) with: seam, file, what's faked
(manual approval at MVP), config keys (auto-approve toggle), how to make it real (point the methods at a real
eNamad / MoH registry/API without touching `VerifyPartnerCenter`), status 🟡. There is **no telephony/VoIP
seam** — the emergency call is out-of-platform by design (a `tel:` link in the UI); do not build a calling
seam.
## 5. Critical rules you must not get wrong
- **No direct nurse↔customer channel.** All post-booking communication is **ticket-mediated and
admin-readable**. **Never expose the family's phone number to the nurse or vice-versa**, and never build a
side-channel. The **emergency-contact playbook is the only sanctioned bypass** — it is *operational*, not a
contact-sharing feature: the emergency contact lives in encrypted `booking_care_instructions` (owned by b9,
surfaced post-confirmation to the assigned nurse only via the two-stage clinical disclosure gate); this
phase's `LogEmergencyTicket` records the *aftermath* (a ticket, optionally an alert), it does **not** widen
that disclosure. Keep it scoped to emergencies; do not turn it into a general contact directory.
- **`is_internal` is a HARD visibility boundary, enforced at the QUERY layer.** Admin-only internal notes
must **never** leak into any user-facing query, payload, or join. `GetTicketThreadQuery`'s **user view
strips every `is_internal` message** in the projection; only the **admin view** returns them. A non-admin
caller can never *set* `is_internal` on `PostMessage` and can never *read* one. This is enforced in the
query/serialization layer, not just the UI.
- **`reference_code` is unique + stable.** It is quoted to users; mint it once (collision-checked), back it
with a UNIQUE index, and never mutate it.
- **Ticket↔booking/refund links are optional — handle null.** `booking_id` and `refund_id` are both nullable;
every read/write path must tolerate a ticket with neither link (a pure support ticket).
- **Authorization/tenancy on every ticket read and write.** Only ticket participants (and admins) can read or
post; enforce on both. Admin may attach to any ticket for full read.
- **`UNIQUE(ticket_id, user_id)` on participants** — a duplicate add returns a clean `OperationResult`
failure, never a raw DB exception; the unique index is the authoritative backstop.
- **Merchant-of-record correctness.** If `partner_centers.is_merchant_of_record = 1`, the **CENTER** (not
Balinyaar, not the nurse) is the legal **invoice issuer** and the **settlement target** — invoices and
settlement follow `partner_centers`, **not** a hardcoded platform. `GetCenterForBooking` is the single
resolver b11's invoice issuance calls; it must return the center as issuer/settlement target when the
booking's nurse is sponsored by a merchant-of-record center, and `platform` otherwise. This is a legal/tax
correctness issue, not cosmetic.
- **`partner_centers``organizations`.** Keep the **launch licensing sponsor** (`partner_centers`) distinct
from the future **employer** (`organizations`). Do not conflate "sponsor for legality" with "employer," and
do not repurpose `organizations` for launch (it stays DEFERRED/inactive).
- **`commission_rate` (the center's cut) is separate from `platform_fee_rate`.** Money math must account for
an additional center cut where present; never collapse the two. **Money is IRR `BIGINT` — no floats,
anywhere**; the three booking amounts always satisfy **`gross = commission + payout`**; the `ledger_entries`
on the money path stay **append-only and balanced** (Σdebit = Σcredit per `transaction_group_id`); webhook
handling is **idempotent**; payouts are **dispute-window gated** and **one payout per booking**
(`nurse_payout_booking_links.booking_id` UNIQUE). This phase does not *post* ledger entries, but the
merchant-of-record / settlement-target it resolves feeds those invariants — get the issuer/target right or
the downstream money is wrong.
- **`settlement_iban` is encrypted PII.** Encrypt via `IFieldEncryptor` before persisting; never store, log,
or project it in plaintext; mask it (last 4) in any read response.
- **Deferred tables stay inactive/unreferenced.** `organizations`, `organization_nurses`, `fraud_flags`,
`recurring_booking_schedules` are **not** created or referenced — adding them later must remain a pure
additive migration.
- **Admin endpoints are internal-only, RBAC-scoped, and audited.** Authorize every admin command/query with
the narrowest fitting b1 scope (`support` cannot pay out, `moderator` cannot refund, etc.); every
state-changing admin op writes an **append-only `audit_logs`** row via the interceptor — never mutate or
delete prior audit rows. **`support_alerts` are internal-only** and must never surface in any user-facing
response.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] `tickets`, `ticket_participants`, `ticket_messages`, `partner_centers` exist via one additive migration
with the constraints in §3.1 (unique `reference_code`, `UNIQUE(ticket_id, user_id)`, `is_internal`
column, `settlement_iban` encrypted); `nurse_profiles.partner_center_id` FK added; the four DEFERRED
tables are **not** created.
- [ ] `OpenTicket`/`AutoCreateCoordinationTicket`/`PostMessage`/`AddParticipant`/`RemoveParticipant`/
`CloseTicket`/`ReopenTicket`/`LogEmergencyTicket`/`GetTicketThread`/`ListMyTickets`/`ListTicketsForAdmin`
are implemented as CQRS features with validators and the §3.7 endpoints, returning the standard
`OperationResult` envelope.
- [ ] `GetTicketThread` strips `is_internal` in the **user** view and returns it in the **admin** view —
proven by a test (an internal message is hidden from the user thread, shown in the admin thread).
- [ ] `CreatePartnerCenter`/`UpdatePartnerCenter`/`VerifyPartnerCenter`/`SponsorNurse`/`GetCenterForBooking`/
`ListPartnerCenters`/`GetPartnerCenterById` + the center dashboard are implemented; `settlement_iban` is
encrypted at rest and masked in reads; `SponsorNurse` sets `nurse_profiles.partner_center_id`;
`GetCenterForBooking` returns the **merchant-of-record** issuer/settlement target.
- [ ] The refund↔ticket link is wired (§3.4): every admin refund has a non-null `ticket_id`.
- [ ] The support-alert worklist (`ListSupportAlerts`/`AssignSupportAlert`/`ResolveSupportAlert`) is built on
b1's raise API; the admin backoffice **surfaces** verification/refund/payout/moderation/config/holiday/
audit under RBAC scopes without rebuilding their handlers.
- [ ] `ILicenseVerificationService` is introduced behind a DI seam with a manual-approve mock and a registry
row.
- [ ] `dotnet build Baya.sln` zero new warnings; `dotnet test Baya.sln` green including this phase's tests.
- [ ] The contract `dev/contracts/domains/messaging-notifications-admin.md` is written and the `swagger.json`
snapshot is refreshed; the `server/CLAUDE.md` *Project map* notes the new feature areas (`Messaging`,
`PartnerCenters`, `SupportAlerts`, the admin backoffice route group), the four new tables + the
`nurse_profiles.partner_center_id` FK, and the `ILicenseVerificationService` seam.
## 7. How to test (what a human can verify after this phase)
Run the API (`dotnet run --project src/API/Baya.Web.Api/...`) against a reachable SQL Server; use Swagger or
curl. Expected results below become the "what can be tested" section of your report.
1. **Open a ticket + post a message.** `POST /v1/tickets` (no `booking_id`/`refund_id`) → `200`, a ticket
with a unique `reference_code`, the opener added as a participant. `POST /v1/tickets/{id}/messages` with a
normal body → `200`, message appears in the thread.
2. **Internal note is hidden in the user view, shown in the admin view.** As an admin, `POST
/v1/tickets/{id}/messages` with `is_internal: true` → `200`. `GET /v1/tickets/{id}` (user view) → the
internal message is **absent**. `GET /v1/admin/tickets/{id}` (admin view) → the internal message **is
present**. A non-admin attempt to set `is_internal: true` → rejected.
3. **Add/remove participant with uniqueness.** `POST /v1/tickets/{id}/participants` adds a user → `200`;
adding the **same** user again → an `OperationResult` failure (not a 500) from the `UNIQUE(ticket_id,
user_id)`. `DELETE /v1/tickets/{id}/participants/{user_id}` removes them.
4. **Create a partner center + sponsor a nurse.** `POST /v1/admin/partner-centers` with
`is_merchant_of_record: true` and a `settlement_iban` → `200`; `GET /v1/admin/partner-centers/{id}` shows
the IBAN **masked** (last 4), never plaintext. `POST /v1/admin/partner-centers/{id}/sponsor-nurse` →
`nurse_profiles.partner_center_id` is set for that nurse.
5. **`GetCenterForBooking` returns the merchant-of-record.** `GET /v1/internal/bookings/{booking_id}/center`
for a booking whose nurse is sponsored by the merchant-of-record center → `issuing_entity_type:
partner_center` + the center id; for a booking whose nurse is unsponsored → `platform`.
6. **Refund anchors to a ticket.** Initiate a refund (b11 flow) → the resulting `refunds.ticket_id` is
non-null and the ticket is a `category=refund` ticket.
7. **Admin worklists list the pending items.** `GET /v1/admin/support-alerts` lists the alerts b14/b6/b9/b13
raised (internal-only — never in a user response); `PATCH …/assign` then `…/resolve` updates owner +
status. The verification queue, refunds, payout dashboard, and moderation queue are reachable under their
admin routes with the correct RBAC scope (a `support` token is denied the payout route → `403`).
8. **Audit trail.** Any admin state change (e.g. `VerifyPartnerCenter`, `ResolveSupportAlert`) writes an
`audit_logs` row visible via `GET /v1/admin/audit-logs`; prior rows are never mutated.
## 8. Hand off & document (close the phase)
- **Docs to update (same change):** `server/CLAUDE.md` *Project map* — add the `Features/Messaging`,
`Features/PartnerCenters`, `Features/SupportAlerts` areas, the admin backoffice route group, the four new
tables + their config folders, the `nurse_profiles.partner_center_id` FK, and the
`ILicenseVerificationService` seam (where it's registered). If you discovered/decided any business rule not
already in the product docs, reflect it in
[`product/business/12-messaging-and-emergencies.md`](../../../product/business/12-messaging-and-emergencies.md),
[`product/business/14-notifications-and-admin.md`](../../../product/business/14-notifications-and-admin.md),
[`product/business/13-tax-invoicing-and-legal.md`](../../../product/business/13-tax-invoicing-and-legal.md),
or [`product/data-model/13-partner-centers-and-future.md`](../../../product/data-model/13-partner-centers-and-future.md)
(no invented rules — record decisions, and regenerate the HTML view per `product/CLAUDE.md` if you touched
Markdown).
- **Contract to write:** publish **`dev/contracts/domains/messaging-notifications-admin.md`** (the §3.7
routes, request/response shapes, the `ticket.status`/`category` and `support_alerts.status` enums, the
`is_internal` user-vs-admin view rule, the `partner_centers` shape with the **masked** `settlement_iban`,
the `GetCenterForBooking` issuer-resolution response, the RBAC scope per admin route, status codes, and
examples) per [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md),
and refresh the `swagger.json` snapshot per [`../../contracts/openapi/README.md`](../../contracts/openapi/README.md)
so [frontend-phase-14-b15](../frontend/frontend-phase-14-b15.md) (messaging/notifications) and
[frontend-phase-15-b15](../frontend/frontend-phase-15-b15.md) (admin + partner consoles) can derive their
types (they do not guess shapes).
- **Handoff & report:** write `shared-working-context/backend/handoff/after-backend-phase-15.md` (tickets,
partner centers, the support-alert worklist, and the consolidated admin backoffice are live; what f14/f15
can now build; the `is_internal` user-vs-admin rule and the RBAC scope per route the frontend must respect;
what's mocked — the `ILicenseVerificationService` manual-approve seam), append your phase summary to
`shared-working-context/backend/STATUS.md`, write `reports/backend-phase-15-report.md` (what was built, what
is now testable and exactly how — the §7 steps — what is mocked + how to make it real, contracts produced,
follow-ups), and update
[`reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) with the
`ILicenseVerificationService` row → 🟡.
- **Memory:** save a `project`-type memory note for the non-obvious decisions here — the **`is_internal`
hard-boundary at the query layer** (and the user-vs-admin `GetTicketThread` views), the
**merchant-of-record resolution** via `GetCenterForBooking` (issuer + settlement target follow
`partner_centers`, not the platform), the **`partner_centers` ≠ `organizations`** distinction, and the
list of **DEFERRED-inactive tables** — with a one-line `MEMORY.md` pointer. This is the last backend phase;
the memory note should also record that the backend chain is complete.
+334
View File
@@ -0,0 +1,334 @@
# Backend Phase 2 — Identity: phone-OTP auth, sessions & roles (REST)
> **Mission:** give the marketplace its front door. The server already has a working
> phone-OTP/JWE/passwordless-TOTP engine and a dynamic-permission RBAC system — but it is reachable
> **only over gRPC**, with OTP delivery stubbed to a log line and no session, refresh-rotation, or
> role-selection surface. This phase **wraps that existing machinery in REST**: `/auth/otp/request`,
> `/auth/otp/verify`, `/auth/refresh`, `/auth/logout`, `/me`, `/me/role`; adds revocable
> **`user_sessions`** with refresh-token rotation + stolen-token (reuse) detection; extends `users` with
> the load-bearing identity columns (`gender`, `national_id` NULL-until-KYC, `shahkar_verified_at`); and
> wires the real **`ISmsSender`** seam so an OTP actually leaves the building (mock = log the code). Do
> **not** rebuild auth — reuse `JwtService`, `AppUserManager` OTP, and the RBAC. After this phase every
> authenticated feature in the chain has something to authenticate against.
>
> **Track:** backend · **Depends on:** [b0](./backend-phase-0.md) (REST surface, rate limiter, cross-cutting seams), [b1](./backend-phase-1.md) (config accessor, `notifications`, marketplace migration baseline) · **Unlocks:** every authenticated backend feature; frontend **f1-b2**
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
This is **backend phase b2**, the root of the human-actor identity domain and the gate everything else
hangs off — no booking, profile, verification, or payment work can exist without it. The platform has
three actor types — **customer** (payer/family), **nurse** (caregiver/seller), **admin** (back-office) —
and **phone number is the primary login credential** via phone-OTP; email is optional and only required
for admin ([`product/business/01-actors-and-onboarding.md`](../../../product/business/01-actors-and-onboarding.md)).
KYC is role-staged: a customer registers and browses with a verified phone alone; a nurse must later pass
the full verification pipeline (b6) before becoming bookable. This phase delivers **login + session +
role selection** — it deliberately stops short of profiles, patients, and bank accounts (those are b3).
**What already exists (do not rebuild) — confirmed in the codebase:**
- **The whole auth engine.** [b0](./backend-phase-0.md) kept the working spine: ASP.NET Core Identity,
**JWE** access tokens (`Jwt/JwtService : IJwtService``GenerateAsync(User)`,
`GenerateByPhoneNumberAsync`, `GetPrincipalFromExpiredToken`, `RefreshToken(Guid)`), **phone-OTP**
passwordless TOTP (`AppUserManagerImplementation.GenerateOtpCode` / `VerifyUserCode`,
`GeneratePhoneNumberConfirmationToken`, `ChangePhoneNumber`; provider constants in
`Identity/Dtos/CustomIdentityConstants`), and the **dynamic-permission RBAC**
(`DynamicPermissionRequirement`/`Handler`, `DynamicPermissionService.CanAccess`,
`ConstantPolicies.DynamicPermission`, `IRoleManagerService`). **Wrap these — never reimplement them.**
- **The REST surface & rate limiter.** [b0](./backend-phase-0.md) created the first versioned controllers
under `Baya.Web.Api/Controllers/V1/` (sealed, `BaseController`, inject `ISender`, `[controller]`/`[action]`
snake_case tokens, `base.OperationResult(...)`), registered `LoggingBehavior`, and wired `AddRateLimiter`
+ `UseRateLimiter()` with **named per-IP policies** ready for auth/OTP to apply. This phase **applies**
those policies — it does not define new middleware.
- **Cross-cutting seams.** [b0](./backend-phase-0.md) introduced and DI-registered `IDateTimeProvider`,
`IFieldEncryptor` (encrypt/decrypt + deterministic `Hash` for lookups), `ICacheService`,
`IObjectStorage`, and `INotificationDispatcher`. **Reuse `IFieldEncryptor`** for the encrypted `phone`/
`email`/`national_id` columns and for the `refresh_token_hash`; **reuse `IDateTimeProvider`** for all
expiry/timestamp math. Do not introduce a second clock or encryptor.
- **Config & the first migration baseline.** [b1](./backend-phase-1.md) created the marketplace migration
baseline, the typed **cached `platform_configs` accessor**, and the `notifications` write path behind
`INotificationDispatcher`. Read OTP TTL / session lifetime / max-attempt knobs through the config
accessor — never hardcode. Your new tables migrate **onto** b1's baseline.
- **`ICurrentUser` + audit interceptor.** [b0](./backend-phase-0.md) wired `ICurrentUser` (Scoped, HTTP-
context-backed, null-object for jobs/tests) and the SaveChanges interceptor that stamps
`CreatedAt/ModifiedAt/CreatedById/ModifiedById`. Handlers never set audit fields by hand.
- **Identity tables & their config.** `User : IdentityUser<int>`, `Role`, `UserRole`, `UserRefreshToken`,
the 8 `IEntityTypeConfiguration`s mapping Identity into the **`usr`** schema, and `Baya.Tests.Setup`
(in-memory SQLite, full Identity stack incl. passwordless TOTP, real `JwtService`/`AppUserManager`).
**You extend these in place**`users`/`roles`/`user_roles` keep their baseline columns and gain new
ones; `user_sessions` is a new table off `users`.
**What this phase introduces:** the REST auth controllers, the `users` column extensions,
**`user_sessions`** (rotation + reuse detection), the role-selection flow over the existing RBAC, and
**one new seam — `ISmsSender`** (the OTP delivery rail; mock = log the code). The customer national-ID
KYC path, push/social login, and org self-onboarding are **(DEFERRED)** per the product doc — do not
build them.
## 2. Required reading (do this first)
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md).
- **Product — business truth.** [`product/business/01-actors-and-onboarding.md`](../../../product/business/01-actors-and-onboarding.md)
— phone-as-primary-credential, the customer↔patient split, role-staged KYC (national_id NULL until KYC),
revocable sessions, MVP-vs-DEFERRED. [`product/overview/platform-summary.md`](../../../product/overview/platform-summary.md)
— the four ground truths and where identity sits in the actor model.
- **Product — data model.** [`product/data-model/01-identity-and-access.md`](../../../product/data-model/01-identity-and-access.md)
— the exact `users` / `user_sessions` / `roles` / `user_roles` columns and constraints you must mirror
(`gender` NVARCHAR(10) NULL, `shahkar_verified_at` reset-on-phone-change, `phone` enc UNIQUE,
`user_sessions.refresh_token_hash`/`is_revoked`/`revoked_at`/`expires_at`, `user_roles.granted_by`/
`granted_at`/`revoked_at`).
- **Engineering truth.** [`server/CLAUDE.md`](../../../server/CLAUDE.md) — *Identity & auth*, *Persistence*,
*Startup wiring*, *Project map*; [`server/CONVENTIONS.md`](../../../server/CONVENTIONS.md) — §1 routing,
§4 controllers, §6 persistence (audit fields, soft-delete, encrypted PII), §11 security (rate limiting),
§12 service registration.
- **Contract conventions you must honour.** [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
(envelope, status codes, auth header, pagination) and
[`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md) (the
**gender enum is load-bearing**`male`/`female`, never defaulted/dropped). Section 8 publishes
`dev/contracts/domains/identity-auth.md` from the [`_TEMPLATE.md`](../../contracts/domains/_TEMPLATE.md).
- **Prior handoff & registry.** `dev/shared-working-context/backend/handoff/after-backend-phase-0.md` and
`after-backend-phase-1.md` (what is live), and
[`reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) (the `ISmsSender`
row you will fill).
- **Code to read and mirror** (do not duplicate): `Baya.Application/Features/Users/**` (the existing
`UserCreateCommand`, `RefreshUserTokenCommand`, `RequestLogout`, `GenerateUserToken`,
`TokenRequest/UserTokenRequestQuery` — these already drive OTP/JWE and are your reuse targets; note the
`//TODO Send Code Via Sms Provider` log line you are replacing), `Baya.Application/Features/Admin/**`
and `Baya.Application/Features/Role/**` (role/RBAC patterns), `Baya.Infrastructure.Identity/Jwt/JwtService.cs`,
`Baya.Infrastructure.Identity/AppUserManagerImplementation.cs`, the existing `UserGrpcServices` (shows
the request→`IMediator`→token shape you are re-exposing over REST), `BaseController`, and
`WebFramework/Swagger/RequireTokenWithoutAuthorizationAttribute` (mark `/auth/refresh` with it).
## 3. Scope — build this
Vertical slices: **entity/migration → command/query handler → controller endpoint → contract**. All
amounts/IDs follow project types (`User.Id` is `int`; `UserRefreshToken` is `Guid`-keyed). Reuse the
existing OTP/JWE/RBAC plumbing throughout — your handlers orchestrate `IAppUserManager`, `IJwtService`,
and the new session repository; they do not re-derive tokens or hash passwords.
### 3.1 Entity & migration changes
Add one EF migration (onto b1's baseline) covering:
- **Extend `users`** (the `usr` schema Identity table — keep all baseline columns, add via the
`UserConfig` configuration + entity properties on `User`):
- `gender` NVARCHAR(10) NULL — `male` / `female`. **Load-bearing** for same-gender matching downstream;
never defaulted or silently dropped. Not collected at OTP signup; settable later via profile (b3) —
here it must merely *exist* and round-trip.
- `national_id` (enc) **NULLABLE** — stays NULL until KYC (b6). Do **not** add it to any signup/verify
request. `national_id_verified_at` DATETIME2 NULL.
- `shahkar_verified_at` DATETIME2 NULL — **reset to NULL on phone change** (see §5). b6 sets it.
- `phone` (enc) — must be **UNIQUE** (one identity per phone). `email` (enc) NULLABLE.
- `is_active` BIT and `deleted_at` DATETIME2 NULL (soft-delete) with a **global query filter**
(`deleted_at IS NULL`) — if the baseline `User` lacks these, add them; otherwise confirm they map.
- Encrypted columns route through `IFieldEncryptor`; the UNIQUE on `phone` uses a deterministic
`phone_hash` (via `IFieldEncryptor.Hash`) so an encrypted column can still be uniquely indexed —
mirror the `iban_hash` pattern documented for b3.
- **New `user_sessions`** entity + `IEntityTypeConfiguration` (in a `Persistence/Configuration/UserConfig/`
sibling, `usr` schema), off `users`:
- `id` (PK), `user_id` (FK → users), `refresh_token_hash` NVARCHAR (store the **hash** via
`IFieldEncryptor.Hash`, never the raw token), `device_info` NVARCHAR NULL, `ip_address` NVARCHAR NULL,
`is_revoked` BIT, `revoked_at` DATETIME2 NULL, `expires_at` DATETIME2 NOT NULL, `created_at`.
- Index `(user_id, is_revoked)` for fast active-session lookup; index on `refresh_token_hash` for
rotation lookups.
- **Extend `roles` / `user_roles`** (keep baseline RBAC columns): `user_roles` gains the **grant/revoke
audit trail** — `granted_by` (FK → users NULL), `granted_at` DATETIME2, `revoked_at` DATETIME2 NULL.
Ensure the actor roles `customer` / `nurse` and the admin sub-roles exist (seed `customer`/`nurse` if
the b1 seed did not; admin sub-roles `support`/`finance`/`moderation`/`super_admin` are seeded but only
ever assigned internally).
Add an `IUserSessionRepository` contract in `Application/Contracts/Persistence/` and expose it on
`IUnitOfWork` (mirror `IUserRefreshTokenRepository`); implement in Persistence.
### 3.2 Commands / queries (`Baya.Application/Features/Identity/` — new area, or extend `Users/`)
Each is a `record` request + `internal sealed` handler; input-bearing commands get a FluentValidation
validator; expected failures return `OperationResult` (never throw). Reads are `AsNoTracking()` +
`.Select()` projections.
- **`RequestOtpCommand(phone)``RequestOtpResult`** — normalize/validate the Iranian phone; **upsert**
the `users` row (create an inactive-until-verified user if new, via the existing `UserCreateCommand`
path / `IAppUserManager`); call `GenerateOtpCode`; **deliver via `ISmsSender`** (replacing the
`//TODO Send Code Via Sms Provider` log). Return a non-enumerating result (e.g. `otp_sent: true`,
`resend_available_in_seconds` from config) — **do not leak whether the phone already existed**.
- **`VerifyOtpCommand(phone, code, device_info?)``AuthTokensResult`** — `VerifyUserCode`; on success
mark `phone_verified_at`/`is_active`, mint a **JWE access token** (`IJwtService.GenerateByPhoneNumberAsync`)
**and a refresh token**, and **create a `user_sessions` row** storing `refresh_token_hash`,
`expires_at` (from config), `device_info`, `ip_address` (from `ICurrentUser`/HTTP context). Return
`{ access_token, refresh_token, access_expires_at, refresh_expires_at, is_new_user, roles[] }`. On a
fresh user `roles` is empty (drives the f1 role-router to `/me/role`).
- **`RefreshTokenCommand(refresh_token)``AuthTokensResult`** — look up the session by
`Hash(refresh_token)`. **Rotation + reuse detection:** if the session is found and active → mark it
revoked, mint a new access+refresh pair, create a **new** session (rotation). If the presented token
hashes to a session that is **already revoked** → treat as **stolen-token reuse**: revoke **all** of
that user's active sessions (logout-everywhere) and return `401`. Expired session → `401`. Mark the
controller action with `RequireTokenWithoutAuthorizationAttribute` (it runs without a valid access
token).
- **`LogoutCommand(refresh_token? | current session)``OperationResult`** — revoke the current session
(`is_revoked=true`, `revoked_at=now`); rotate the security stamp via the existing `RequestLogout` path
so the JWE `OnTokenValidated` security-stamp check rejects the old access token too. Optional
`everywhere: true` revokes all the user's sessions.
- **`GetMeQuery()``MeResult`** — current user (id, phone-masked, first/last name, gender, `is_active`),
**roles[]**, **profile-completion** flags (e.g. `has_customer_profile`, `has_nurse_profile` — false for
now; b3 populates the underlying tables), and **nurse verification status** (read from
`nurse_verifications.status` when present; until b6 it returns `not_started`/`null` — never read a
removed `nurse_profiles.verification_status`). Projection only; `AsNoTracking()`.
- **`SelectRoleCommand(role)``MeResult`** — assign **`customer` or `nurse`** to the current user via
`user_roles` (audit `granted_by` = self / system, `granted_at`). **Reject any admin sub-role** with
`403` — admin roles are internal-only and never self-assignable (§5). Idempotent if the role is already
held. A user may hold both `customer` and `nurse`.
### 3.3 Controllers (`Baya.Web.Api/Controllers/V1/`)
Sealed, inherit `BaseController`, inject `ISender`, return `base.OperationResult(...)`, snake_case
`[controller]`/`[action]` routes, `CancellationToken` threaded. Apply the b0 rate-limit policies as noted.
| Method & route (snake_case) | Request → handler | Auth | Rate-limited |
| --- | --- | --- | --- |
| `POST api/v1/auth/otp/request` | `RequestOtpCommand` | none | **yes** (per-phone + per-IP OTP policy) |
| `POST api/v1/auth/otp/verify` | `VerifyOtpCommand` | none | **yes** (verify/brute-force policy) |
| `POST api/v1/auth/refresh` | `RefreshTokenCommand` | `RequireTokenWithoutAuthorization` | **yes** (refresh policy) |
| `POST api/v1/auth/logout` | `LogoutCommand` | authenticated | no |
| `GET api/v1/me` | `GetMeQuery` | authenticated | no |
| `POST api/v1/me/role` | `SelectRoleCommand` | authenticated | no |
> Routes render through the snake_case transformer; document the exact emitted paths in the contract from
> the published `swagger.json` (don't assume the token expansion).
### 3.4 Wire `ISmsSender` for OTP delivery
Introduce **`ISmsSender`** (Application contract) with a real-shaped signature
(`Task SendOtpAsync(string phone, string code, CancellationToken ct)` plus a generic
`SendAsync(string phone, string message, ...)` for later transactional SMS). The **mock** Infrastructure
implementation **logs the code** (structured, never the full PII number in plaintext beyond what logging
policy allows) and returns success; selection is by config/registration (`ServiceConfiguration/`
extension), never an `if (mock)` in the handler. `RequestOtpCommand` calls it after `GenerateOtpCode`.
### 3.5 Out of scope (DEFERRED — do not build)
- Customer national-ID KYC (`customer_profiles.national_id_verified_at`) — column deferred; never gate
browsing on it. → deferred per [`product/business/01-actors-and-onboarding.md`](../../../product/business/01-actors-and-onboarding.md) §(c).
- Push-notification registration, social login, nursing-company (org) self-onboarding. → same doc.
- Profiles, patients, addresses, nurse bank accounts → **[b3](./backend-phase-3.md)**.
- The `is_verified` flip, Shahkar re-verification on phone change (the *handler* that re-runs Shahkar) →
**[b6](./backend-phase-6.md)**. This phase only **resets `shahkar_verified_at` to NULL** when the phone
changes; it does not run Shahkar.
## 4. Mocks & seams in this phase
| Seam | Owner | Mock behaviour | Registry |
| --- | --- | --- | --- |
| **`ISmsSender`** | **introduced here** | logs the OTP code (structured); returns success. Selected by config; real Kavenegar/Ghasedak client swaps in later (implement the same interface, keep idempotency/rate-limit logic). | **add row → 🟡** |
| `IFieldEncryptor` | reuse (b0) | encrypt/decrypt `phone`/`email`/`national_id`, hash `phone`/`refresh_token`. | no change |
| `IDateTimeProvider` | reuse (b0) | clock for OTP/session expiry. | no change |
| `ICacheService` | reuse (b0) | config-accessor cache (b1) only; not new here. | no change |
| `INotificationDispatcher` | reuse (b0/b1) | optional "new login" notification (in-app, b1 write). | no change |
Record `ISmsSender` in [`reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md):
seam name + file, what's faked, config keys (gateway api-key, sender line, OTP TTL), step-by-step how to
make it real (pick gateway, add client package to `Directory.Packages.props`, implement `SendOtpAsync`,
register over the mock by config), status 🟡.
## 5. Critical rules you must not get wrong
- **Phone is the primary credential.** OTP is the only public login. **Email is optional** and required
only for admin — never make email mandatory, never make it a login key.
- **`national_id` is NOT collected at signup** and stays **NULL until KYC** (b6). An unverified
registration must never look KYC-complete. Do not add `national_id` to any auth request body.
- **`shahkar_verified_at` resets to NULL on phone change.** Whenever the stored phone changes (via
`ChangePhoneNumber`), null out `shahkar_verified_at` so b6 re-verifies. Don't silently keep a stale
binding.
- **Sessions are revocable with refresh-token rotation + reuse detection.** Each refresh **rotates**
(old session revoked, new one issued). A refresh presented against an **already-revoked** session is a
**stolen-token signal** → revoke all the user's sessions and `401`. Store only the **hash** of the
refresh token (`IFieldEncryptor.Hash`), never the raw token. Logout must invalidate the session
server-side (and rotate the security stamp so the access token dies too).
- **Admin roles are NEVER self-assignable** via the public `/me/role`. `SelectRoleCommand` accepts only
`customer` / `nurse`; any admin sub-role → `403`. Admin provisioning is internal-only and goes through
the RBAC grant path (audited `granted_by`).
- **OTP is rate-limited and codes expire.** Apply the b0 rate-limit policies to `otp/request`,
`otp/verify`, and `refresh`. Read TTL/max-attempt/lockout knobs from the b1 config accessor. Treat
brute-force / lockout as an explicit handled `400`/`429` state — never an unhandled exception. Do not
enumerate accounts: `otp/request` returns the same shape whether or not the phone existed.
- **`users.gender` is load-bearing** (`male`/`female`) for same-gender matching — the column must exist
and round-trip cleanly now even though it is populated later; never default it to a value or drop it.
- **Encrypt PII at rest** (`phone`, `email`, `national_id`) via `IFieldEncryptor`; never log plaintext PII
or project it into non-authorized responses (`/me` masks the phone).
- **Reuse, don't rebuild.** Tokens come from `IJwtService`; OTP from `IAppUserManager`; RBAC from the
existing permission system. No parallel JWT issuer, no second OTP generator, no `if (mock)` branches.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] The six endpoints exist, are reachable through Swagger, and return the `OperationResult` envelope;
`otp/request`/`otp/verify`/`refresh` are rate-limited; `refresh` carries
`RequireTokenWithoutAuthorization`.
- [ ] `users` is extended (`gender`, `national_id` enc NULL, `national_id_verified_at`,
`shahkar_verified_at`, `phone` enc UNIQUE via hash, `email` enc NULL, `is_active`, `deleted_at`
+ soft-delete query filter); `user_sessions` and the `user_roles` audit columns exist; one clean
migration onto b1's baseline (`dotnet build` zero new warnings, `dotnet ef migrations` applies).
- [ ] Refresh rotation works and a **replayed/old refresh token is rejected** with all sessions revoked;
logout invalidates the session **and** the access token (security-stamp rotation).
- [ ] `SelectRoleCommand` assigns `customer`/`nurse` only; an admin sub-role attempt returns `403`.
- [ ] `ISmsSender` is introduced behind DI, the mock logs the code, the registry row is added (🟡), and
`RequestOtpCommand` calls it (the old TODO log is gone).
- [ ] Handler unit tests (NSubstitute) for verify/refresh/role + at least one `WebApplicationFactory`
integration test per endpoint area (happy path, 401 on `/me` unauthenticated, 400 on bad OTP,
reuse-detection 401 on refresh, 403 on admin self-assign). `dotnet test Baya.sln` green.
- [ ] The **Project map** in `server/CLAUDE.md` notes the new `Identity` feature area, `user_sessions`,
the auth controllers, and the `ISmsSender` seam.
## 7. How to test (what a human can verify after this phase)
A reachable SQL Server is required. Run the API (`dotnet run --project src/API/Baya.Web.Api/...`), open
`/swagger`.
1. **OTP request logs a code.** `POST api/v1/auth/otp/request` with `{ "phone": "09120000000" }``200`
`{ otp_sent: true, ... }`; the **OTP code appears in the server log** (the `ISmsSender` mock). Repeat
immediately past the limit → `429`.
2. **Verify mints tokens + creates a session.** `POST api/v1/auth/otp/verify` with the phone + the logged
code → `200` with `access_token`, `refresh_token`, expiries, `is_new_user: true`, `roles: []`. Confirm
a `user_sessions` row exists (`is_revoked=false`).
3. **`/me` returns the user + roles.** `GET api/v1/me` with `Authorization: Bearer <access_token>``200`
with masked phone, empty `roles`, profile-completion flags false, nurse verification `not_started`/null.
Without the header → `401`.
4. **Role selection.** `POST api/v1/me/role` `{ "role": "customer" }``200`, `/me` now shows
`["customer"]`. `{ "role": "nurse" }` also succeeds (a user can be both). `{ "role": "super_admin" }`
`403`.
5. **Refresh rotates; replay is rejected.** `POST api/v1/auth/refresh` with the refresh token → `200` new
pair (old session now revoked). Replay the **same old** refresh token → `401`, and confirm **all** that
user's sessions are revoked (stolen-token detection).
6. **Logout kills the session and the access token.** `POST api/v1/auth/logout``200`; the prior
`access_token` now fails `/me` with `401` (security-stamp rotation), and the session is `is_revoked`.
7. **Bad OTP.** `otp/verify` with a wrong/expired code → `400` with a safe message (no enumeration).
These steps become the "what is now testable" section of your report.
## 8. Hand off & document (close the phase)
- **Docs to update (same change):** `server/CLAUDE.md`*Project map* (new `Features/Identity` area,
`user_sessions` table + config, the V1 auth controllers, the `ISmsSender` seam and where it's
registered) and *Identity & auth* (note OTP delivery is now real-seamed, sessions exist, role-selection
flow). If you decided any rule not already in the product docs (e.g. the exact reuse-detection response),
reflect it in [`product/business/01-actors-and-onboarding.md`](../../../product/business/01-actors-and-onboarding.md)
— record decisions, don't invent rules.
- **Contract to write:** publish **`dev/contracts/domains/identity-auth.md`** from the
[`_TEMPLATE.md`](../../contracts/domains/_TEMPLATE.md) — the six endpoints (routes, request/response
shapes, auth, rate-limited flags, status codes incl. `401` reuse-detection and `403` admin self-assign),
the `role` enum (`customer`/`nurse` public; admin sub-roles internal), the `AuthTokensResult` /
`MeResult` / `RequestOtpResult` shared shapes (mark the masked phone), and the gender enum note. Then
**publish the `swagger.json` snapshot** per [`openapi/README.md`](../../contracts/openapi/README.md) so
f1-b2 derives types from it, not by guessing.
- **Handoff & report:** write `dev/shared-working-context/backend/handoff/after-backend-phase-2.md` (auth
is live over REST; what f1-b2 can now build — login, OTP, role router, `AuthContext` roles; what's
mocked = SMS delivery), append to `backend/STATUS.md`, write
`dev/shared-working-context/reports/backend-phase-2-report.md` (what was built, what is testable and
exactly how — the §7 steps, what is mocked + how to make `ISmsSender` real, the `identity-auth` contract
produced, follow-ups: profiles/patients/bank in b3, Shahkar/`is_verified` in b6), and update
[`reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) (the `ISmsSender`
row → 🟡).
- **Memory:** save a `project` memory note for the non-obvious decisions — how REST wraps the existing
gRPC/OTP/JWE engine without duplication, the refresh-rotation + reuse-detection design (revoke-all on
replay), the `phone_hash` UNIQUE-on-encrypted pattern, and the rule that admin roles are never
self-assignable — with a one-line `MEMORY.md` pointer.
+341
View File
@@ -0,0 +1,341 @@
# Backend Phase 3 — Identity: profiles, patients & nurse bank accounts
> **Mission:** put the *people* behind the accounts. On top of the bare `users`/sessions/auth spine that
> b2 shipped, build the role-specific profile extensions a marketplace actually transacts against: a
> nurse's seller profile (bio, experience, the `is_accepting_bookings` toggle, and the **guarded**
> `is_verified` flag), a customer's thin payer profile with its emergency contact, the first-class
> **patient** records a customer manages on behalf of the people who can't self-advocate, and the
> **payout bank account** that is the single place real money will one day leave the platform. Every PII
> column here is encrypted; tenancy is enforced so a customer can only ever touch their own patients; and
> the IBAN is hardened with a uniqueness hash and an automated **استعلام شبا** ownership inquiry — not an
> admin's eyeballs. This is the data catalog, verification, booking, and payouts all build directly on.
>
> **Track:** backend · **Depends on:** [b2](./backend-phase-2.md) (users/auth/OTP/sessions/roles), [b0](./backend-phase-0.md) (`IFieldEncryptor`, `ICurrentUser`, audit interceptor, REST surface, seam pattern) · **Unlocks:** geography & addresses (b4), catalog (b5), nurse verification (b6), booking (b8); frontend **f2-b3**
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
This is **backend phase b3**, the second identity phase. b2 turned the inherited auth spine into a real
phone-OTP login: `users` (phone primary, `gender`, nullable `national_id`), `user_sessions` (rotation +
reuse detection), `roles`/`user_roles`, and the role-selection flow. What b2 deliberately did **not**
build is everything *attached* to a user once they pick a role — the seller profile, the payer profile,
the care recipients, and the payout destination. That is this phase. After b3, a nurse has a profile that
verification (b6) can flip to verified and that the catalog (b5) can hang priced service variants off; a
customer has patients and a profile that booking (b8) can act on behalf of; and a nurse has a bank
account that payouts (b13) can pay into. **No geography here** — saved service addresses
(`customer_addresses`) and nurse coverage areas need the province/city/district tables, so they belong to
**b4** (see §3 DEFERRED).
**What already exists (do not rebuild) — built by prior phases:**
- **`users`, auth, sessions, roles** — [b2](./backend-phase-2.md) extended `users` (phone enc UNIQUE,
`email` enc nullable, `national_id` enc nullable, `gender` NVARCHAR(10) `male`/`female`,
`national_id_verified_at`, `shahkar_verified_at`, `role`, `is_active`, `deleted_at`), built
`user_sessions` (refresh-token rotation + reuse detection), `roles`/`user_roles` (admin RBAC), the
`/auth/otp/*`, `/auth/refresh`, `/auth/logout`, `/me`, and `/me/role` endpoints, and the `ISmsSender`
seam. **Read the current user from `ICurrentUser`; resolve the customer/nurse profile off
`users.id` — never re-create the user or session machinery.**
- **`IFieldEncryptor`** — [b0](./backend-phase-0.md) introduced the field-encryption seam:
`Encrypt(string)` / `Decrypt(string)` plus a deterministic `Hash(string)` for lookup columns. **Every
PII column in this phase goes through it** (national_id was wired by b2; you add IBAN, account-holder
name, emergency contacts, medical notes, and the deterministic `iban_hash` here). Never store or log
plaintext PII.
- **`ICurrentUser` + the audit-field SaveChanges interceptor** — [b0](./backend-phase-0.md). Handlers
never set `CreatedById`/`CreatedAt`/`ModifiedById`/`ModifiedAt`; the interceptor stamps them from
`ICurrentUser`. Use `ICurrentUser.UserId` for tenancy resolution.
- **The REST surface & CQRS pipeline** — [b0](./backend-phase-0.md): versioned `sealed : BaseController`
controllers, `ISender`, `base.OperationResult(...)`, snake_case `[controller]`/`[action]` routes, rate
limiting, `LoggingBehavior`, `ValidateCommandBehavior`, `OperationResult<T>`, Mapster, FluentValidation,
CQRS via **`martinothamar/Mediator`** (not MediatR), `IDateTimeProvider`.
- **`platform_configs` typed cached accessor** — [b1](./backend-phase-1.md). If you need a tunable (e.g. a
max-patients-per-customer guard), read it through the accessor; never hardcode.
**What this phase introduces:** the four new domain tables (`nurse_profiles`, `customer_profiles`,
`patients`, `nurse_bank_accounts`), their CRUD/management capabilities, and **one new seam —
`IBankAccountOwnershipVerifier`** (the mocked استعلام شبا IBAN-owner ↔ national-id inquiry). The
`partner_center_id` FK on `nurse_profiles` is a **forward dependency** on b15 — declare it nullable and do
not enforce or build `partner_centers` here.
## 2. Required reading (do this first)
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md) —
especially *Persistence* (AsNoTracking + `.Select` projection, pagination, one
`IEntityTypeConfiguration<T>` per entity, soft-delete filters, the encrypted-PII rule) and *CQRS*.
- [`product/business/01-actors-and-onboarding.md`](../../../product/business/01-actors-and-onboarding.md)
**the business rules**: phone is the primary credential; the **customer ≠ patient** split (the payer
is frequently not the care recipient); patient `gender` is load-bearing for same-gender matching;
customer national-ID KYC is **DEFERRED**; a nurse is not bookable until verified.
- [`product/data-model/01-identity-and-access.md`](../../../product/data-model/01-identity-and-access.md)
**the canonical schema** for `nurse_profiles`, `customer_profiles`, `patients`, `nurse_bank_accounts`.
Mirror the field names and constraints exactly: the guarded `is_verified`, the filtered uniques, the
`iban_hash` UNIQUE, the `matched_national_id`/`account_holder_from_bank`/`ownership_vendor_ref` trio, and
the **CUT** columns (`nurse_profiles.verification_status`, `response_rate`, `avg_response_time_hours`,
`profile_completion_score`; `customer_profiles.national_id_verified_at`) — **do not reintroduce them.**
- [`product/overview/platform-summary.md`](../../../product/overview/platform-summary.md) — the four
ground truths and the encrypt-PII-at-rest expectation that shapes every column here.
- **Code to mirror:** b2's `users`/`user_sessions` entity configs and the `Features/Identity/**` (or
`Features/Auth/**`) command/handler structure — your new features sit alongside them; b0's
`IFieldEncryptor` usage and any existing encrypted-column converter pattern; the existing
`IEntityTypeConfiguration<T>` files in `Persistence/Configuration/` for the filtered-index and
value-converter syntax.
- **Contract conventions:** [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
(envelope, snake_case routes, pagination, auth) and
[`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md) (the
**PII & sensitive fields** masking rule — IBAN returned masked, last-4 only — and `gender` as
load-bearing).
- **Prior handoffs:** `dev/shared-working-context/backend/handoff/after-backend-phase-2.md`,
`…-after-backend-phase-0.md`, and `reports/mocks-registry.md` (the seam rows you reuse + the new one you add).
## 3. Scope — build this
All features live under `Baya.Application/Features/Identity/{Commands|Queries}/<Name>/`; entities under
`Baya.Domain/Entities/Identity/`; one `IEntityTypeConfiguration<T>` per entity in
`Persistence/Configuration/IdentityConfig/`; **one EF migration** for the four new tables. Reads use
`AsNoTracking()` + `.Select(...)` to a DTO and are paginated where they list; writes go through
`IUnitOfWork` with a single `CommitAsync` per command and never throw for expected failures (return
`OperationResult.SuccessResult/FailureResult/NotFoundResult`). Encrypted columns route through
`IFieldEncryptor` (an EF value converter or in-handler encrypt — mirror b2's national_id approach).
### 3.1 Entities + migration
**`nurse_profiles`** [CORE] — the nurse's seller profile + denormalized search/quality aggregates.
- Fields: `id` (BIGINT PK); `user_id` (FK → `users` **UNIQUE**, 1:1); `partner_center_id` (FK →
`partner_centers` **NULL** — forward dependency on b15, nullable, **no FK enforcement target built
here**); `bio`, `years_of_experience`, `education_level`, `education_field`, `specializations_json`;
**`is_verified`** (BIT NOT NULL DEFAULT 0 — **GUARDED**, see §5); `is_accepting_bookings` (BIT NOT NULL
DEFAULT 0); denormalized read-only aggregates `average_rating`, `total_reviews`,
`total_completed_bookings`; `created_at`, `updated_at`, `deleted_at` (soft-delete).
- **`is_verified` is write-guarded** — model it with **no public setter** (private/internal-set, or a
`MarkVerified()`/`MarkUnverified()` domain method only the b6 verification-confirm transaction calls).
No command, controller, or mapping in *this* phase may set it. See §5.
- **Aggregates are read-only here** — `average_rating`/`total_reviews`/`total_completed_bookings` default
to 0 and are recomputed by reviews/bookings phases; never accept them from a request body.
- **Do NOT add** `verification_status`, `response_rate`, `avg_response_time_hours`,
`profile_completion_score` (CUT — `nurse_verifications.status` from b6 is the sole verification truth).
- Soft-delete global query filter on `deleted_at IS NULL`.
- Relations: 1:1 → `users`; (forward) 1:N → `nurse_service_variants` (b5), `nurse_service_areas` (b4),
`nurse_bank_accounts` (this phase), `nurse_verifications` (b6), `bookings`/`nurse_payouts` (later).
**`customer_profiles`** [CORE] — the thin payer extension.
- Fields: `id` (BIGINT PK); `user_id` (FK → `users` **UNIQUE**, 1:1); `default_emergency_contact_name`
(**enc**); `default_emergency_contact_phone` (**enc**); `created_at`, `updated_at`.
- **Do NOT add** `national_id_verified_at` (CUT for MVP — customer national-ID KYC is DEFERRED; the column
is not even created at launch).
- Relations: 1:1 → `users`; 1:N → `patients` (this phase), `customer_addresses` (b4), `booking_requests`
(b8).
**`patients`** [CORE] — the care recipient, a first-class entity separate from the payer.
- Fields: `id` (BIGINT PK); `customer_id` (FK → `customer_profiles`); `display_name`, `first_name`,
`last_name`; `birth_date` (DATE); `gender` (NVARCHAR(10) — `male`/`female`, **required**,
same-gender-matching signal); `blood_type` (nullable); `initial_medical_notes` (**enc**); `is_active`
(BIT, archive flag); `created_at`, `updated_at`.
- **Tenancy invariant** (see §5): a patient belongs to exactly one `customer_id`; every read/write is
scoped to the signed-in customer; a patient referenced by a booking must belong to that booking's
`customer_id` (enforced fully in b8, but the ownership scoping starts here).
- Relations: N:1 → `customer_profiles`; (forward) 1:N → `booking_requests` (b8), `patient_care_records` (b14).
**`nurse_bank_accounts`** [CORE] — the payout destination; the single place real money leaves the platform.
- Fields: `id` (BIGINT PK); `nurse_id` (FK → `nurse_profiles`); `bank_name`; `account_holder_name`
(**enc**); `iban` (**enc**); **`iban_hash`** (NVARCHAR(64) — deterministic `IFieldEncryptor.Hash(iban)`,
**`UNIQUE`** so one IBAN can't silently serve two nurses); `is_primary` (BIT); **`matched_national_id`**
(BIT **NULL** — result of the استعلام شبا IBAN-owner ↔ national-id inquiry; NULL until the inquiry runs;
first payout is gated on `true` in b13); `account_holder_from_bank` (NVARCHAR(200) NULL — name the bank
returned, snapshot); `ownership_vendor_ref` (NVARCHAR(200) NULL — vendor transaction id for audit);
`is_verified` (BIT); `verified_by_admin_id` (FK → `users` NULL); `verified_at` (NULL); `created_at`,
`updated_at`.
- **Constraints:** `UNIQUE(iban_hash)`; **filtered `UNIQUE(nurse_id) WHERE is_primary = 1`** (exactly one
primary account per nurse). Both are DB indexes in the migration — the authoritative backstop, not just
handler logic.
- Relations: N:1 → `nurse_profiles`; (forward) 1:N → `nurse_payouts` (b13). The b6
`bank_account_verification` step couples to this table — build the account + inquiry here, the
verification step there.
### 3.2 Commands & queries (CQRS, `OperationResult`, never throw for expected failures)
| Capability | Type | Route | What it does |
| --- | --- | --- | --- |
| **`UpsertNurseProfileCommand`** | Command | `POST api/v1/nurse_profiles/upsert` | Creates (on first call) or updates the signed-in nurse's profile: `bio`, `years_of_experience`, `education_level`, `education_field`, `specializations_json`. Resolves `user_id` from `ICurrentUser`; rejects if the user's role isn't `nurse`. **Never** accepts `is_verified` or the aggregates. Idempotent on `user_id` (the UNIQUE 1:1 is the backstop). |
| **`SetNurseAcceptingBookingsCommand`** | Command | `POST api/v1/nurse_profiles/set_accepting_bookings` | Toggles `is_accepting_bookings` for the signed-in nurse (pause/resume without touching verified status). |
| **`GetMyNurseProfileQuery`** | Query | `GET api/v1/nurse_profiles/me` | Projects the signed-in nurse's profile incl. read-only `is_verified` + aggregates. AsNoTracking + `.Select`. |
| **`UpsertCustomerProfileCommand`** | Command | `POST api/v1/customer_profiles/upsert` | Creates/updates the signed-in customer's profile + `default_emergency_contact_name`/`_phone` (**encrypted**). Resolves `user_id` from `ICurrentUser`; rejects non-`customer` role. |
| **`GetMyCustomerProfileQuery`** | Query | `GET api/v1/customer_profiles/me` | Projects the customer's profile; emergency-contact phone returned **masked** to non-self callers per the masking rule (self sees full). |
| **`CreatePatientCommand`** | Command | `POST api/v1/patients/create` | Creates a patient under the signed-in customer (`customer_id` derived from `ICurrentUser``customer_profiles`, **never** from the request). Validates required `gender`, name, `birth_date`; `initial_medical_notes` encrypted. |
| **`ListPatientsQuery`** | Query | `GET api/v1/patients/list?page=&page_size=` | Lists the signed-in customer's **own** patients only (tenancy-scoped). Projected + paginated; `initial_medical_notes` returned to the owning customer only. |
| **`GetPatientQuery`** | Query | `GET api/v1/patients/get/{id}` | Returns one patient **only if it belongs to the signed-in customer** — otherwise `NotFoundResult` (don't leak existence). |
| **`UpdatePatientCommand`** | Command | `POST api/v1/patients/update/{id}` | Updates a patient the signed-in customer owns; tenancy-checked; re-encrypts changed PII. |
| **`ArchivePatientCommand`** | Command | `POST api/v1/patients/archive/{id}` | Sets `is_active = false` (soft archive, not hard delete — preserves longitudinal history for b14). Tenancy-checked. |
| **`AddNurseBankAccountCommand`** | Command | `POST api/v1/nurse_bank_accounts/add` | Adds a payout account for the signed-in nurse: `bank_name`, `account_holder_name` (enc), `iban` (enc) + computed `iban_hash`. **Rejects a duplicate IBAN** via the `iban_hash` UNIQUE (return a clean `409`/`FailureResult`, not an unhandled DB exception). Then **triggers `IBankAccountOwnershipVerifier`** (see §4) and persists `matched_national_id`, `account_holder_from_bank`, `ownership_vendor_ref`. If the nurse has no other account, this one may default to `is_primary` (subject to the filtered-unique rule). |
| **`SetPrimaryBankAccountCommand`** | Command | `POST api/v1/nurse_bank_accounts/set_primary/{id}` | Makes one of the nurse's accounts primary: clears the prior primary and sets this one, in **one transaction**, so the filtered `UNIQUE(nurse_id) WHERE is_primary=1` never trips. A duplicate-primary attempt without clearing the old one must be blocked (the filtered unique is the backstop). Tenancy-checked. |
| **`ListNurseBankAccountsQuery`** | Query | `GET api/v1/nurse_bank_accounts/list` | Lists the signed-in nurse's accounts with **masked IBAN** (last 4 only), `is_primary`, `is_verified`, `matched_national_id`. Projected. |
| **`TriggerBankAccountOwnershipInquiryCommand`** | Command | `POST api/v1/nurse_bank_accounts/verify_ownership/{id}` | Re-runs the استعلام شبا inquiry for an existing account (e.g. after a failed/NULL first attempt) and updates `matched_national_id`/`account_holder_from_bank`/`ownership_vendor_ref`. Idempotent — re-running with the same input yields the same vendor ref from the mock. |
- **Controllers:** `NurseProfilesController`, `CustomerProfilesController`, `PatientsController`,
`NurseBankAccountsController` — each `sealed : BaseController`, inject `ISender`, return
`base.OperationResult(...)`, snake_case `[controller]`/`[action]` routes, `CancellationToken` threaded.
Authorize with the narrowest fitting policy: nurse-scoped endpoints require the nurse role, customer-scoped
the customer role; the bank-account ownership-inquiry endpoints are **rate-limited** (they call a vendor seam).
- **Validators:** FluentValidation on every input-bearing command — `gender` required + in
(`male`,`female`) on `CreatePatientCommand`; IBAN format (IR + 24 digits, Sheba) on
`AddNurseBankAccountCommand`; non-empty names; phone format on the emergency contact.
- **Mapping:** Mapster in the handler *after* the projected query — never hydrate an entity to map it.
### 3.3 DEFERRED (build the seam/flag, not the feature — with a pointer)
- **`customer_addresses`** + nurse **`nurse_service_areas`** — need the province/city/district hierarchy
and the geocoder. **DEFERRED to [b4](./backend-phase-4.md).** Do not create these tables or their CRUD
here; the digest is explicit that addresses need geography first.
- **Customer national-ID KYC** (`customer_profiles.national_id_verified_at`) — DEFERRED per
[`product/business/01-actors-and-onboarding.md`](../../../product/business/01-actors-and-onboarding.md)
§(c). The column is **not created** at MVP; do not collect or gate browsing/booking on it.
- **Admin staff-role grant/revoke** (`roles`/`user_roles` management UI) — the tables exist from b2; the
admin management endpoints land with the admin backoffice in [b15](./backend-phase-15.md). Don't build
them here.
- **Nurse aggregate recompute** (writing `average_rating`/`total_reviews`/`total_completed_bookings`) —
the *columns* are created here read-only; the recompute writes come from reviews/bookings phases (b9/b14).
- **`is_verified` flip** — the *guarded model* is built here; the flip itself is the b6 verification-confirm
transaction. Expose **no** write path for it in this phase.
## 4. Mocks & seams in this phase
| Seam | Owner | Mock behaviour | Registry |
| --- | --- | --- | --- |
| **`IBankAccountOwnershipVerifier`** | **introduced here** | `VerifyOwnershipAsync(iban, nurseNationalId, ct)` returns an `OwnershipInquiryResult` with `MatchedNationalId` (bool), `AccountHolderFromBank` (string), `VendorRef` (string). Mock = **deterministic fake match**: for a normal seeded/test IBAN it returns `MatchedNationalId = true`, echoes a plausible `AccountHolderFromBank` (e.g. the submitted holder name), and a fake `VendorRef` (e.g. `MOCK-SHEBA-{hash}`); for a designated **mismatch** test IBAN it returns `MatchedNationalId = false` (and a different holder name) so the ownership-mismatch path is testable. **No real bank/KYC call, no money moves.** | **add a new row** (🟡) |
| `IFieldEncryptor` | reuse from **b0** | local symmetric key from config; encrypts `iban`, `account_holder_name`, `initial_medical_notes`, emergency contacts, and computes the deterministic `iban_hash` via `Hash(iban)`. Never logs plaintext. | reuse row |
| `ICurrentUser` | reuse from **b0** | resolves `UserId` for tenancy scoping. | reuse row |
| `IDateTimeProvider` | reuse from **b0** | testable `UtcNow` for `verified_at`/audit. | reuse row |
The mock lives behind a **DI-registered interface** in Infrastructure (real impl is a drop-in later);
selection is **config-driven, never an `if (mock)` branch** in a handler. Append the
`IBankAccountOwnershipVerifier` row to
[`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md)
(seam, file, what's faked, config keys, **step-by-step how to make it real** — pick a Finnotech/banking-bridge
استعلام شبا provider, add its client + package + settings, implement `VerifyOwnershipAsync` against the real
Sheba-owner inquiry, persist the real `ownership_vendor_ref`/`external_response_json`, and what to test).
> Do **not** pre-build the verification-pipeline seams (`IShahkarVerifier`, `IIdentityKycProvider`,
> `ICredentialVerifier`) — those are introduced in [b6](./backend-phase-6.md). This phase owns only
> `IBankAccountOwnershipVerifier`.
## 5. Critical rules you must not get wrong
- **`is_verified` is write-guarded — never expose a setter.** Model `nurse_profiles.is_verified` with no
public setter (private-set + a domain method the verification transaction calls). It is flipped **ONLY**
inside the b6 verification-confirm transaction once every required `verification_steps.status='passed'`.
No command, controller, mapping, or update in *this* phase may set it; a profile is created with
`is_verified = 0` and stays there until b6. A nurse is **not bookable** until verified — onboarding a
profile must never imply bookability.
- **Tenancy invariant — a patient belongs to its customer, always.** Resolve `customer_id` from
`ICurrentUser`, **never** from the request body. Every patient read/write is scoped to the signed-in
customer; reading or mutating another customer's patient returns `NotFoundResult` (don't leak existence).
A patient used in a booking must belong to the same `customer_id` (fully enforced in b8, scoping begins
here). The same ownership scoping applies to `nurse_bank_accounts` (a nurse only ever touches their own).
- **Encrypt all PII at rest.** `national_id` (already, from b2), `iban`, `account_holder_name`,
`default_emergency_contact_name`, `default_emergency_contact_phone`, and `initial_medical_notes` route
through `IFieldEncryptor`. Never store or log plaintext; never project plaintext PII into a non-authorized
response. On the wire, IBAN is **masked** (last 4) in list responses per the masking rule.
- **`iban_hash` is UNIQUE — one IBAN can't serve two nurses.** Compute it deterministically via
`IFieldEncryptor.Hash(iban)` and rely on the `UNIQUE(iban_hash)` index as the authoritative duplicate
guard. A duplicate add is a **clean** `409`/`FailureResult`, not an unhandled `DbUpdateException`. (The
encrypted `iban` column itself is non-deterministic and can't be uniquely indexed — that's *why* the hash
exists.)
- **Exactly one primary bank account per nurse.** The filtered `UNIQUE(nurse_id) WHERE is_primary = 1`
index is the backstop; `SetPrimaryBankAccountCommand` clears the old primary and sets the new one in **one
transaction** so the constraint never trips. Never let two `is_primary = 1` rows exist for a nurse.
- **First payout is gated on `matched_national_id = true`** — set here by the استعلام شبا inquiry
(`IBankAccountOwnershipVerifier`), enforced at payout time in [b13](./backend-phase-13.md). Never mark a
payout-ready state off admin eyeballing; the holder-national-id ↔ verified-nurse-national-id match (money-mule
prevention) is the gate. `matched_national_id` starts NULL and only the inquiry sets it.
- **Customer national-ID KYC is DEFERRED** — do not create `national_id_verified_at` on
`customer_profiles`, do not collect it, and **never gate customer browsing or booking on it.**
- **Aggregates and `is_verified` are read-only inputs to this phase.** `average_rating`, `total_reviews`,
`total_completed_bookings` default to 0 and are written only by later phases; reject any attempt to set
them (or `is_verified`) from a request body.
- **Reads are projected + paginated; money/PII never leaks.** AsNoTracking + `.Select` to a DTO on every
read; `ListPatientsQuery`/`ListNurseBankAccountsQuery` paginate; no unbounded `ToListAsync()`. Audit fields
are stamped by the interceptor, not the handler.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] The four tables (`nurse_profiles`, `customer_profiles`, `patients`, `nurse_bank_accounts`) exist via
**one migration**, each with its `IEntityTypeConfiguration<T>`: the 1:1 UNIQUE on `nurse_profiles.user_id`
and `customer_profiles.user_id`, the `UNIQUE(iban_hash)`, the filtered `UNIQUE(nurse_id) WHERE is_primary=1`,
the guarded (no-public-setter) `is_verified`, encrypted PII columns, soft-delete filter on
`nurse_profiles`, and audit wiring. **None** of the CUT columns are present.
- [ ] All §3.2 commands/queries implemented (CQRS, `OperationResult`, projected + paginated reads,
FluentValidation validators), with the four controllers, each role-scoped and tenancy-enforced.
- [ ] **`IBankAccountOwnershipVerifier`** introduced (Application interface, Infrastructure mock, DI
registration via a `ServiceConfiguration/` extension, config-selected). No `if (mock)` in handlers.
- [ ] Tenancy is provably enforced (a customer cannot read/mutate another customer's patient; a nurse cannot
touch another nurse's bank account); duplicate-IBAN and duplicate-primary are blocked by the DB
indexes and surfaced as clean failures.
- [ ] Handler unit tests (NSubstitute) for: nurse/customer profile upsert; patient CRUD + the
**cross-customer rejection**; bank-account add → ownership-inquiry sets `matched_national_id`; the
mismatch-IBAN path; `set_primary` flips the filtered-unique correctly; duplicate-IBAN rejected via
`iban_hash`. ≥1 `WebApplicationFactory` integration test per controller (happy path, 401, validation 400).
`dotnet build Baya.sln` zero new warnings; `dotnet test Baya.sln` green.
- [ ] The `Baya.Application/Features/Identity/**` profile/patient/bank areas and the
`IBankAccountOwnershipVerifier` seam are reflected in the **Project map** in `server/CLAUDE.md`.
- [ ] The contract `dev/contracts/domains/identity-profiles.md` is written and the `swagger.json` snapshot
republished.
## 7. How to test (what a human can verify after this phase)
Log in as a nurse and as a customer (reuse b2's OTP flow / a seeded session). Then:
1. **Nurse profile**`POST api/v1/nurse_profiles/upsert` with bio/experience → a `nurse_profiles` row is
created with `is_verified = 0` and `is_accepting_bookings = 0`; `GET api/v1/nurse_profiles/me` returns it
with read-only aggregates at 0. Confirm there is **no** endpoint or body field that can set `is_verified`.
2. **Accepting-bookings toggle**`POST api/v1/nurse_profiles/set_accepting_bookings` → flips
`is_accepting_bookings`; `is_verified` is untouched.
3. **Customer profile**`POST api/v1/customer_profiles/upsert` with an emergency contact → row created;
`GET …/me` returns it; verify the stored emergency-contact phone is **encrypted at rest** (inspect the
column — it is ciphertext, not the plaintext number).
4. **Patient CRUD**`POST api/v1/patients/create` (with required `gender`) → patient created under the
signed-in customer; `GET api/v1/patients/list` shows it; `update`/`archive` work; `initial_medical_notes`
is ciphertext at rest.
5. **Tenancy rejection** — as customer **A**, create a patient; as customer **B**, call
`GET api/v1/patients/get/{A's id}` and `POST api/v1/patients/update/{A's id}` → both return **404/not-found**
(B can never see or mutate A's patient). This is the load-bearing tenancy test.
6. **Add bank account + ownership inquiry**`POST api/v1/nurse_bank_accounts/add` with a normal test IBAN →
account created; the mock `IBankAccountOwnershipVerifier` runs and sets `matched_national_id = true`,
`account_holder_from_bank`, and `ownership_vendor_ref`; `GET …/list` shows the IBAN **masked** (last 4).
7. **Ownership mismatch** — add an account with the designated **mismatch** test IBAN → `matched_national_id =
false` and the mismatch holder name is recorded (the payout-gating path can later reject it).
8. **Duplicate IBAN** — add the **same** IBAN again (same nurse or a second nurse) → rejected with a clean
`409`/failure via the `iban_hash` UNIQUE, **not** an unhandled exception.
9. **Primary flip** — add a second account and `POST api/v1/nurse_bank_accounts/set_primary/{id2}` → account 2
becomes primary and account 1 is no longer primary; at no point do two `is_primary = 1` rows exist (the
filtered unique holds). Attempting to force a second primary without clearing the first is blocked.
## 8. Hand off & document (close the phase)
- **Docs to update:** the **Project map** in [`server/CLAUDE.md`](../../../server/CLAUDE.md) (add the
`Features/Identity/**` profile/patient/bank areas + the `IBankAccountOwnershipVerifier` seam and where it's
registered). If you discover/confirm a rule the product docs don't capture (e.g. the masked-IBAN-on-list
behaviour, or the default-first-account-becomes-primary behaviour), record it in
[`product/data-model/01-identity-and-access.md`](../../../product/data-model/01-identity-and-access.md) —
don't invent rules.
- **Contract to write:** **`dev/contracts/domains/identity-profiles.md`** (per
[`../../contracts/domains/_TEMPLATE.md`](../../contracts/domains/_TEMPLATE.md)) — the nurse-profile,
customer-profile, patient, and nurse-bank-account endpoints; the `gender` (`male`/`female`) and
`blood_type` enums; the profile/patient/bank-account DTO shapes (read-only `is_verified` + aggregates;
**masked** IBAN; encrypted fields stated as masked vs full); auth/role/rate-limit notes; and the tenancy,
guarded-`is_verified`, `iban_hash`-uniqueness, single-primary, and `matched_national_id`-gates-first-payout
side-effects. Republish the `swagger.json` snapshot per
[`../../contracts/openapi/README.md`](../../contracts/openapi/README.md). This is what **f2-b3** consumes.
- **Handoff & report:** write `dev/shared-working-context/backend/handoff/after-backend-phase-3.md` (profiles,
patients, and nurse bank accounts are live; what **f2-b3** can now build — "who is care for"/patient
add-list-edit, customer profile, nurse profile bootstrap, nurse bank-account settings; which
endpoints/contracts are live; that addresses/service-areas are **DEFERRED to b4**; that the IBAN-ownership
rail is mocked behind `IBankAccountOwnershipVerifier`), append to `backend/STATUS.md`, write
`dev/shared-working-context/reports/backend-phase-3-report.md` (what was built, **what is now testable and
exactly how** per §7, what is mocked + how to make it real, contracts produced, follow-ups: addresses (b4),
the `is_verified` flip (b6), payout gating on `matched_national_id` (b13), aggregate recompute (b9/b14)),
and update `dev/shared-working-context/reports/mocks-registry.md` (the `IBankAccountOwnershipVerifier`
row → 🟡).
- **Memory:** save a `project` memory note for the non-obvious decisions this phase fixes — the guarded
`is_verified` (no setter, flipped only in b6), the patient tenancy invariant + cross-customer-returns-404
rule, the `iban_hash` deterministic-hash uniqueness, the filtered single-primary index, and the
`matched_national_id`-gates-first-payout link to b13 — with a one-line pointer in `MEMORY.md`.
+332
View File
@@ -0,0 +1,332 @@
# Backend Phase 4 — Geography, addresses & nurse service areas
> **Mission:** lay the geographic spine the whole marketplace stands on. Build the
> **province → city → district** reference hierarchy (as *tables*, not hardcoded lists, so new regions
> launch without a deploy), seed it with Iran's provinces, major cities, and **Tehran's 22 municipal
> districts**; let admins curate it with `is_active`/`sort_order` toggles; serve the **cascading
> dropdowns** that every address form and search filter needs; let a **nurse declare where they will
> travel** (`nurse_service_areas`, city or city+district, `district_id=NULL` meaning *the whole city*);
> and let a **customer save service addresses** with a single enforced primary, encrypted at rest, and
> geocoded coordinates behind the **`IGeocoder`** seam. After this phase, catalog/search and booking have
> the geography they consume.
>
> **Track:** backend · **Depends on:** [b3](./backend-phase-3.md) (`nurse_profiles`, `customer_profiles`), [b0](./backend-phase-0.md) (the `IGeocoder` host, `IFieldEncryptor`, REST surface, audit/caching seams), [b1](./backend-phase-1.md) (the seed pattern + typed config accessor) · **Unlocks:** catalog/search ([b5](./backend-phase-5.md)/[b7](./backend-phase-7.md)), booking ([b8](./backend-phase-8.md)); frontend **f3-b4**
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
This is **backend phase b4**, a near-root reference domain that almost everything downstream consumes.
Balinyaar matches families to nurses **by named place**, not by GPS radius: a customer's saved address
lives in a `city` (and optionally a `district`), a nurse declares the `cities`/`districts` they will
travel to, and search later intersects the two. The geo hierarchy is stored in tables (not a static code
list) precisely so a new city or district can be launched by an admin insert — and so `is_active` /
`sort_order` give ordered, toggleable dropdowns without ever deleting a region that has historical rows
pointing at it. This phase builds that hierarchy, the two membership tables that hang off it
(`nurse_service_areas`, `customer_addresses`), and the **`IGeocoder`** seam that turns a typed address
into the lat/lng EVV will later check against.
**What already exists (do not rebuild) — built by prior phases:**
- **`nurse_profiles` & `customer_profiles`** — [b3](./backend-phase-3.md) built the role profile
extensions off `users` (`nurse_profiles.id` 1:1 with a nurse `user`; `customer_profiles.id` 1:1 with a
customer `user`), plus `patients` and `nurse_bank_accounts` and their tenancy rules. `nurse_service_areas`
hangs off `nurse_profiles`; `customer_addresses` hangs off `customer_profiles`. **Reuse those FKs — do
not re-create the profiles.**
- **The `IGeocoder` host** — [b0](./backend-phase-0.md) reserved the registry row for the geocoding seam
(it is *introduced* here, see §4). b0 also built **`IFieldEncryptor`** (the encrypt/decrypt + deterministic
`Hash` used by every encrypted PII column — addresses use it), **`ICacheService`** (the in-memory cache
the geo-lookup queries read through), **`IDateTimeProvider`**, **`ICurrentUser`** + the audit-field
SaveChanges interceptor, the REST controller surface (`sealed : BaseController`, `ISender`,
`base.OperationResult(...)`, snake_case routes), rate limiting, and `LoggingBehavior`.
- **The seed + typed-config pattern** — [b1](./backend-phase-1.md) established how reference/seed data is
loaded into the marketplace baseline (the first migration baseline, the seeding mechanism, the typed
cached `platform_configs` accessor). **Mirror b1's seeding mechanism** for the province/city/district
seed — do not invent a parallel one.
- The b0 foundation generally: Clean-Arch projects, CQRS via **`martinothamar/Mediator`** (`ISender`,
`ICommand`/`IQuery`, `internal sealed` handlers), `OperationResult<T>`, Mapster, FluentValidation,
`IUnitOfWork`, soft-delete query filters, the `ServiceConfiguration/` registration convention.
**What this phase introduces:** the five tables below, the admin/public/nurse/customer capabilities over
them, and **one new seam — `IGeocoder`** (the mocked maps/geocoding provider). No GPS-radius model, no
map-tile rendering, no availability — those are out of scope (see DEFERRED tags throughout).
## 2. Required reading (do this first)
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md) —
especially *Persistence* (projection + pagination, one `IEntityTypeConfiguration<T>` per entity,
soft-delete filters, **encrypted PII columns go through the field encryptor seam**) and *Performance,
caching* (read-heavy reference data is cached behind the cache seam with sensible invalidation).
- [`product/data-model/02-geography.md`](../../../product/data-model/02-geography.md) — **the canonical
geo schema**: `provinces` 1:N `cities` 1:N `districts` with `sort_order`/`is_active`; `districts` are
*optional* (Tehran's 22 مناطق / neighborhoods elsewhere); `nurse_service_areas` with `district_id=NULL`
= whole city and `UNIQUE(nurse_id, city_id, district_id)`; **named districts, not a GPS radius**.
- [`product/data-model/01-identity-and-access.md`](../../../product/data-model/01-identity-and-access.md) —
the **`customer_addresses`** definition (encrypted address + coordinates for EVV, filtered
`UNIQUE(customer_id) WHERE is_primary=1`) and how `nurse_service_areas` relates to `nurse_profiles`.
- [`product/business/04-search-and-matching.md`](../../../product/business/04-search-and-matching.md) —
**why this exists**: geography is driven by nurse-declared service areas; a city-level row (no district)
means the whole city; a city search must later match both city-only rows *and* any district row in that
city; districts are optional and vary (Tehran's 22 official مناطق vs neighborhoods elsewhere). This is
the consumer that makes `district_id=NULL` semantics load-bearing — get them right here.
- **The digests** (authoritative per-domain detail): the *Geography* and *Identity & Access →
`customer_addresses`/`nurse_service_areas`* sections of `dm_identity_geo_services_verif.md`, and the
*Search & Matching → geo* section of `biz_catalog_search.md` (in the run's `digests/`).
- **Code to mirror:** b3's `nurse_profiles`/`customer_profiles` configs and the `customer`/`nurse` feature
command structure; b1's seeding mechanism + the typed cached config accessor + the first migration
baseline it created (you add one migration on top); b0's `IFieldEncryptor` usage on PII columns, the
`ICacheService` `GetOrCreateAsync` pattern, and any `ServiceConfiguration/` seam registration.
- **Contract conventions:** [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
(envelope, snake_case routes, pagination shape) and
[`money-and-types.md`](../../contracts/conventions/money-and-types.md) (not money here, but the id/type
conventions — ids are `BIGINT`, coordinates are `decimal`, never `float`).
- **Prior handoffs:** `dev/shared-working-context/backend/handoff/after-backend-phase-3.md`,
`…-1.md`, `…-0.md`, and `reports/mocks-registry.md` (the `IGeocoder` row you flip from reserved → 🟡).
## 3. Scope — build this
Features live under `Baya.Application/Features/Geography/{Commands|Queries}/<Name>/`,
`Baya.Application/Features/ServiceAreas/{Commands|Queries}/<Name>/`, and
`Baya.Application/Features/Addresses/{Commands|Queries}/<Name>/`. Entities go in
`Baya.Domain/Entities/Geography/` (provinces/cities/districts/nurse_service_areas) and
`Baya.Domain/Entities/Identity/` (customer_addresses, next to b3's profiles — it is an identity-domain
table per the data model). One `IEntityTypeConfiguration<T>` per entity in
`Persistence/Configuration/GeographyConfig/` and `…/IdentityConfig/`. **One EF migration** for the five
tables. Ids are `BIGINT`; coordinates are `decimal(9,6)` (never `float`/`double`).
### 3.1 Entities + migration
**`provinces`** [CORE] — top of the geo hierarchy.
- Fields: `id` (BIGINT PK), `name_fa`, `name_en` (NVARCHAR — Persian primary, both required),
`sort_order` (int, default 0), `is_active` (BIT, default 1), audit fields, `deleted_at` (soft-delete).
- Relations: 1:N → `cities`.
**`cities`** [CORE] — the main address/search granularity.
- Fields: `id` (BIGINT PK), `province_id` (FK → `provinces`), `name_fa`, `name_en`, `sort_order`,
`is_active`, audit fields, `deleted_at`.
- Index: `(province_id, sort_order)` for the ordered cascading lookup.
- Relations: N:1 → `provinces`; 1:N → `districts`, `nurse_service_areas`, `customer_addresses`.
**`districts`** [CORE]/[MVP] — Tehran's 22 municipal مناطق / major neighborhoods elsewhere; **optional**.
- Fields: `id` (BIGINT PK), `city_id` (FK → `cities`), `name_fa`, `name_en`, `sort_order`, `is_active`,
audit fields, `deleted_at`.
- Index: `(city_id, sort_order)`.
- Relations: N:1 → `cities`; 1:N → `nurse_service_areas`, `customer_addresses` (both nullable refs).
**`nurse_service_areas`** [CORE] — where a nurse will travel; the membership row search later intersects.
- Fields: `id` (BIGINT PK), `nurse_id` (FK → `nurse_profiles`), `city_id` (FK → `cities`),
`district_id` (FK → `districts`, **NULLABLE**), `is_active` (BIT, default 1), audit fields, `deleted_at`.
- **Constraint:** `UNIQUE(nurse_id, city_id, district_id)` — and it **must include the NULL `district_id`
rows**. On SQL Server a plain unique index treats NULLs as distinct, which would *wrongly* allow two
"whole city" rows for the same nurse+city. Enforce the whole-city uniqueness deliberately: either a
**filtered unique index** `UNIQUE(nurse_id, city_id) WHERE district_id IS NULL` *plus*
`UNIQUE(nurse_id, city_id, district_id) WHERE district_id IS NOT NULL`, or a computed/sentinel column
so the single index covers both. Whichever you pick, a duplicate "whole city" and a duplicate
"city+district" are **both** rejected. (See §5 — this is the rule most easily gotten wrong.)
- Relations: N:1 → `nurse_profiles`, `cities`, `districts`.
**`customer_addresses`** [CORE] — saved service locations; encrypted address + coordinates for EVV.
- Fields: `id` (BIGINT PK), `customer_id` (FK → `customer_profiles`), `city_id` (FK → `cities`),
`district_id` (FK → `districts`, NULLABLE), `title` (NVARCHAR — "خانه"/"محل کار", a label, not PII),
`address_line` (**encrypted** via `IFieldEncryptor`), `postal_code` (**encrypted**, nullable),
`latitude` / `longitude` (`decimal(9,6)`, nullable until geocoded), `is_primary` (BIT, default 0),
`recipient_name` / `recipient_phone` (**encrypted**, nullable), audit fields, `deleted_at`.
- **Constraint:** **filtered `UNIQUE(customer_id) WHERE is_primary=1`** — exactly one primary per
customer, enforced at the DB level (the authoritative backstop), not only in the handler.
- Relations: N:1 → `customer_profiles`, `cities`, `districts`; later referenced by
`booking_requests`/`bookings` (b8/b9 — **DEFERRED here**, just don't preclude it).
> **Soft-delete & audit:** every table declares the global `deleted_at IS NULL` query filter and inherits
> audit-field stamping from b0's interceptor — handlers never set `CreatedAt`/`CreatedById`. Geo rows are
> **deactivated (`is_active=0`) far more often than deleted**, so toggled-off regions vanish from
> dropdowns without orphaning the historical addresses/areas that point at them.
### 3.2 Seed data (mirror b1's seeding mechanism)
Add a province/city/district seed that runs as part of the b1-style seeding path (idempotent — safe to
re-run; key on a stable natural identifier such as `name_en` within parent, not on auto id):
- **All 31 Iranian provinces** (`name_fa`/`name_en`, `sort_order` by Iranian convention with Tehran first
or alphabetical Persian — keep it deterministic).
- **Major cities** at minimum the white-space targets the product calls out:
**Tehran, Karaj, Mashhad, Isfahan, Shiraz, Tabriz, Ahvaz, Qom** (plus each province's capital).
- **Tehran's 22 municipal districts** (منطقه ۱ … منطقه ۲۲) under the Tehran city row. Other cities get
no districts at seed time (whole-city coverage is the default and is meaningful — see §5). Adding
neighborhoods elsewhere is an **admin insert later**, no deploy.
### 3.3 Commands & queries (CQRS, `OperationResult`, never throw for expected failures)
| Capability | Type | Route | What it does |
| --- | --- | --- | --- |
| **`ListProvincesQuery`** | Query | `GET api/v1/geo/provinces` | Active provinces ordered by `sort_order`. `AsNoTracking` + `.Select` to a `ProvinceDto`; **cached** via `ICacheService.GetOrCreateAsync` (reference data; invalidate on admin write). Public. |
| **`ListCitiesQuery`** | Query | `GET api/v1/geo/cities?province_id=` | Active cities for a province, ordered by `sort_order`. Projected + cached + paginated. Public. |
| **`ListDistrictsQuery`** | Query | `GET api/v1/geo/districts?city_id=` | Active districts for a city, ordered. **Empty list is a valid result** (a city with no districts → caller selects whole-city). Projected + cached. Public. |
| **`GetGeoTreeQuery`** *(optional convenience)* | Query | `GET api/v1/geo/tree` | The full active province→city→district tree in one cached payload for the cascading dropdown (use if the client prefers one round-trip; otherwise the three lazy queries above suffice). Cached aggressively; invalidated on any admin geo write. Public. |
| **`CreateProvinceCommand` / `UpdateProvinceCommand`** | Command | `POST/PUT api/v1/admin_geo/provinces[/{id}]` | Admin create/edit `name_fa`/`name_en`/`sort_order`. Admin policy. **Invalidates the geo cache.** |
| **`SetProvinceActiveCommand`** | Command | `POST api/v1/admin_geo/provinces/{id}/set_active` | Toggle `is_active` (no delete). Cascade meaning: deactivating a province hides its cities/districts from public dropdowns (filter on the join, don't bulk-rewrite children). Invalidates cache. |
| **`CreateCityCommand` / `UpdateCityCommand` / `SetCityActiveCommand`** | Command | `POST/PUT api/v1/admin_geo/cities[/{id}]`, `…/{id}/set_active` | Same pattern under a `province_id`. |
| **`CreateDistrictCommand` / `UpdateDistrictCommand` / `SetDistrictActiveCommand`** | Command | `POST/PUT api/v1/admin_geo/districts[/{id}]`, `…/{id}/set_active` | Same pattern under a `city_id`. |
| **`AddNurseServiceAreaCommand`** | Command | `POST api/v1/nurse_service_areas` | The signed-in nurse declares coverage: `{ city_id, district_id? }`. `district_id` omitted/NULL = **whole city**. Validates the city/district exist and are active and that the district belongs to the city; enforces `UNIQUE(nurse_id, city_id, district_id)`**a duplicate (including a duplicate whole-city row) returns `409`, never a 500**. Tenancy: `nurse_id` from `ICurrentUser`, never the body. *(DEFERRED hook: this is the write that later fans out `nurse_search_index` rows in [b7](./backend-phase-7.md) — leave a clean extension point, do not build the index here.)* |
| **`RemoveNurseServiceAreaCommand`** | Command | `DELETE api/v1/nurse_service_areas/{id}` | Soft-removes the nurse's own area (tenancy-checked). *(DEFERRED hook: triggers index row removal in b7.)* |
| **`ListMyServiceAreasQuery`** | Query | `GET api/v1/nurse_service_areas` | The nurse's own areas (city + optional district names, "whole city" flag). Tenancy-scoped, projected, paginated. |
| **`CreateAddressCommand`** | Command | `POST api/v1/customer_addresses` | Creates an address for the signed-in customer: `{ title, city_id, district_id?, address_line, postal_code?, recipient_name?, recipient_phone?, is_primary? }`. **Encrypts** `address_line`/`postal_code`/recipient fields via `IFieldEncryptor`. **Geocodes** via `IGeocoder.GeocodeAsync(...)` to set `latitude`/`longitude`. If `is_primary=true` (or it is the customer's first address), enforce **single-primary** (clear the prior primary in the same unit of work; the filtered unique index is the backstop). Tenancy from `ICurrentUser`. |
| **`UpdateAddressCommand`** | Command | `PUT api/v1/customer_addresses/{id}` | Edit fields; **re-geocode** when the address line/city/district changed; re-encrypt PII. Tenancy-checked. |
| **`SetPrimaryAddressCommand`** | Command | `POST api/v1/customer_addresses/{id}/set_primary` | Atomically makes one address primary and clears the previous (single-primary invariant). |
| **`DeleteAddressCommand`** | Command | `DELETE api/v1/customer_addresses/{id}` | Soft-delete the customer's own address (tenancy-checked). |
| **`ListMyAddressesQuery`** | Query | `GET api/v1/customer_addresses` | The customer's own addresses, **primary first**. Projected (decrypt the address for display *only in the owner's own read*), paginated, tenancy-scoped. |
- **Controllers:** `GeoController` (public read), `AdminGeoController` (admin policy; create/update/set_active),
`NurseServiceAreasController` (nurse policy, tenancy-scoped), `CustomerAddressesController` (customer
policy, tenancy-scoped). All `sealed : BaseController`, inject `ISender`, return `base.OperationResult(...)`,
snake_case `[controller]`/`[action]` routes, `CancellationToken` threaded.
- **Validators (FluentValidation):** non-empty `name_fa`/`name_en` on geo create/update; valid `province_id`/
`city_id` parents; `AddNurseServiceArea` (city active, district-belongs-to-city when provided);
`CreateAddress`/`UpdateAddress` (city active, non-empty `address_line`, district-belongs-to-city when
provided, postal-code format if present).
- **Mapping:** Mapster in the handler after the projected query (never hydrate entities to map them).
### 3.4 DEFERRED (build the seam/flag, not the feature)
- **`nurse_search_index` fan-out** on service-area add/remove — **DEFERRED to [b7](./backend-phase-7.md)**.
Leave the add/remove handlers as the clean trigger point; do not build the index table or its
maintenance here.
- **GPS-radius / map-tile / "nurses near me" map discovery** — **DEFERRED** ([`product/business/04-search-and-matching.md`](../../../product/business/04-search-and-matching.md)
§(c)). Geography is **named districts**, full stop. Do not add a radius/distance model.
- **Region bulk-import feed** (`IGeoDataImporter` against an official statistics dataset) — **DEFERRED**;
the idempotent one-time seed (§3.2) plus admin CRUD is sufficient for MVP. Note it in the report.
- **EVV distance check** that *consumes* `customer_addresses.latitude/longitude` — **DEFERRED to
[b9](./backend-phase-9.md)**. This phase only *produces* the coordinates.
## 4. Mocks & seams in this phase
| Seam | Owner | Mock behaviour | Registry |
| --- | --- | --- | --- |
| **`IGeocoder`** | **introduced here** | `GeocodeAsync(addressText, cityName, districtName?, ct)` returns a deterministic `(latitude, longitude)` for the input (e.g. a stable hash-derived offset around the city centroid, or an echo of a configured static coordinate per city) plus a `formatted_address` and a `confidence`. **No real network call.** A config switch can force a `null`/`low-confidence` result so the "address saved without coordinates / map-pin-missing" UI states are testable. Coordinates are `decimal`, never `float`. | **flip reserved → 🟡** |
| `IFieldEncryptor` | reuse from **b0** | local symmetric key; encrypts `address_line`/`postal_code`/recipient fields; **never logs plaintext PII**; deterministic `Hash` available if a lookup is ever needed. | reuse row |
| `ICacheService` | reuse from **b0/b1** | in-memory; the geo-lookup queries read through `GetOrCreateAsync` and admin writes invalidate the geo keys. | reuse row |
The mock lives behind a **DI-registered interface** in Infrastructure (real impl is a drop-in later);
selection is **config-driven, never an `if (mock)` branch in a handler**. Flip the reserved `IGeocoder`
row in [`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md)
to 🟡 with: the seam (interface + file), what's faked, the config keys it reads, and **step-by-step how to
make it real** — register a **Neshan** (or Google) geocoding client implementing `IGeocoder`, the API-key
config, the request/response mapping to `(lat, lng, formatted_address, confidence)`, rate-limit/retry, and
what to test (a known Tehran address resolves within the expected bounds).
## 5. Critical rules you must not get wrong
- **`district_id = NULL` is a MEANINGFUL value ("entire city"), not missing data.** It must participate
correctly in the `UNIQUE(nurse_id, city_id, district_id)` constraint (a nurse cannot declare the same
whole-city coverage twice) **and** it must be a real coverage choice that later search treats as
"matches every district in that city". SQL Server's default NULL-distinct behaviour will silently let
duplicate whole-city rows through — defeat that with the filtered-index pair (or sentinel) in §3.1.
**Never** treat a NULL district as "the nurse forgot to pick one".
- **Geography is named districts, NOT GPS radii.** Do not implement, or leave room to implement, a
radius/haversine coverage model. Coverage is the set of `(city, district?)` rows a nurse declared;
search intersects rows, not circles. (The address lat/lng exists only for the later EVV *distance check*
against the booking site, not for coverage matching.)
- **Respect `is_active` — deactivation hides, it never deletes.** A toggled-off province/city/district
must disappear from the public cascading dropdowns (the queries filter `is_active=1` at every level and
honour the parent's active state), **without** deleting the region or orphaning the addresses/service
areas that already reference it. Deletion is reserved for genuinely erroneous rows with no children.
- **Exactly one primary address per customer.** Enforce it both in the handler (clearing the prior primary
in the same unit of work when a new primary is set) **and** with the filtered
`UNIQUE(customer_id) WHERE is_primary=1` index as the authoritative DB backstop — a race that tries to
set a second primary fails on the constraint, not silently. The customer's first address is primary by
default.
- **Addresses are encrypted PII.** `address_line`, `postal_code`, and recipient name/phone go through
`IFieldEncryptor` at rest — never stored or logged in plaintext, never returned in a list projection to
anyone but the owning customer. Decrypt only in the owner's own read path. (Coordinates and the `title`
label are not PII and may stay plaintext.)
- **Tenancy.** `nurse_id` on a service area and `customer_id` on an address come from `ICurrentUser`, never
from the request body; a nurse/customer can only read and mutate **their own** rows; cross-tenant access
returns `404`, not `403` revealing existence. (Booking-time tenancy — an address used in a booking must
belong to that booking's customer — is enforced in b8/b9; don't pre-build it, but don't break it.)
- **Reference reads are cached, writes invalidate.** The geo-lookup queries are read-heavy and near-static;
serve them through `ICacheService` and invalidate the relevant keys on every admin geo write so a
newly-activated city appears and a deactivated one disappears promptly. Don't hardcode the list in code.
- **Projection + pagination always.** Every read uses `AsNoTracking()` + `.Select(...)` to a DTO and every
list is paginated; no unbounded `ToListAsync()`, no entity hydration just to map.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] The five tables (`provinces`, `cities`, `districts`, `nurse_service_areas`, `customer_addresses`)
exist via **one migration**, each with its `IEntityTypeConfiguration<T>`, soft-delete query filter,
and audit wiring; the `nurse_service_areas` whole-city-aware uniqueness and the
`customer_addresses` filtered `UNIQUE(customer_id) WHERE is_primary=1` are real DB constraints;
coordinates are `decimal(9,6)`; PII columns are encrypted.
- [ ] The seed (§3.2) loads provinces + major cities + Tehran's 22 districts **idempotently** and runs via
the b1 seeding path.
- [ ] All §3.3 commands/queries implemented (CQRS, `OperationResult`, projected + paginated + cached reads,
validators), with `GeoController`, `AdminGeoController`, `NurseServiceAreasController`,
`CustomerAddressesController`.
- [ ] **`IGeocoder`** introduced (Application interface, Infrastructure mock, DI registration via a
`ServiceConfiguration/` extension, config-selected). No `if (mock)` in handlers. Address create/update
sets coordinates from it.
- [ ] Handler unit tests (NSubstitute) for: the cascading lookups, the **duplicate service-area (incl.
duplicate whole-city) rejection**, single-primary enforcement (setting a second primary clears the
first / is blocked by the filtered index), geocode wiring, and `is_active` filtering. ≥1
`WebApplicationFactory` integration test per controller (happy path, 401, validation 400).
`dotnet build Baya.sln` zero new warnings; `dotnet test Baya.sln` green.
- [ ] The **Project map** in `server/CLAUDE.md` reflects `Features/Geography/**`,
`Features/ServiceAreas/**`, `Features/Addresses/**` and the new `Geography` domain folder + the
`IGeocoder` seam; the contract `dev/contracts/domains/geography-addresses.md` is written and the
`swagger.json` snapshot republished.
## 7. How to test (what a human can verify after this phase)
1. **Seed ran** — start the API → `GET api/v1/geo/provinces` returns the 31 provinces ordered by
`sort_order`; `GET api/v1/geo/cities?province_id={Tehran}` includes Tehran; `GET api/v1/geo/districts?city_id={Tehran}`
returns the **22 districts**; `GET api/v1/geo/districts?city_id={Mashhad}` returns an **empty list**
(valid — whole-city only).
2. **Cascading dropdown**`provinces` → pick one → `cities?province_id=` → pick one → `districts?city_id=`
each returns only `is_active=1` rows in `sort_order`; (or `GET api/v1/geo/tree` returns the whole active
tree in one payload).
3. **Admin toggle**`POST api/v1/admin_geo/cities/{id}/set_active` (deactivate) → that city **disappears**
from `GET api/v1/geo/cities` for its province (and its districts disappear) **without** being deleted;
re-activate → it returns. Confirms `is_active` hides, not deletes.
4. **Nurse adds a service area** — as a nurse, `POST api/v1/nurse_service_areas { city_id }` (no district)
`201`, a **whole-city** area; `GET api/v1/nurse_service_areas` shows it flagged "whole city".
5. **Duplicate rejected** — repeat the same `POST` (same city, no district) → **`409`** (the whole-city
uniqueness fires, not a 500); add `{ city_id, district_id }` for a district in that city → `201`; repeat
that → **`409`**.
6. **Create a geocoded address** — as a customer, `POST api/v1/customer_addresses { title, city_id,
address_line, is_primary:true }` → `201` with **`latitude`/`longitude` populated** (from the `IGeocoder`
mock); `GET api/v1/customer_addresses` shows it primary-first with the address decrypted for the owner.
7. **Single-primary enforced** — create a second address with `is_primary:true` (or
`POST …/{id}/set_primary`) → it becomes primary and the **previous primary is cleared**; an attempt to
force two primaries is rejected by the filtered unique index. Confirm only one `is_primary=1` row exists.
8. **PII not leaked** — confirm `address_line` is stored encrypted (inspect the row / a non-owner read does
not return the plaintext) and never appears in logs.
## 8. Hand off & document (close the phase)
- **Docs to update:** the **Project map** in [`server/CLAUDE.md`](../../../server/CLAUDE.md) (add the new
`Geography` domain + the `Features/Geography|ServiceAreas|Addresses/**` areas and the `IGeocoder` seam);
if you confirm/decide a rule the product docs don't capture — e.g. the **filtered-index pair** chosen to
make whole-city (`district_id=NULL`) uniqueness real, or the address-is-primary-by-default-on-first rule —
record it in [`product/data-model/02-geography.md`](../../../product/data-model/02-geography.md) or
[`01-identity-and-access.md`](../../../product/data-model/01-identity-and-access.md) (regenerate the HTML
view per `product/CLAUDE.md`). **Don't invent rules.**
- **Contract to write:** **`dev/contracts/domains/geography-addresses.md`** (per
[`../../contracts/domains/_TEMPLATE.md`](../../contracts/domains/_TEMPLATE.md)) — the public geo lookups
(provinces/cities/districts/tree), the admin geo CRUD + set_active endpoints, the nurse service-area
add/remove/list, and the customer address CRUD + set_primary; the DTO shapes (`ProvinceDto`, `CityDto`,
`DistrictDto`, `NurseServiceAreaDto` with the **"whole city"** flag, `CustomerAddressDto` with **masked**
PII and `decimal` coordinates); the `district_id=NULL` ⇒ whole-city semantics spelled out;
auth/rate-limit/tenancy notes; the `409` duplicate-area and single-primary side-effects. Republish the
`swagger.json` snapshot per [`../../contracts/openapi/README.md`](../../contracts/openapi/README.md). This
is what **f3-b4** consumes.
- **Handoff & report:** write `dev/shared-working-context/backend/handoff/after-backend-phase-4.md` (geo
hierarchy + addresses + service areas are live; what f3 can now build — address book + map-pin picker +
cascading province/city/district dropdowns, nurse coverage-area editor; which endpoints/contracts are
live; that geocoding is mocked behind `IGeocoder`; the `district_id=NULL` semantics the frontend must
honour). Append to `backend/STATUS.md`, write
`dev/shared-working-context/reports/backend-phase-4-report.md` (what was built, **what is now testable and
exactly how** per §7, what is mocked + how to make it real, contract produced, follow-ups: the b7 search
fan-out hook, the DEFERRED EVV distance check, the region bulk-import feed), and update
`dev/shared-working-context/reports/mocks-registry.md` (flip the `IGeocoder` row → 🟡).
- **Memory:** save a `project` memory note for the non-obvious decisions this phase fixes — the
**whole-city (`district_id=NULL`) uniqueness solution** (the filtered-index pair / sentinel), the
named-districts-not-GPS-radii rule, the single-primary filtered unique + handler pattern, the address-PII
encryption columns, and the `IGeocoder` seam — with a one-line pointer in `MEMORY.md`.
+436
View File
@@ -0,0 +1,436 @@
# Backend Phase 5 — Service catalog & nurse pricing variants
> **Mission:** stand up the two-tier service model that the entire marketplace is priced and searched on.
> First the **admin catalog skeleton** — top-level care **categories** plus EAV-style configurable
> **option groups** and **option values** so new pricing dimensions ship as *data*, not migrations. Then the
> **nurse pricing layer** — the `nurse_service_variants` that are the **atomic bookable unit** of the whole
> platform: a nurse + a category + one chosen value per required dimension, at the nurse's own price and
> price unit. Transparent, upfront, nurse-set pricing is a deliberate differentiator versus the opaque
> "توافقی / negotiable" incumbents — and after this phase, search (b7), booking (b8), and every downstream
> money calculation operate on a variant, never on a nurse.
>
> **Track:** backend · **Depends on:** [backend-phase-3](backend-phase-3.md) (`nurse_profiles`, identity/roles), [backend-phase-1](backend-phase-1.md) (marketplace migration baseline, seed/config, admin auth) · **Unlocks:** search & matching ([backend-phase-7](backend-phase-7.md)), booking requests & lifecycle ([backend-phase-8](backend-phase-8.md)), and the catalog browse + service-builder UI ([frontend-phase-4-b5](../frontend/frontend-phase-4-b5.md))
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
This is the **catalog & pricing** phase. Identity exists (b2/b3): there are `users` with a `role`
(`admin`/`nurse`/`customer`) and a `gender`, and every nurse has a `nurse_profiles` row. Geography exists
(b4): `provinces`/`cities`/`districts` are seeded and `nurse_service_areas` declares where a nurse will
travel. Config & reference (b1) gave us the **first marketplace migration baseline**, the typed cached
config accessor, admin auth, and the `support_alerts`/`notifications` plumbing. None of that defines *what a
nurse sells or for how much* — that is this phase.
The product models catalog in **three admin layers** (category → option group → option value) and **two
nurse layers** (variant → variant option). The admin layers are an intentionally **EAV-style configurable
structure**: an admin can introduce a new pricing dimension ("live-in", "number of patients") as rows, with
**no schema migration**. The nurse layers turn that skeleton into priced, bookable offerings. The output of
this phase is the thing the customer actually pays for: a **variant**.
**What already exists (do not rebuild):**
- **Identity, roles & nurse profiles** — [backend-phase-3](backend-phase-3.md) built `nurse_profiles`
(1:1 → `users`, carrying `is_verified`, `is_accepting_bookings`, and the denormalized
`average_rating`/`total_reviews`/`total_completed_bookings` aggregates), `customer_profiles`, `patients`,
and `nurse_bank_accounts`. [backend-phase-2](backend-phase-2.md) established phone-OTP auth, sessions, and
`users.gender` (`male`/`female`). **Variants FK to `nurse_profiles`** — read that entity; do not re-model
it. Tenancy ("only the owning nurse edits their variants") keys off `ICurrentUser``nurse_profiles`.
- **Config, admin auth & the migration baseline** — [backend-phase-1](backend-phase-1.md) created the **first
marketplace EF Core migration** (the baseline every later phase extends additively), `platform_configs`
(typed cached accessor), `audit_logs` (written by the SaveChanges interceptor), the in-app `notifications`
write, and the `support_alerts` raise API. Your migration is **additive** on top of b1's baseline. Admin
endpoints use the admin policy established by b1/b2 — reuse it.
- **Geography** — [backend-phase-4](backend-phase-4.md) built `provinces`/`cities`/`districts` (+ seed) and
`nurse_service_areas` (`district_id = NULL` ⇒ whole city; `UNIQUE(nurse_id, city_id, district_id)`).
Catalog itself does **not** touch geography — but b7 fans a variant out across its nurse's service areas
into the search index, so keep the variant shape clean for that projection.
- **Cross-cutting seams** — [backend-phase-0](backend-phase-0.md) introduced `ICacheService`,
`IDateTimeProvider`, `IFieldEncryptor`, `IObjectStorage`, and `INotificationDispatcher`, plus the REST
surface (`BaseController`, snake_case routing, rate limiting), the CQRS pipeline
(`ISender`/`ICommand`/`IQuery`, `ValidateCommandBehavior`, `OperationResult<T>`), and the audit-field
interceptor. **Reuse `ICacheService`** for the read-heavy public catalog reads; do not introduce new seams.
> The **denormalized `nurse_search_index`**, the `INurseSearch` search seam, and the index-maintenance
> hooks that fan a variant out per covered area are owned by **[backend-phase-7](backend-phase-7.md)**, *not*
> this phase. You build the variant as a clean, projectable source; b7 reads it. Do **not** create the search
> index or a search query here. **(DEFERRED → b7.)**
>
> The **`variant_snapshot_json`** that freezes a variant onto a booking at booking time is owned by the
> **Booking area ([backend-phase-8](backend-phase-8.md))**. This phase ships a **variant-snapshot serializer**
> (a pure library function — §3.4) that b8 *consumes*; it is not an endpoint here. **(snapshot persistence
> DEFERRED → b8.)**
>
> **`nurse_availability_slots` / `nurse_availability_exceptions`** are **soft scheduling guidance only** — not
> on the money or safety path. They are **(DEFERRED)** for MVP; see
> [`product/data-model/03-services-and-pricing.md`](../../../product/data-model/03-services-and-pricing.md).
> Note them in your report; **do not build** them.
## 2. Required reading (do this first)
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md).
- **Product — business rules (source of truth):**
[`product/business/03-service-catalog-and-pricing.md`](../../../product/business/03-service-catalog-and-pricing.md)
— admin defines the catalog skeleton (categories + configurable option groups/values, addable without a
schema change); each nurse defines their own variants (category + chosen option combination + own price +
price unit); the **five price units** (`per_hour`/`per_session`/`per_half_day`/`per_day`/`per_24h`); the
auto-generated-but-editable `display_name`; **deactivate, never delete**; and that catalog is snapshotted
onto the booking. Read **(b) Iran-specific** — why `per_24h`/`per_day` are first-class and why upfront
pricing is the differentiator.
- **Product — data model (source of truth):**
[`product/data-model/03-services-and-pricing.md`](../../../product/data-model/03-services-and-pricing.md)
— the three admin layers + two nurse layers, **why the EAV configurability is load-bearing**, why the
**variant is the bookable unit (not the nurse)**, the `UNIQUE(variant_id, option_group_id)` "one value per
dimension" rule, the **NULL `service_category_id` = cross-category** rule, and the "consider a uniqueness
strategy on `(nurse_id, category, option-set)`" guidance you will implement as the duplicate-listing guard.
- **Type & money rules on the wire:**
[`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md) —
**price is IRR Rials as an integer** (`BIGINT`), no floats, and money crosses the wire as a string of
digits; the `price_unit` enum is a stable string code; `name_fa`/`name_en` reference data returns both.
- **Code to mirror (existing patterns):** an existing feature folder under
`Baya.Application/Features/<Area>/{Commands|Queries}/<Name>/` (request `record` + `internal sealed`
handler + `OperationResult`, validator picked up by `ValidateCommandBehavior`), an
`IEntityTypeConfiguration<T>` under `Persistence/Configuration/<Area>Config/`, a `sealed` controller under
`Baya.Web.Api/Controllers/V1/` (`BaseController`, inject `ISender`, `[controller]`/`[action]` snake_case
tokens, `base.OperationResult(...)`), the b1 reference-data **seed** pattern (how seeded `name_fa`/`name_en`
rows were inserted in the baseline migration), and how reads use `AsNoTracking()` + `.Select()` projection
+ pagination + `ICacheService`.
- **Contract conventions:** [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
(envelope, snake_case routes, status codes, mandatory list pagination, localisation of `name_fa`/`name_en`).
- **Prior handoffs:** `dev/shared-working-context/backend/handoff/after-backend-phase-3.md` (the
`nurse_profiles` shape + the admin/nurse policies) and `.../after-backend-phase-1.md` (the migration
baseline + config accessor + admin policy).
## 3. Scope — build this
A vertical slice per capability: entity + EF config + migration → command/query handler(s) → controller
endpoint → contract. Everything async with `CancellationToken`; reads are `AsNoTracking()` + `.Select()`
projection + pagination; writes go through `IUnitOfWork` with a single `CommitAsync`. Money is IRR `BIGINT`.
### 3.1 Entities, configs & migration
Add these five tables as **one additive EF Core migration** on top of b1's baseline. One
`IEntityTypeConfiguration<T>` per entity in `Persistence/Configuration/CatalogConfig/`. All catalog rows
carry a **`name_fa` (primary) + `name_en`** pair — never persist a category/group/value without the Persian
label.
- **`service_categories`** — admin-managed top-level care types; the primary search dimension.
- Columns: `id` (BIGINT PK), `name_fa` (NVARCHAR, required), `name_en` (NVARCHAR, required),
`description_fa`/`description_en` (NVARCHAR, nullable), `icon_key` (NVARCHAR, nullable — UI glyph),
`sort_order` (INT, for ordered display), `is_active` (BIT, default 1), plus the audit fields stamped by
the interceptor (`CreatedAt/ModifiedAt/CreatedById/ModifiedById`) and soft-delete (`deleted_at`).
- **Seed** the five MVP categories with both labels: **Elderly Care** (مراقبت از سالمند), **Post-Surgery
Recovery** (مراقبت پس از جراحی), **Infant Care** (مراقبت از نوزاد), **Chronic Illness Management**
(مدیریت بیماری مزمن), **Companionship** (همراهی / مراقبت روزمره). Seed in the migration (b1 baseline
pattern) so a nurse can build a variant immediately.
- Index: `(is_active, sort_order)` for the public ordered list; soft-delete query filter (`!IsDeleted`).
- **`service_option_groups`** — admin-managed configurable **dimensions** (e.g. نوع شیفت / shift type,
تعداد بیمار / patient count).
- Columns: `id` (BIGINT PK), `service_category_id` (BIGINT FK → `service_categories`, **NULLABLE**
**NULL = cross-category**, applies everywhere), `name_fa`/`name_en` (required), `is_required` (BIT —
whether a variant must answer this group), `sort_order` (INT), `is_active` (BIT), + audit + soft-delete.
- Index: `(service_category_id, sort_order)`; soft-delete query filter. The nullable FK is deliberate —
enforce nothing that breaks the cross-category (NULL) case.
- **`service_option_values`** — the concrete choices within a group (e.g. شبانه‌روزی, ۲ نفر).
- Columns: `id` (BIGINT PK), `option_group_id` (BIGINT FK → `service_option_groups`), `name_fa`/`name_en`
(required), `sort_order` (INT), `is_active` (BIT), + audit + soft-delete.
- Index: `(option_group_id, sort_order)`; soft-delete query filter.
- **`nurse_service_variants`** — **the atomic bookable unit.** A nurse + category + chosen option combination
at a price.
- Columns: `id` (BIGINT PK), `nurse_id` (BIGINT FK → `nurse_profiles`), `service_category_id` (BIGINT FK →
`service_categories`), `price` (**BIGINT — IRR Rials, integer, no float, ever**), `price_unit` (NVARCHAR
stable code — closed set `per_hour` | `per_session` | `per_half_day` | `per_day` | `per_24h`),
`session_count` (INT, nullable — number of sessions/units the engagement spans; relevant for
`per_session` and packages), `display_name` (NVARCHAR — **auto-generated from the option labels, but
nurse-editable**), `is_active` (BIT, default 1 — deactivation, never hard-delete), + audit + soft-delete.
- **Indexes / constraints:** index on `(nurse_id, is_active)` for the nurse's offerings list and the b7
index projection; index on `service_category_id`; the **duplicate-listing guard** (§3.3) on
`(nurse_id, service_category_id, option-set)`; soft-delete query filter (`!IsDeleted`).
- `price_unit` is the **only** value in this area allowed to be a closed code enum — categories, groups,
and values are **data**, never code constants (EAV is load-bearing).
- **`nurse_service_variant_options`** — the option values that define one variant's configuration.
- Columns: `id` (BIGINT PK), `variant_id` (BIGINT FK → `nurse_service_variants`), `option_group_id` (BIGINT
FK → `service_option_groups`), `option_value_id` (BIGINT FK → `service_option_values`), + audit.
- **`UNIQUE(variant_id, option_group_id)`** — **one value per dimension per variant** (a variant cannot
answer the same group twice). Index `(variant_id)` for loading a variant's full option set.
> **Do not add `nurse_search_index` here.** It is b7's denormalized read model. **Do not add
> `variant_snapshot_json`** — it lives on `booking_requests` and is owned by b8. **Do not add
> `nurse_availability_slots`/`_exceptions`** — DEFERRED.
### 3.2 Admin catalog — commands & queries
Feature folder `Baya.Application/Features/Catalog/`. **Admin-only** (narrowest admin policy from b1/b2);
these mutate the platform skeleton.
- **`CreateServiceCategoryCommand`** / **`UpdateServiceCategoryCommand`** (`Commands/CreateServiceCategory/`,
`.../UpdateServiceCategory/`) — set `name_fa`/`name_en` (both required), descriptions, `icon_key`,
`sort_order`. FluentValidation: both labels non-empty.
- **`SetServiceCategoryActiveCommand`** (`Commands/SetServiceCategoryActive/`) — activate/deactivate
(`is_active`). **Soft state only — never hard-delete.** Deactivating a category hides it from public browse
and from new variant creation; existing variants in that category are left intact (their bookings/history
must survive — see §5).
- **`CreateServiceOptionGroupCommand`** / **`UpdateServiceOptionGroupCommand`**
(`Commands/CreateServiceOptionGroup/`, `.../UpdateServiceOptionGroup/`) — set `service_category_id`
(**nullable — NULL marks the group cross-category**), `name_fa`/`name_en`, `is_required`, `sort_order`.
- **`CreateServiceOptionValueCommand`** / **`UpdateServiceOptionValueCommand`**
(`Commands/CreateServiceOptionValue/`, `.../UpdateServiceOptionValue/`) — set `option_group_id`,
`name_fa`/`name_en`, `sort_order`, `is_active`.
- **`GetCatalogCategoriesQuery`** (`Queries/GetCatalogCategories/`) — **public**, paginated, `is_active`
categories ordered by `sort_order`, returning `name_fa`/`name_en`. `AsNoTracking()` + `.Select()`;
**cache through `ICacheService`** with invalidation on any category mutation (read-heavy reference data).
- **`GetCategoryOptionGroupsQuery`** (`Queries/GetCategoryOptionGroups/`) — **public**, for a category id,
returns its **applicable** option groups (the category's own groups **plus** all cross-category
(NULL-category) groups), each with its active option values, `is_required`, ordered by `sort_order`. This
is the skeleton the nurse builder fills in and the customer browses. Cache + invalidate on group/value
mutation.
### 3.3 Nurse variants — commands & queries
Feature folder `Baya.Application/Features/Variants/` (or a `Catalog/Variants/` sub-area, matching the
surrounding convention). **Nurse-owner-only** for writes; tenancy via `ICurrentUser``nurse_profiles`.
- **`CreateVariantCommand`** (`Commands/CreateVariant/`) — the nurse picks a `service_category_id`, supplies
the chosen `option_value_id` for each required group (and any optional groups they answer), sets `price`
(IRR BIGINT) + `price_unit` (one of the five) + optional `session_count`, and an optional `display_name`
override. The handler, in one transaction:
1. **Validate the category** exists and is active.
2. **Resolve applicable groups** (the category's groups + cross-category groups) and assert **every
`is_required` group is answered exactly once** and **no group is answered twice** (one value per
dimension); reject unknown groups/values, or a value that does not belong to its claimed group.
3. **Duplicate-listing guard:** reject if this nurse already has a non-deleted variant in the same category
with the **identical answered option-set** (same set of `(option_group_id, option_value_id)` pairs).
Enforce with a DB backstop (see below) **and** an explicit pre-check returning a clean conflict
`OperationResult`, never a raw DB exception.
4. **Auto-generate `display_name`** from the chosen option labels (e.g. category + " · " + value labels)
when the nurse did not override it.
5. Insert the variant + its `nurse_service_variant_options` rows; `CommitAsync` once.
- FluentValidation: `price > 0`, `price_unit` in the closed set, `session_count` null or `> 0`, at least
the required groups present.
- **DB backstop for the duplicate guard:** because the option-set is multi-row, a plain composite unique
index is insufficient. Persist a deterministic **`option_set_hash`** column on `nurse_service_variants`
(a stable hash of the sorted `(option_group_id, option_value_id)` pairs, computed in the handler) and put
a **filtered `UNIQUE(nurse_id, service_category_id, option_set_hash) WHERE deleted_at IS NULL`** on it.
This makes the guard race-safe; the pre-check gives the friendly message. (If b1's conventions already
established a canonical hashing helper, reuse it.)
- **`UpdateVariantCommand`** (`Commands/UpdateVariant/`) — owner edits `price`, `price_unit`,
`session_count`, and `display_name`. Re-validate price/unit; if the edit would collide with another of the
nurse's variants' option-set, reject. (Changing the **option-set** itself is treated as create-new +
deactivate-old to keep historical meaning stable — do not silently mutate a variant's dimensions; if you
do allow option edits, recompute `option_set_hash` and re-run the duplicate guard.)
- **`SetVariantActiveCommand`** (`Commands/SetVariantActive/`) — owner activates/deactivates (`is_active`).
**Deactivate, never hard-delete.** A deactivated variant cannot be booked and (via b7) must drop out of the
search index. Editing `display_name` may piggyback as `EditVariantDisplayNameCommand` or be folded into
`UpdateVariantCommand` — match the surrounding granularity.
- **`ListMyVariantsQuery`** (`Queries/ListMyVariants/`) — the signed-in nurse's own offerings, **active and
inactive**, paginated, each with category label, resolved option labels, price, unit, `session_count`,
`display_name`, `is_active`. `AsNoTracking()` + `.Select()` projection.
- **`GetVariantQuery`** (`Queries/GetVariant/`) — a single variant with its full resolved option-set and
labels, for the edit screen and for b8's booking-request capture to read the canonical offering. Owner or
admin for the full view; a public-safe projection (price/unit/labels, no internal fields) backs the
customer-facing nurse profile.
### 3.4 Variant-snapshot serializer (library function for b8 — not an endpoint)
- **`IVariantSnapshotSerializer`** (interface in `Application/Contracts/`, implementation in Application or
Infrastructure) — a pure function `string Serialize(variant + resolved options)` that emits the canonical
**`variant_snapshot_json`**: category id + `name_fa`/`name_en`, each `(option_group label, option_value
label)`, `price`, `price_unit`, `session_count`, `display_name`, and the variant id, **as they are at
serialize time**. Booking ([backend-phase-8](backend-phase-8.md)) calls this to freeze the offering onto
`booking_requests.variant_snapshot_json` so later variant edits/deactivation never mutate past bookings,
disputes, or invoices. **This phase ships and unit-tests the serializer; b8 persists its output.** Do not
add the snapshot column here.
### 3.5 REST endpoints
Controllers under `Baya.Web.Api/Controllers/V1/` (`sealed`, `BaseController`, inject `ISender`,
`[controller]`/`[action]` snake_case tokens, `base.OperationResult(...)`, narrowest authorize policy, lists
paginated). Routes shown logically; the snake_case transformer produces the real segments.
| Verb & route | Maps to | Auth |
| --- | --- | --- |
| `POST /v1/admin/catalog/categories` | `CreateServiceCategoryCommand` | admin |
| `PUT /v1/admin/catalog/categories/{id}` | `UpdateServiceCategoryCommand` | admin |
| `PATCH /v1/admin/catalog/categories/{id}/active` | `SetServiceCategoryActiveCommand` | admin |
| `POST /v1/admin/catalog/option-groups` | `CreateServiceOptionGroupCommand` | admin |
| `PUT /v1/admin/catalog/option-groups/{id}` | `UpdateServiceOptionGroupCommand` | admin |
| `POST /v1/admin/catalog/option-values` | `CreateServiceOptionValueCommand` | admin |
| `PUT /v1/admin/catalog/option-values/{id}` | `UpdateServiceOptionValueCommand` | admin |
| `GET /v1/catalog/categories` | `GetCatalogCategoriesQuery` | public |
| `GET /v1/catalog/categories/{id}/option-groups` | `GetCategoryOptionGroupsQuery` | public |
| `POST /v1/nurse/variants` | `CreateVariantCommand` | nurse (owner) |
| `PUT /v1/nurse/variants/{id}` | `UpdateVariantCommand` | nurse (owner) |
| `PATCH /v1/nurse/variants/{id}/active` | `SetVariantActiveCommand` | nurse (owner) |
| `GET /v1/nurse/variants` | `ListMyVariantsQuery` | nurse (owner) |
| `GET /v1/nurse/variants/{id}` | `GetVariantQuery` | owner / admin (full) · public (safe projection) |
### 3.6 Out of scope (DEFERRED — build the seam/hook/serializer, not the feature)
- **`nurse_search_index`, the `INurseSearch` seam, the search query, and index fan-out/maintenance** —
**(DEFERRED → [backend-phase-7](backend-phase-7.md))**. Keep the variant shape projection-friendly; b7
reads category/price/unit/`is_active` from it.
- **`variant_snapshot_json` persistence** — **(DEFERRED → [backend-phase-8](backend-phase-8.md))**. You ship
the serializer (§3.4); b8 writes the column.
- **`nurse_availability_slots` / `nurse_availability_exceptions`** — soft guidance, not money/safety path —
**(DEFERRED)**; note them, don't build.
- **Holiday/surge pricing rules engine; the lighter Companionship/daily-living *tier* as a pricing model;
dynamic/tiered commission per category** — **(DEFERRED)**, see
[`product/business/03-service-catalog-and-pricing.md`](../../../product/business/03-service-catalog-and-pricing.md)
(c). Companionship ships only as a seeded **category** (data), not as a special pricing path.
## 4. Mocks & seams in this phase
**None.** Catalog and variant data are fully owned by Balinyaar's database — there is no third-party service
to mock here. This phase **introduces no cross-cutting seam.**
- **Reuse `ICacheService`** (from [backend-phase-0](backend-phase-0.md)) for the read-heavy public catalog
reads (`GetCatalogCategories`, `GetCategoryOptionGroups`) with invalidation on the matching admin mutation.
Do **not** redefine it.
- The **search-index writer** seam (`INurseSearch` / `ISearchIndexWriter`) that variant writes will fan out
through is **introduced in [backend-phase-7](backend-phase-7.md)**, not here. Do not pre-build it; leave the
registry row to b7. (Listed in
[`mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md).)
- `IVariantSnapshotSerializer` (§3.4) is an **internal application contract**, not an external-service seam —
it does not go in the mock registry. It has a single real implementation.
Because this phase mocks nothing, there is no `mocks-registry.md` row to add — but you **must** still write
the phase report (§8).
## 5. Critical rules you must not get wrong
- **The bookable unit is the variant, NOT the nurse.** Search (b7), booking (b8), and every pricing
calculation operate on `nurse_service_variants` — a nurse who has *no* active variant is not bookable. Do
not anywhere treat "a nurse" as the priced/bookable entity; the customer pays for a specific variant.
- **`price` is IRR Rials as an integer (`BIGINT`) — no floats, anywhere.** Never store, compute, or serialize
price as a float/decimal-with-fraction; there is no Toman in the DB or the contract. The engagement total
is **`price` combined with `price_unit` and `session_count`** — **do NOT compute a total from `price`
alone**; the unit and session count are load-bearing and a downstream consumer (booking) derives the total
from all three. Money crosses the wire as a string of digits per
[`money-and-types.md`](../../contracts/conventions/money-and-types.md).
- **One value per dimension per variant.** Enforce **`UNIQUE(variant_id, option_group_id)`** on
`nurse_service_variant_options` — a variant must never answer the same option group twice. Validate this in
the handler *and* let the unique index be the authoritative backstop.
- **All required option groups must be answered.** A `CreateVariant`/`UpdateVariant` that omits any
applicable `is_required` group is a validation failure (clean `OperationResult`, not an exception). The
applicable set = the category's own groups **plus** every cross-category (NULL `service_category_id`) group.
- **A NULL-category option group applies cross-category.** `service_option_groups.service_category_id = NULL`
means the dimension applies to *every* category. `GetCategoryOptionGroups`, the required-group check, and
the duplicate guard must all include cross-category groups — silently dropping them is a defect.
- **Catalog must be seeded before any nurse can create a variant.** The five seed categories ship in this
phase's migration; a variant create against a missing/inactive category fails cleanly. Do not let variant
creation succeed against a category that does not exist or is deactivated.
- **Duplicate identical listings are rejected.** Two non-deleted variants for the same nurse, same category,
and the **identical answered option-set** are not allowed — guarded by the filtered
`UNIQUE(nurse_id, service_category_id, option_set_hash) WHERE deleted_at IS NULL` backstop plus a friendly
pre-check conflict message. (A nurse may, of course, have many variants per category — just not two
*identical* ones.)
- **Deactivate, never hard-delete.** Variants (and categories/groups/values) soft-deactivate. A deactivated
variant is unbookable and (via b7) drops out of search. **Past bookings, snapshots, disputes, and invoices
must never be mutated by a later catalog edit or deactivation** — historical records survive via the
snapshot (§3.4), which is the entire reason snapshotting exists.
- **EAV is load-bearing — categories/groups/values are DATA, not code.** Do not hardcode categories, option
groups, or option values as C# enums/constants. The **only** closed code enum in this area is the
`price_unit` set (`per_hour`/`per_session`/`per_half_day`/`per_day`/`per_24h`). Admins must be able to add a
new dimension as rows with **no migration**.
- **Every catalog row carries `name_fa` (primary) + `name_en`.** Never persist or return a category/group/
value without both labels; the client picks by locale (it never derives a label from a code).
- **Tenancy & authority.** Only the **owning nurse** may create/edit/deactivate their variants (check
`ICurrentUser``nurse_profiles`, not just a role); only **admins** may touch the catalog skeleton
(categories/groups/values). A nurse editing another nurse's variant is a `403`/`NotFound`, never a success.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] `service_categories`, `service_option_groups`, `service_option_values`, `nurse_service_variants`,
`nurse_service_variant_options` exist via **one additive migration** with the §3.1 constraints:
`UNIQUE(variant_id, option_group_id)`; the nullable `service_option_groups.service_category_id`; the
filtered duplicate-listing `UNIQUE(nurse_id, service_category_id, option_set_hash) WHERE deleted_at IS
NULL`; `price` as `BIGINT`; soft-delete query filters. The **five seed categories** (`name_fa`+`name_en`)
are present.
- [ ] Admin CRUD (`Create/Update/SetActive` for categories; `Create/Update` for option groups & values) and
the public `GetCatalogCategories` / `GetCategoryOptionGroups` queries are implemented as CQRS features
with validators and the §3.5 endpoints, returning the standard `OperationResult` envelope. The public
reads are cached via `ICacheService` and invalidated on the matching mutation.
- [ ] Nurse `CreateVariant` / `UpdateVariant` / `SetVariantActive` / `ListMyVariants` / `GetVariant` are
implemented with: required-group enforcement (incl. cross-category groups), one-value-per-dimension,
the duplicate-listing guard (pre-check + DB backstop), auto-generated-but-editable `display_name`, and
owner-tenancy. Variants deactivate, never hard-delete.
- [ ] `IVariantSnapshotSerializer` is implemented and **unit-tested** (its JSON contains category labels,
each option label, price, unit, session count) — ready for b8 to persist.
- [ ] Tests prove: admin creates category + required group + values; a nurse builds a valid variant; a
**duplicate identical listing is rejected**; **missing a required group fails validation**; `ListMyVariants`
returns the nurse's active + inactive variants. (See §7.)
- [ ] `dotnet build Baya.sln` zero new warnings; `dotnet test Baya.sln` green including this phase's tests.
- [ ] The contract `dev/contracts/domains/catalog.md` is written and the `swagger.json` snapshot is refreshed;
the `server/CLAUDE.md` *Project map* notes the new `Features/Catalog` (+ variants) area and the
`CatalogConfig` configuration folder.
## 7. How to test (what a human can verify after this phase)
Run the API (`dotnet run --project src/API/Baya.Web.Api/...`) against a reachable SQL Server; use Swagger or
curl. The expected results below become the "what can be tested" section of your report.
1. **Catalog is seeded.** `GET /v1/catalog/categories``200`, lists the five seed categories with
`name_fa`+`name_en`, ordered by `sort_order`, active only.
2. **Admin builds a dimension.** As admin, `POST /v1/admin/catalog/option-groups` with
`service_category_id` = Elderly Care, `name_fa: "نوع شیفت"`, `name_en: "Shift type"`, `is_required: true`
`200`. Then `POST /v1/admin/catalog/option-values` twice (e.g. `روزانه / Daytime`, `شبانه‌روزی /
Live-in`) → both `200`. `GET /v1/catalog/categories/{elderly_id}/option-groups` → returns the new required
group **plus any cross-category groups**, each with its values.
3. **Nurse builds a valid variant.** As a nurse, `POST /v1/nurse/variants` with the Elderly category, the
required shift-type value = شبانه‌روزی, `price: "8000000"` (IRR string), `price_unit: per_24h``200`,
variant created `is_active: true`, with an auto-generated `display_name` containing the category + option
labels.
4. **Duplicate identical listing is rejected.** Repeat the exact same `POST /v1/nurse/variants` (same
category + same option-set) → a clean **conflict** `OperationResult` failure (not a 500, not a raw DB
exception).
5. **Missing a required group fails validation.** `POST /v1/nurse/variants` for Elderly **without** the
required shift-type value → a `400`/validation `OperationResult` failure naming the missing required group.
6. **One value per dimension.** Attempt to send two values for the same option group in one create → rejected
(handler + the `UNIQUE(variant_id, option_group_id)` backstop).
7. **List my variants (active + inactive).** `GET /v1/nurse/variants` → both the active variant and (after
`PATCH /v1/nurse/variants/{id}/active` → inactive) the deactivated one appear, visually/dataly distinct;
the deactivated one is flagged unbookable. The variant row is **never** hard-deleted.
8. **Tenancy.** As a *different* nurse, `PUT /v1/nurse/variants/{id}` on the first nurse's variant →
`403`/`NotFound`, never a success.
9. **Snapshot serializer (unit test).** A unit test serializes a variant + its options and asserts the JSON
carries the category labels, each option label, price, `price_unit`, and `session_count`.
## 8. Hand off & document (close the phase)
- **Docs to update (same change):** `server/CLAUDE.md` *Project map* — add the `Features/Catalog`
(categories/option-groups/option-values) and the nurse `Variants` feature area, the five new tables + the
`Persistence/Configuration/CatalogConfig/` folder, and the `IVariantSnapshotSerializer` contract (and that
it is consumed by b8). If you established a reusable option-set hashing helper, note it in
`server/CONVENTIONS.md`. If you discovered/decided any business rule not already in the product docs,
reflect it in
[`product/business/03-service-catalog-and-pricing.md`](../../../product/business/03-service-catalog-and-pricing.md)
or [`product/data-model/03-services-and-pricing.md`](../../../product/data-model/03-services-and-pricing.md)
(no invented rules — record decisions, and regenerate the HTML view per `product/CLAUDE.md` if you touched
Markdown).
- **Contract to write:** publish **`dev/contracts/domains/catalog.md`** (the §3.5 routes; request/response
shapes for categories, option groups, option values, and variants; the `price_unit` enum; the `name_fa`/
`name_en` pairing; the IRR-string money format; the required-group/duplicate-listing failure cases and
status codes — `409` on a duplicate listing, `400` on a missing required group; examples) per
[`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md) and
[`money-and-types.md`](../../contracts/conventions/money-and-types.md), starting from the
[domain contract template](../../contracts/domains/_TEMPLATE.md). Refresh the `swagger.json` snapshot per
[`../../contracts/openapi/README.md`](../../contracts/openapi/README.md) so
[frontend-phase-4-b5](../frontend/frontend-phase-4-b5.md) can derive its types (it does not guess shapes).
- **Handoff & report:** write `shared-working-context/backend/handoff/after-backend-phase-5.md` (the catalog
is seeded and admin-CRUDable; the public catalog browse endpoints are live; nurses can build/edit/deactivate
variants; the `IVariantSnapshotSerializer` is ready for b8; **what b7 must read** from the variant for the
search index; **what f4-b5 can now build** — category grid + service builder; the duplicate-listing,
required-group, and one-value-per-dimension rules the frontend must respect). Append your phase summary to
`shared-working-context/backend/STATUS.md`, and write `reports/backend-phase-5-report.md` (what was built,
what is now testable and exactly how — the §7 steps — that **nothing is mocked** in this phase, the contract
produced, and follow-ups for b7 (index projection) and b8 (snapshot persistence)). This phase adds **no**
`mocks-registry.md` row (it mocks nothing) — state that explicitly in the report so the next agent doesn't
go looking.
- **Memory:** save a `project`-type memory note for the non-obvious decisions here — the **variant is the
bookable unit** principle, the **EAV / NULL-category cross-category** rule, the **`option_set_hash` +
filtered-unique** duplicate-listing strategy, and the **price + price_unit + session_count (never price
alone)** total rule — with a one-line `MEMORY.md` pointer.
+353
View File
@@ -0,0 +1,353 @@
# Backend Phase 6 — Nurse verification & credentials (mocked vendors)
> **Mission:** build the **trust engine** — the platform's entire brand. A **data-driven**, platform-owned
> verification pipeline (steps are *rows*, not an enum) that mixes automated KYC-vendor checks with manual
> admin document review, rolls every per-step outcome into one authoritative `nurse_verifications.status`,
> maintains a structured, queryable **credential registry** (license numbers, authority, holder name,
> issue/expiry), and **transactionally flips `nurse_profiles.is_verified`** the moment every required step
> passes (and reverses it on suspension). Documents live in object storage behind signed URLs — only
> metadata + an integrity hash touch the DB. Every external vendor (Shahkar, identity KYC/liveness, the
> MoH/INO/criminal-record portals, IBAN ownership) is **mocked behind a DI seam** so the real swap is
> implementation-only. After this phase a nurse can finally become bookable.
>
> **Track:** backend · **Depends on:** [b3](./backend-phase-3.md) (`nurse_profiles`, `nurse_bank_accounts`, `IBankAccountOwnershipVerifier`), [b1](./backend-phase-1.md) (`support_alerts`, the typed config accessor), [b0](./backend-phase-0.md) (`IObjectStorage`, `IFieldEncryptor`, `ICurrentUser`, audit interceptor) · **Unlocks:** search visibility ([b7](./backend-phase-7.md)); frontend **f5-b6**
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
This is **backend phase b6**, the verification leg of the build. Balinyaar is a *trust-first* home-nursing
marketplace; the product docs are blunt that "verified trust is your **entire brand**." Vetting is
**platform-owned, non-optional, and performed at the authoritative source** — a nurse's service variants
become bookable **only after every required verification step passes**. This phase builds that gate: the
6-step pipeline, the admin review queue, the structured credential registry that powers the public trust
badge and renewal alerts, and the **one transaction** that flips `nurse_profiles.is_verified`. It sits
between catalog ([b5](./backend-phase-5.md), which built the bookable service variants) and search
([b7](./backend-phase-7.md), which surfaces only verified nurses) — and it is the hard prerequisite that
makes the `nurse_search_index.is_searchable` projection meaningful.
**What already exists (do not rebuild) — built by prior phases:**
- **`nurse_profiles`** — [b3](./backend-phase-3.md) built the nurse extension off `users` (1:1 UNIQUE
`user_id`), carrying the **guarded `is_verified` BIT DEFAULT 0** (no public setter — this phase owns the
only write), `is_accepting_bookings`, the denormalized rating aggregates, and soft-delete. **The legacy
`verification_status` column was deliberately CUT** — `nurse_verifications.status` is the sole source of
verification truth. Do **not** reintroduce a second copy.
- **`nurse_bank_accounts`** — [b3](./backend-phase-3.md) built the payout-IBAN table (`iban` enc,
`iban_hash` UNIQUE, `is_primary` filtered-UNIQUE per nurse, `is_verified`, `matched_national_id`,
`account_holder_from_bank`, `ownership_vendor_ref`, `verified_by_admin_id`) and the
**`IBankAccountOwnershipVerifier`** seam (استعلام شبا / Sheba inquiry). This phase's
`bank_account_verification` step **couples to that table and reuses that seam** — it does not re-register
or re-verify bank accounts.
- **`users` / identity** — [b2](./backend-phase-2.md)/[b3](./backend-phase-3.md): `users.national_id` (enc,
**NULL until KYC passes**), `users.gender`, `shahkar_verified_at`/`national_id_verified_at` (reset to NULL
on phone change), and admin RBAC roles for the review queue. The identity-KYC step **populates
`users.national_id`** on pass; Shahkar and IBAN ownership both compare against that verified national-ID.
- **`support_alerts`** — [b1](./backend-phase-1.md) built the alert table + the **raise API** (built early
precisely because verification raises expiry/renewal/shared-SIM alerts). This phase **raises** alerts; it
does not build the table.
- **The typed, cached config accessor + `platform_configs`** — [b1](./backend-phase-1.md). Read review-SLA
hints, expiry-scan cadence, and any verification thresholds through it; never hardcode.
- **`IObjectStorage`** — [b0](./backend-phase-0.md): presigned/streamed put/get/delete keyed by an opaque
storage key, returning a retrievable URL. Verification documents store bytes here. **Reuse it.**
- **`IFieldEncryptor`** — [b0](./backend-phase-0.md): `Encrypt`/`Decrypt`/`Hash`. `credential_number` is
encrypted PII through this seam. **Reuse it.**
- The b0 foundation: REST surface, `BaseController`, `OperationResult<T>`, CQRS via
**`martinothamar/Mediator`** (not MediatR), Mapster, FluentValidation + `ValidateCommandBehavior`,
`ICurrentUser` + the audit-field SaveChanges interceptor, rate limiting, `IDateTimeProvider`,
`INotificationDispatcher` (in-app write landed via b1).
**What this phase introduces:** the five verification tables + seed, the nurse/admin capabilities, the
guarded `is_verified` flip transaction, the credential-expiry scanner, the public trust badge — and **three
new seams** (`IShahkarVerifier`, `IIdentityKycProvider`, `ICredentialVerifier`). The **scheduled cron** that
drives the expiry scan is DEFERRED — the scan is admin/manually triggered now (see §3.3).
## 2. Required reading (do this first)
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md) — especially
the *Persistence* (encrypted PII through the field encryptor; one config per entity; projected/paginated
reads) and *Architecture* (seams in Application, mocks in Infrastructure) blocks.
- [`product/business/02-nurse-verification.md`](../../../product/business/02-nurse-verification.md) — **the
business rules**: the six steps and what each proves, the data-driven pipeline, the structured credential
registry, continuous monitoring (phone-change re-run, expiry re-verify), and the MVP-vs-DEFERRED line
(automated MoH/INO lookup, ML fraud scoring, and the liability-insurance step are all DEFERRED).
- [`product/data-model/04-verification-and-credentials.md`](../../../product/data-model/04-verification-and-credentials.md)
**the canonical schema** for the five tables. Mirror these field names exactly (especially the
`nurse_credentials` column list and the `UNIQUE` constraints).
- [`product/research/verification.md`](../../../product/research/verification.md) — *why* the design is what
it is: MoH پروانه صلاحیت حرفه‌ای is the single most important credential and **already bundles the
criminal-record screen**; identity is the *easy* layer (buy a KYC vendor, don't build); license check is
**manual** today because no public B2B API exists; the criminal record is **consent-gated to the person**
(nurse-uploaded + re-requested). This is the source of the "manual today, API later" seam shape.
- The **identity digest** for the cross-domain invariants:
`dm_identity_geo_services_verif.md` (`is_verified` is write-guarded; `national_id` NULL until KYC;
`shahkar_verified_at` resets on phone change; `nurse_search_index.is_searchable` depends on `is_verified`).
- **Code to mirror:** [b3](./backend-phase-3.md)'s `nurse_bank_accounts` config + the
`IBankAccountOwnershipVerifier` usage; [b1](./backend-phase-1.md)'s `support_alerts` raise API + the typed
config accessor + the seed-migration pattern (for `verification_step_types`); [b0](./backend-phase-0.md)'s
`IObjectStorage`/`IFieldEncryptor` usage and a `ServiceConfiguration/` seam registration; any existing
`Features/<Area>/{Commands|Queries}/**` for handler structure and `OperationResult` returns.
- **Contract conventions:** [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
(envelope, routing) — money is not central here but follow the format.
- **Prior handoffs:** `dev/shared-working-context/backend/handoff/after-backend-phase-3.md`,
`…-1.md`, `…-0.md`, and `reports/mocks-registry.md` (the `IObjectStorage` /
`IBankAccountOwnershipVerifier` / `IFieldEncryptor` rows you reuse, and where to add the three new ones).
## 3. Scope — build this
Features live under `Baya.Application/Features/Verification/{Commands|Queries}/<Name>/`; entities in
`Baya.Domain/Entities/Verification/`; one `IEntityTypeConfiguration<T>` per entity in
`Persistence/Configuration/VerificationConfig/`; one EF migration for the five tables + one **seed
migration** for `verification_step_types`.
### 3.1 Entities + migration
**`nurse_verifications`** [CORE] — the master per-nurse record; **the SOLE source of verification truth**.
- Fields: `id`, `nurse_id` (FK `nurse_profiles`, **UNIQUE** → 1:1), `status` (enum, see below),
`submitted_at`, `approved_at`, `rejected_at`, `suspended_at` (all nullable), `rejection_reason` (nullable),
`reviewed_by_admin_id` (FK `users` nullable), `internal_notes` (nullable), audit fields, soft-delete.
- Relations: 1:1 → `nurse_profiles`; 1:N → `verification_steps`.
**`verification_step_types`** [CORE] — the admin catalog of pipeline steps (data-driven, not an enum).
- Fields: `id`, `code` (NVARCHAR, **UNIQUE**, stable machine code), `display_name`, `description`,
`is_required` (BIT), `is_automated` (BIT), `automation_provider` (NVARCHAR nullable — e.g. `shahkar`,
`identity_kyc_vendor`), `sort_order` (int), `is_active` (BIT), audit fields.
- **SEED** (via the seed migration) exactly these six stable codes:
`identity_kyc` (automated), `shahkar_match` (automated), `moh_competency_license` (manual today),
`ino_membership` (manual), `criminal_record` (manual, time-limited), `bank_account_verification`
(automated, couples to b3). A new regulatory step (e.g. professional-liability insurance) is one INSERT —
**never** hardcode the six in a C# enum.
- Relations: 1:N → `verification_steps`.
**`verification_steps`** [CORE] — one row per step per nurse.
- Fields: `id`, `nurse_verification_id` (FK), `step_type_id` (FK), `status` (enum, see below),
`external_response_json` (NVARCHAR(MAX) nullable — raw KYC-vendor response for audit),
`expires_at` (DATETIME2 nullable — for time-limited steps), **`is_automated` (BIT — *snapshotted* from the
step type at seed time, read this, never the live step-type)**, `started_at`/`completed_at` (nullable),
`failure_reason` (nullable), audit fields. **`UNIQUE(nurse_verification_id, step_type_id)`**.
- **On expiry → revert to `pending` + `RaiseSupportAlert`** (handled by the scanner, §3.2).
- Relations: N:1 → `nurse_verifications`, `verification_step_types`; 1:N → `verification_documents`.
**`verification_documents`** [CORE] — **metadata only; bytes never in the DB**.
- Fields: `id`, `step_id` (FK `verification_steps`), `object_storage_key` (NVARCHAR — the opaque
`IObjectStorage` key), `integrity_hash` (NVARCHAR(64) — content hash to detect tampering/swap),
`content_type`, `file_size_bytes` (BIGINT), `original_file_name` (nullable), `uploaded_by_user_id`
(FK `users`), audit fields, soft-delete.
- **No PII bytes, ever** — the file lives in object storage behind a **signed URL**; the DB row is metadata.
- Relations: N:1 → `verification_steps`.
**`nurse_credentials`** [MVP] — the structured, queryable credential registry (powers badge + renewal).
- Fields (mirror [`04-verification-and-credentials.md`](../../../product/data-model/04-verification-and-credentials.md)
exactly):
`id`, `nurse_id` (FK `nurse_profiles`), `credential_type` (NVARCHAR — `moh_competency_license` /
`ino_membership` / `criminal_record`), `credential_number` (**NVARCHAR(100) encrypted** via
`IFieldEncryptor`), `holder_name_snapshot` (NVARCHAR(200) — name as printed, for ID cross-check),
`issuing_authority` (NVARCHAR(200)), `issued_at` (DATE nullable), `expires_at` (DATE nullable — drives
renewal alerts), `verification_source` (NVARCHAR(300) nullable — portal URL/method),
`verification_method` (NVARCHAR(20) — `manual` / `portal` / `api`), `verified_by_admin_id` (FK `users`
nullable), audit fields, soft-delete.
- Relations: N:1 → `nurse_profiles`; cross-referenced by the relevant `verification_steps`.
**Status enums** (define as proper enums; persist per project convention):
- `VerificationStatus`: `not_started` | `pending` | `in_review` | `approved` | `rejected` | `suspended`.
- `VerificationStepStatus`: `not_started` | `pending` | `in_review` | `passed` | `failed` | `expired`.
(`pending` = awaiting submission/automation; `in_review` = awaiting manual admin decision.)
### 3.2 Commands & queries (CQRS, `OperationResult`, never throw for expected failures)
| Capability | Type | Route | What it does |
| --- | --- | --- | --- |
| **`AdminUpsertStepTypeCommand`** / **`AdminListStepTypesQuery`** / **`AdminDeactivateStepTypeCommand`** | Cmd/Query | `POST`/`GET`/`DELETE api/v1/admin_verification_step_types[/{id}]` | Admin CRUD over the step-type catalog (`code` immutable once used, `is_required`/`is_automated`/`automation_provider`/`sort_order`/`is_active`). Adding a step = one row. Cached (read-heavy reference data) with invalidation on write. |
| **`SubmitNurseVerificationCommand`** | Command | `POST api/v1/nurse_verification/submit` | For the signed-in nurse (tenancy via `ICurrentUser`): upsert a `nurse_verifications` row, set `status='pending'` + `submitted_at`, and **seed one `verification_steps` row per *active required* `verification_step_types`** (status `pending`/`not_started`, **snapshotting `is_automated`** onto each step). Idempotent: re-submitting does not duplicate steps. |
| **`GetNurseVerificationStatusQuery`** | Query | `GET api/v1/nurse_verification` | The nurse's aggregate `status` + per-step list (code, display, status, `expires_at`, failure reason, whether automated) + a "what's blocking bookability" summary. Projected (`AsNoTracking` + `.Select`), tenancy-scoped. Feeds f5's checklist. |
| **`RequestDocumentUploadUrlCommand`** | Command | `POST api/v1/nurse_verification/steps/{stepId}/upload_url` | Returns a **signed PUT URL** from `IObjectStorage` for a manual-evidence step (MoH license PDF, INO card, عدم سوء پیشینه). Validates the step belongs to the nurse and accepts uploads. Returns the `object_storage_key` the client echoes back on confirm. |
| **`ConfirmDocumentUploadCommand`** | Command | `POST api/v1/nurse_verification/steps/{stepId}/documents` | After the client uploads to the signed URL: persist a `verification_documents` **metadata row** (key, `integrity_hash` computed/echoed, content type, size). **Bytes never enter the DB.** Moves the step to `in_review` if it's a manual step. |
| **`RunIdentityKycCommand`** | Command | `POST api/v1/nurse_verification/steps/identity_kyc/run` | Calls **`IIdentityKycProvider`** (national-ID validity + name match + liveness). Persists `external_response_json`; on pass **populates `users.national_id`** + `national_id_verified_at` and sets the step `passed`; on fail sets `failed` + `failure_reason`. Re-aggregates (§ `AggregateAndFinalize`). |
| **`RunShahkarMatchCommand`** | Command | `POST api/v1/nurse_verification/steps/shahkar_match/run` | Calls **`IShahkarVerifier`** to bind the login SIM ↔ the **verified** `users.national_id`. Persists `external_response_json` + sets `shahkar_verified_at` on pass. **Shared-SIM is an explicit handled failure state** → step `failed` + `RaiseSupportAlert(shared_sim)`. Requires identity KYC passed first (national-ID present). |
| **`RunBankAccountVerificationCommand`** | Command | `POST api/v1/nurse_verification/steps/bank_account_verification/run` | Reuses **`IBankAccountOwnershipVerifier`** (b3) on the nurse's primary `nurse_bank_accounts`: holder national-ID must equal the verified nurse national-ID. On match sets the bank account's `matched_national_id=1` + the step `passed`; on mismatch `failed` (money-mule guard). |
| **`AdminListPendingStepsQuery`** | Query | `GET api/v1/admin_verifications?status=in_review&page=&page_size=` | The manual-review worklist (MoH/INO/criminal steps in `in_review`): nurse, step code, submitted docs (with **signed GET URLs** via `IObjectStorage`). Projected + paginated. |
| **`AdminGetVerificationDetailQuery`** | Query | `GET api/v1/admin_verifications/{nurseVerificationId}` | Full per-nurse detail for the doc-viewer: all steps, documents (signed URLs), existing credentials, identity name (for cross-check). Projected. |
| **`AdminReviewStepCommand`** | Command | `POST api/v1/admin_verifications/steps/{stepId}/decide` | Admin **passes or rejects** a manual step with a required reason on reject. **On pass of a credential-bearing step (MoH/INO/criminal): record a `nurse_credentials` row** (encrypted `credential_number`, `holder_name_snapshot` **cross-checked against the identity name**, authority, issued/expires, `verification_method='manual'`, `verified_by_admin_id`). Writes an audit-log entry (b1 audit interceptor + an explicit decision record). Re-aggregates. |
| **`AggregateAndFinalize`** | Command (internal step, called after every outcome write) | — | Re-reads all *required* steps. Sets `nurse_verifications.status` (`pending``in_review` when any step is `in_review`; `rejected` if any required step `failed`; **`approved` only when every required step is `passed`**). **On all-passed: flip `nurse_profiles.is_verified = 1` in the SAME transaction**, set `approved_at`. Idempotent — re-running an already-approved verification is a no-op. |
| **`AdminSuspendVerificationCommand`** | Command | `POST api/v1/admin_verifications/{nurseVerificationId}/suspend` | Sets `status='suspended'` + `suspended_at` and **reverses the flip — `nurse_profiles.is_verified = 0` in the same transaction** (un-publishing the nurse from search via b7's projection hook). Records reason + admin. |
| **`ScanExpiringCredentialsCommand`** | Command | `POST api/v1/admin_verifications/scan_expiring` (admin-triggered; cron DEFERRED) | Scans `nurse_credentials.expires_at` and the matching time-limited `verification_steps.expires_at` (criminal-record especially). For each lapsed credential: **revert the step to `pending`/`expired`, raise a `support_alert` + a renewal-prompt notification**, and if a *required* credential lapsed, re-aggregate (which can drop `is_verified` via suspension semantics). Paginated/batched. |
| **`GetVerifiedTrustBadgeQuery`** | Query | `GET api/v1/nurses/{nurseId}/trust_badge` (public) | The public "verified" badge sourced from approved `nurse_verifications` + non-expired `nurse_credentials` (credential **types** held, e.g. "MoH license · INO member", **never the encrypted numbers**). Projected, cached. Feeds the public nurse profile (f6). |
- **Controllers:** `NurseVerificationController` (nurse policy, tenancy-scoped),
`AdminVerificationController` (admin policy, sensitive — **rate-limited**), and the public
trust-badge action (anonymous, read-only). All `sealed : BaseController`, inject `ISender`, return
`base.OperationResult(...)`, snake_case `[controller]`/`[action]` routes, `CancellationToken` threaded.
- **Validators:** FluentValidation on the submit/run/decide/upload commands (national-ID format on identity
run; required rejection reason on a reject decision; required `expires_at` on the criminal-record
credential; step ownership/tenancy on the nurse-side commands).
### 3.3 DEFERRED (build the seam/flag, not the feature)
- **The scheduled expiry-scan cron** (`CredentialExpiryScannerJob`, emulating Nursys e-Notify cadence) —
DEFERRED. The scan logic ships now as `ScanExpiringCredentialsCommand`, admin-triggered; leave a clean
entry point + a `verification_expiry_scan_cadence` config key. (Roadmap: a hosted scheduler.)
- **Automated MoH/INO license lookup** — DEFERRED behind `ICredentialVerifier` (`verification_method='api'`);
the manual review path is the default impl today. Build the seam, not the API client.
- **`fraud_flags` / ML fraud scoring** and the **professional-liability-insurance step** — DEFERRED
(`fraud_flags` modeled-but-inactive; the insurance step is "addable as a row when required"). Do not build.
- **Customer national-ID KYC** — out of scope here (the column stays unused per b3). Do not gate on it.
## 4. Mocks & seams in this phase
| Seam | Owner | Mock behaviour | Registry |
| --- | --- | --- | --- |
| **`IShahkarVerifier`** | **introduced here** | `MatchAsync(phone, nationalId, ct)` returns a deterministic result + a fake `vendor_ref` + an `external_response_json` blob: **pass** when phone+national-ID match a seed map; **force shared-SIM failure** for a known test number (the explicit handled state). No real Shahkar call. | **add a new row** (🟡) |
| **`IIdentityKycProvider`** | **introduced here** | `VerifyAsync(nationalId, livenessPayload, ct)` returns deterministic **pass/fail by test national-ID** + a fake vendor ref + `external_response_json`; pass implies a name-match string used for the credential cross-check. No real liveness/OCR. | **add a new row** (🟡) |
| **`ICredentialVerifier`** | **introduced here** | The **manual-admin default impl** *is* the mock: `Verify(credentialType, …)` returns `RequiresManualReview` today (`verification_method='manual'`), shaped so an `api`/`portal` impl drops in later for MoH/INO. No portal call. | **add a new row** (🟡) |
| `IBankAccountOwnershipVerifier` | reuse from **b3** | Sheba inquiry mock; returns holder national-ID = entered one (or mismatch for a test IBAN). Used by `bank_account_verification`. | reuse row |
| `IObjectStorage` | reuse from **b0** | local-disk/in-memory blob store; signed PUT/GET URLs for `verification_documents`. | reuse row |
| `IFieldEncryptor` | reuse from **b0** | local symmetric key; encrypts `credential_number`, never logs plaintext. | reuse row |
| `ICacheService` | reuse from **b0/b1** | in-memory; behind the typed config accessor + the step-type/badge caches. | reuse row |
Each new seam is an **Application-layer interface** with an **Infrastructure mock** and **DI registration**
via a `ServiceConfiguration/` extension (real impl is config-selected later — **never** an `if (mock)` branch
in a handler). Persist the raw vendor response in `external_response_json` so the audit trail survives the
swap. Append the three new rows to
[`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md)
(seam, file, what's faked, config keys, **step-by-step how to make it real** — which Iranian KYC vendor
[Finnotech / U-ID / Jibbit / Farashensa / Verify / Kavoshak], which methods to implement, what to test; and
for `ICredentialVerifier`, the note that MoH/INO have **no public B2B API** so it stays manual until one
appears).
## 5. Critical rules you must not get wrong
- **`nurse_verifications.status` is the SINGLE source of truth.** `nurse_profiles.verification_status` no
longer exists — **do not reintroduce it** or any second copy of verification state. Every read of "is this
nurse verified for *state* purposes" goes through `nurse_verifications.status`; the only derived boolean is
`nurse_profiles.is_verified`, written solely by the flip below.
- **Flip `nurse_profiles.is_verified = 1` ONLY inside the all-passed transaction** that confirms every
*required* step is `passed` (`AggregateAndFinalize`). Never set it from a controller, a partial pass, or an
out-of-band update. **Reverse it (`is_verified = 0`) in the same transaction on suspension** — a suspended
or lapsed nurse must immediately stop being bookable/searchable.
- **A nurse is bookable only after all required steps pass.** Any failing/pending/expired required step keeps
every service variant unbookable. This is the gate b7's `nurse_search_index.is_searchable` reads.
- **The pipeline is data-driven — read required steps from `verification_step_types`, never a code enum.** A
new step is an INSERT. Seed the six codes; don't branch on them in a `switch` that would break when a row
is added.
- **`step_type.is_automated` is SNAPSHOTTED onto `verification_steps` at seed time — read the snapshot, not
the live step type.** Historical records must survive later catalog edits (an admin toggling a step's
automation must not rewrite the meaning of past verifications).
- **Expiring credentials auto-revert the step.** On `expires_at` lapse (criminal-record especially), revert
the step to `pending`/`expired`, **raise a `support_alert`**, send a renewal prompt, and if a *required*
credential lapsed, re-gate bookability. A lapsed certificate must **never** silently keep a nurse verified.
- **`users.national_id` is populated only after the identity step passes**, and every downstream comparison
(Shahkar match, IBAN ownership, credential holder-name cross-check) compares against **that** verified
national-ID. An unverified registration must never look KYC-complete.
- **IBAN ownership gates the first payout, not admin eyeballing.** `bank_account_verification` reuses b3's
`IBankAccountOwnershipVerifier`; the holder national-ID must equal the verified nurse national-ID
(money-mule prevention). Never pass the step on a mismatch.
- **`verification_documents` store metadata only — bytes live in object storage behind signed URLs, with an
integrity hash; files are never public.** No document byte stream is ever written to or returned from the
DB; access is always a short-lived signed URL.
- **`credential_number` is encrypted PII** (through `IFieldEncryptor`) and **`holder_name_snapshot` must be
cross-checked against the nurse's identity name** before a credential is recorded as verified. Never trust
an uploaded file alone — forgery (the "imposter nurse") is the documented attack.
- **Shared-SIM is an explicit handled state**, not an undefined edge: fail Shahkar gracefully with a clear,
non-accusatory reason and raise a `support_alert`. Re-run Shahkar on phone change (`shahkar_verified_at`
resets to NULL upstream).
- **Every manual admin decision is auditable** (the b1 audit interceptor + an explicit decision record) for
defensibility — verification is platform-owned and must never be marketed as a check not actually performed.
- **All vendor steps are mocked now — but behind real DI seams.** No mock behaviour baked into call sites;
the swap is implementation-only and config-selected.
- **Tenancy:** every nurse-side command/query is scoped to the authenticated nurse via `ICurrentUser`; a
nurse can never read or act on another nurse's verification. Admin endpoints sit behind the admin policy and
are rate-limited. The public trust badge exposes **credential types held, never numbers**.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] The five tables (`nurse_verifications`, `verification_step_types`, `verification_steps`,
`verification_documents`, `nurse_credentials`) exist via one migration, each with its
`IEntityTypeConfiguration<T>`, the `nurse_id` UNIQUE (1:1) on `nurse_verifications`, the
`UNIQUE(nurse_verification_id, step_type_id)` on `verification_steps`, the **snapshot `is_automated`**
column, encrypted `credential_number`, and soft-delete/audit wiring.
- [ ] The seed migration loads the **six** `verification_step_types` with the exact stable codes and the
correct `is_required`/`is_automated` flags.
- [ ] All §3.2 commands/queries implemented (CQRS, `OperationResult`, projected + paginated reads,
validators), with `NurseVerificationController`, `AdminVerificationController`, and the public badge.
- [ ] **`IShahkarVerifier`, `IIdentityKycProvider`, `ICredentialVerifier`** introduced (Application
interfaces, Infrastructure mocks, DI via a `ServiceConfiguration/` extension, config-selected); the b3
`IBankAccountOwnershipVerifier` and b0 `IObjectStorage`/`IFieldEncryptor` seams reused (not redefined).
No `if (mock)` in handlers.
- [ ] The **flip transaction** is correct: `is_verified=1` only on all-required-passed, in one transaction;
`is_verified=0` on suspension, in one transaction; the expiry scan reverts steps + raises alerts.
- [ ] Handler unit tests (NSubstitute) for: step seeding from required step-types, the snapshot of
`is_automated`, the automated-step pass/fail (Shahkar + identity KYC mocks), the manual-review →
credential-record path with holder-name cross-check, the all-passed flip, the suspension reverse, and
the expiry revert + alert. ≥1 `WebApplicationFactory` integration test per controller (happy path, 401,
validation 400). `dotnet build Baya.sln` zero new warnings; `dotnet test Baya.sln` green.
- [ ] The `Baya.Application/Features/Verification/**` area is added to the **Project map** in
`server/CLAUDE.md`; the three new seams noted where seams are documented.
- [ ] The contract `dev/contracts/domains/verification.md` is written and the `swagger.json` snapshot
republished.
## 7. How to test (what a human can verify after this phase)
Prereqs: a nurse user with a `nurse_profiles` row (b3) and (for the bank step) a primary
`nurse_bank_accounts` row; an admin user. The seam mocks are deterministic — use the seed/test national-IDs.
1. **Seed step-types**`GET api/v1/admin_verification_step_types` → the **six** seeded codes appear with
correct `is_required`/`is_automated` flags. Add a 7th via `POST` → it persists (proves data-driven).
2. **Submit** — as the nurse, `POST api/v1/nurse_verification/submit` → a `nurse_verifications` row in
`pending` and **one `verification_steps` row per required step**, each with `is_automated` snapshotted.
`GET api/v1/nurse_verification` shows the checklist + "what's blocking bookability".
3. **Upload a document**`POST …/steps/{stepId}/upload_url` returns a signed URL; PUT a file to it (mock
storage); `POST …/steps/{stepId}/documents` → a `verification_documents` **metadata row** with the
`object_storage_key` + `integrity_hash` stored; **no bytes in the DB**; the manual step moves to `in_review`.
4. **Run automated Shahkar / identity KYC**`POST …/steps/identity_kyc/run` (pass test national-ID) →
step `passed`, `users.national_id` populated, `external_response_json` stored; then
`POST …/steps/shahkar_match/run``passed` (and `shahkar_verified_at` set). Run Shahkar with the
**shared-SIM** test number → step `failed` + a `support_alert` raised.
5. **Run bank-account verification**`POST …/steps/bank_account_verification/run` (matching test IBAN) →
`passed` + the account's `matched_national_id=1`; with the **mismatch** test IBAN → `failed`.
6. **Admin approves manual steps**`GET api/v1/admin_verifications?status=in_review` shows the queue;
`POST api/v1/admin_verifications/steps/{stepId}/decide` (pass) on MoH + INO + criminal-record →
each records a `nurse_credentials` row (encrypted number, holder-name cross-check, expiry on the
criminal record). Reject one with a reason → step `failed` and the nurse sees the reason.
7. **All steps passed flips `is_verified` in one transaction** — once every required step is `passed`,
`nurse_verifications.status='approved'` **and** `nurse_profiles.is_verified=1` — confirm both changed
together (verify there is no in-between state where the verification is approved but `is_verified` is still 0).
8. **Suspend reverses it**`POST api/v1/admin_verifications/{id}/suspend``status='suspended'` **and**
`is_verified=0` together.
9. **Expire a credential** — set a `nurse_credentials.expires_at` (criminal record) in the past, run
`POST api/v1/admin_verifications/scan_expiring` → the matching step reverts to `pending`/`expired`, a
`support_alert` is raised, and (since it's required) the nurse is re-gated.
10. **Public trust badge**`GET api/v1/nurses/{nurseId}/trust_badge` → shows the verified status +
credential **types** held; **no credential numbers** are ever returned.
## 8. Hand off & document (close the phase)
- **Docs to update:** the **Project map** in [`server/CLAUDE.md`](../../../server/CLAUDE.md) (add the
`Features/Verification/**` area + the three new seams). If you discover/confirm a rule the product docs
don't capture (e.g. the exact `in_review` vs `pending` step semantics, the renewal-prompt notification, or
the trust-badge "types not numbers" rule), record it in
[`product/business/02-nurse-verification.md`](../../../product/business/02-nurse-verification.md) /
[`product/data-model/04-verification-and-credentials.md`](../../../product/data-model/04-verification-and-credentials.md)
— don't invent rules. Note the new seam family in `server/CONVENTIONS.md` if it establishes a reusable pattern.
- **Contract to write:** **`dev/contracts/domains/verification.md`** (per
[`../../contracts/domains/_TEMPLATE.md`](../../contracts/domains/_TEMPLATE.md)) — the nurse endpoints
(submit, status, upload-url, confirm-document, run identity/Shahkar/bank steps), the admin endpoints
(step-type CRUD, pending-steps queue, detail, decide, suspend, scan-expiring), and the public trust-badge
endpoint; the `VerificationStatus` / `VerificationStepStatus` enums and the six step-type `code`s; the
step / step-type / document (signed-URL) / credential DTO shapes (**`credential_number` never serialized**;
badge shows **types not numbers**); auth/tenancy/rate-limit notes; the side effects (`is_verified` flip,
`support_alerts` raised, `users.national_id` populated). Republish the `swagger.json` snapshot per
[`../../contracts/openapi/README.md`](../../contracts/openapi/README.md). This is what **f5-b6** consumes.
- **Handoff & report:** write `dev/shared-working-context/backend/handoff/after-backend-phase-6.md` (the
verification pipeline is live, what f5 can now build — the nurse verification checklist, identity submit,
document upload, credentials form, under-review state, trust badge; which endpoints/contracts are live;
that all vendors are mocked behind `IShahkarVerifier`/`IIdentityKycProvider`/`ICredentialVerifier` and the
reused b3 bank seam). Append to `backend/STATUS.md`, write
`dev/shared-working-context/reports/backend-phase-6-report.md` (what was built, **what is now testable and
exactly how** per §7, what is mocked + how to make it real, contracts produced, follow-ups: the expiry-scan
cron, automated MoH/INO lookup, `fraud_flags`), and update
`dev/shared-working-context/reports/mocks-registry.md` (the three new seam rows → 🟡; the reused rows noted).
- **Memory:** save a `project` memory note for the non-obvious decisions this phase fixes — `status` is the
single source of truth (no `verification_status` copy), the guarded `is_verified` flip/reverse transaction,
the `is_automated` snapshot rule, the data-driven step catalog, the expiry-revert + alert flow, documents-as-
metadata-only, and the three new vendor seams — with a one-line pointer in `MEMORY.md`.
+357
View File
@@ -0,0 +1,357 @@
# Backend Phase 7 — Search & matching (nurse search index)
> **Mission:** make verified nurses discoverable. Build the **denormalized `nurse_search_index`** — one
> flat row per bookable variant **per covered area** (fan-out) — and the write-side **maintenance hooks**
> that keep it consistent on every change to a nurse's profile, variants, service areas, verification, or
> reviews. Then build the single family-facing **search query** (filter by category, city/district with
> NULL-district = whole-city, gender, price; sort by rating; paginate) behind a **search-service seam**
> so an Elasticsearch backend can drop in later without touching callers. A row is searchable **only**
> when the nurse is verified, not suspended, accepting bookings, and the variant is active — an
> unverified or paused nurse must **never** surface. This is the discovery layer the whole booking funnel
> stands on.
>
> **Track:** backend · **Depends on:** [b5](./backend-phase-5.md) (catalog & variants), [b6](./backend-phase-6.md) (verification → `is_verified`), [b4](./backend-phase-4.md) (geography & service areas), [b3](./backend-phase-3.md) (nurse profiles, gender, rating aggregates) · **Unlocks:** booking discovery (b8); frontend **f6-b7**
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
This is **backend phase b7**, the discovery layer. Catalog (b5) gave nurses priced **variants** — the
atomic bookable unit; geography (b4) gave them **service areas** (which cities/districts they travel to);
verification (b6) gave them the guarded **`is_verified`** flag; profiles (b3) gave them **gender** and the
denormalized **rating aggregates**. All those facts live in normalized tables across four domains. A naive
"find me a verified female elder-care nurse in Tehran district 3 under X rials, best-rated first" query
joins `nurse_profiles → nurse_service_variants → nurse_service_areas` plus a rating sort across 4+ tables —
slow at modest scale. This phase **flattens all of it into one maintained-on-write read model** so search
is a single indexed, paginated table scan, and **introduces the `INurseSearch` seam** so MVP runs on SQL
today and Elasticsearch can replace the backend later by config alone. No Elasticsearch at MVP — the index
table *is* the search backend (and stays the projection/fallback even after Elastic lands).
**What already exists (do not rebuild) — built by prior phases:**
- **Catalog & bookable variants** — [b5](./backend-phase-5.md) built `service_categories`,
`service_option_groups`/`service_option_values`, and the nurse-side **`nurse_service_variants`**
(`nurse_id`, `service_category_id`, `price`, `price_unit``per_hour`/`per_session`/`per_half_day`/
`per_day`/`per_24h`, `session_count`, `is_active`, `display_name`) + `nurse_service_variant_options`.
**The variant is the bookable unit — search projects *variants*, not nurses.** b5 already stubbed a
write-side hook on variant create/edit/activate/deactivate (the catalog digest's `ISearchIndexWriter`
note); **this phase owns the real implementation** behind that hook — do not re-create the variant CRUD.
- **Geography & service areas** — [b4](./backend-phase-4.md) built `provinces`/`cities`/`districts` (each
with `sort_order`, `is_active`) and **`nurse_service_areas`** (`nurse_id`, `city_id`, `district_id` NULL,
`UNIQUE(nurse_id, city_id, district_id)`; **`district_id = NULL` means the whole city**). The geo lookup
queries (ListProvinces/ListCities/ListDistricts) and the nurse Add/Remove-ServiceArea commands already
exist — **this phase hooks the index fan-out onto the service-area writes**, it does not rebuild the geo
domain or the area editor.
- **Nurse profiles, gender & rating aggregates** — [b3](./backend-phase-3.md) built `nurse_profiles`
(`user_id` UNIQUE, the guarded `is_verified` BIT, `is_accepting_bookings` BIT, the denormalized
`average_rating`/`total_reviews`/`total_completed_bookings`) and `users.gender` (`male`/`female`). The
**aggregate-recompute on review/booking transitions** is wired in b3/b14 — this phase **reads** those
fields into the index, it does not own the recompute math.
- **Verification & the `is_verified` flip** — [b6](./backend-phase-6.md) built `nurse_verifications`
(the sole source of verification truth; `status``not_started`/`pending`/`in_review`/`approved`/
`rejected`/`suspended`) and the **guarded flip** that sets `nurse_profiles.is_verified=1` only inside
the confirm transaction (and reverses it on suspension). **This phase hooks index maintenance onto that
flip** so flipping `is_verified` flips the rows' `is_searchable` — it does not touch the verification
pipeline.
- The b0 foundation + b1 plumbing: REST surface, `BaseController`, `OperationResult<T>`, CQRS via
**`martinothamar/Mediator`**, `IDateTimeProvider`, the typed cached `platform_configs` accessor, and the
**`ICacheService`** seam (optional result/geo-lookup caching). Reuse all of these.
**What this phase introduces:** the `nurse_search_index` table + its EF config + migration, the
**index-maintenance handlers** (upsert/fan-out/remove + `is_searchable` recomputation + a full backfill/
rebuild job), the **`SearchNurses` query** behind the new **`INurseSearch` seam** (SQL impl
`SqlNurseSearch` now), and the public `GET /search/nurses` endpoint. The same-gender filter is first-class
here. Booking-side capture of `booking_requests.required_caregiver_gender` is owned by **b8 (DEFERRED**
here — see §3).
## 2. Required reading (do this first)
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md) —
especially *Persistence* (AsNoTracking + `.Select` projection, pagination on every list, one
`IEntityTypeConfiguration<T>` per entity) and *Performance/caching* (cache read-heavy data behind the
cache seam).
- [`../../../product/business/04-search-and-matching.md`](../../../product/business/04-search-and-matching.md) —
**the business rules**: search by category + city/district + price + rating; geography driven by
nurse-declared service areas (city-level row = whole city); **the denormalized index exists only when the
nurse is verified + not suspended + the variant active**; **same-gender matching as a first-class,
near-hard filter** surfaced *before* booking; MVP (this) vs DEFERRED (map discovery, hard availability
filter, algorithmic ranking).
- [`../../../product/data-model/03-services-and-pricing.md`](../../../product/data-model/03-services-and-pricing.md) —
**the canonical `nurse_search_index` schema** (the field table: `variant_id`, `nurse_id`,
`service_category_id`, copied `price`/`price_unit`, `city_id`/`district_id` one-row-per-area, `nurse_gender`,
copied `average_rating`/`total_reviews`/`total_completed_bookings`, `is_searchable`, `updated_at`) and the
**"maintained on writes to `nurse_profiles`, `nurse_service_variants`, `nurse_service_areas`, `reviews`"**
+ **"`is_searchable=1` only when the source nurse/variant are bookable"** invariants. Mirror these names
exactly.
- **Code to mirror:** b5's `Features/Catalog/**` (the variant create/edit/activate/deactivate commands and
the write-side hook stub you'll implement); b4's `nurse_service_areas` config + the Add/Remove-ServiceArea
commands; b3's `nurse_profiles` config (gender + rating aggregate columns) and the aggregate-recompute
path; b6's `is_verified` flip transaction. Mirror their `Features/<Area>/{Commands|Queries}/<Name>/`
layout, `IEntityTypeConfiguration<T>`, and the `IUnitOfWork`/`CommitAsync` pattern.
- **Contract conventions:** [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
and [`money-and-types.md`](../../contracts/conventions/money-and-types.md) (IRR `BIGINT`, the envelope,
pagination shape). `price` in the index and in search responses is **IRR `long`/`BIGINT`** — no floats.
- **Prior handoffs:** `dev/shared-working-context/backend/handoff/after-backend-phase-5.md`, `…-6.md`,
`…-4.md`, `…-3.md`, and `reports/mocks-registry.md` (the `INurseSearch`/`ICacheService` seam rows).
## 3. Scope — build this
Money (`price`) is IRR `long` / `BIGINT`. The search read model + its maintenance live under
`Baya.Application/Features/Search/{Commands|Queries}/<Name>/`; the entity in
`Baya.Domain/Entities/Search/`; one `IEntityTypeConfiguration<T>` in
`Persistence/Configuration/SearchConfig/`; the `INurseSearch` seam in `Application/Contracts/`, its SQL
implementation in Infrastructure; one EF migration for the table.
### 3.1 Entity + migration
**`nurse_search_index`** [CORE] — denormalized read model; **one row per (variant × covered area)** (fan-out).
- Fields (mirror [`product/data-model/03-services-and-pricing.md`](../../../product/data-model/03-services-and-pricing.md)):
- `id` BIGINT PK.
- `variant_id` BIGINT FK → `nurse_service_variants`.
- `nurse_id` BIGINT FK → `nurse_profiles`.
- `service_category_id` BIGINT FK → `service_categories` (copied from the variant — the primary search
dimension).
- `price` BIGINT, `price_unit` NVARCHAR (copied from the variant; closed `price_unit` set).
- `city_id` BIGINT, `district_id` BIGINT **NULL** — the covered area this row represents.
**`district_id = NULL` is a meaningful value: "whole city".** One row per area the nurse covers
(fan-out); a nurse covering 3 areas with 2 active variants yields up to 6 rows.
- `nurse_gender` NVARCHAR(10) — copied from `users.gender` via the nurse, for the same-gender filter.
- `average_rating`, `total_reviews`, `total_completed_bookings` — copied from `nurse_profiles`.
- `is_searchable` BIT — **true only when** nurse `is_verified=1` AND `nurse_verifications.status` not
`suspended` AND `is_accepting_bookings=1` AND variant `is_active=1` (see §5). The single visibility gate.
- `updated_at` DATETIME2 — stamped from `IDateTimeProvider` on every upsert.
- **Indexes (this is the whole point — get them right):**
- A **covering composite index** for the hot search path:
`(is_searchable, service_category_id, city_id, district_id) INCLUDE (price, nurse_gender, average_rating, nurse_id, variant_id)`
— so the filtered, rating-sorted page is served from the index without key lookups.
- A **filtered unique index** `UNIQUE(variant_id, city_id, district_id) WHERE deleted_at IS NULL`
(district_id NULL participating) so a given variant×area appears **exactly once** — this is the upsert
target and the anti-duplication guard.
- A secondary index on `nurse_id` (so a nurse-scoped rebuild/remove is cheap).
- **No business writes here.** This table is a **read-only projection** — only the maintenance handlers in
§3.2 write it, and only by re-deriving from source tables. Soft-delete + audit/`updated_at` wiring per
conventions; the table is fully **re-derivable** from source by the backfill job (§3.2).
### 3.2 Index-maintenance handlers (the write side — owned by the source's code path)
The index is maintained **inline, inside the same transaction** as the source write that changes a
projected fact. The projection is written **only by the code path that owns the source row** — a variant
write reindexes that variant, a profile/verification write reindexes that nurse, a service-area write fans
out/removes that nurse's rows. These are small internal commands the existing b3/b4/b5/b6 handlers call (or
that the SaveChanges pipeline dispatches) — **not** public endpoints.
| Command | Trigger (source write) | What it does |
| --- | --- | --- |
| **`ReindexVariantCommand(variantId)`** | `nurse_service_variants` create / edit (price, category, options) / activate / deactivate (b5) | Recomputes the index rows for **this variant across all the nurse's service areas**: upserts one row per area (copying price/unit/category, the nurse's gender + rating + searchability), and sets `is_searchable` per the §5 predicate. On **deactivate** the variant's rows go `is_searchable=0` (kept, not deleted — soft-delete the rows on hard variant deletion only, which b5 forbids). |
| **`ReindexNurseCommand(nurseId)`** | `nurse_profiles` change: the **`is_verified` flip** (b6), **suspend/un-suspend**, **`is_accepting_bookings` toggle** (b3), and the **rating-aggregate recompute** on review/booking transitions (b3/b14) | Re-derives **every row for the nurse** = (each active variant) × (each service area), recomputing `is_searchable`, and refreshing the copied `nurse_gender`/`average_rating`/`total_reviews`/`total_completed_bookings`. Flipping `is_verified` from 1→0 (or suspend) sets **all** the nurse's rows `is_searchable=0` in one go; flipping 0→1 (with accepting + active variants) makes them searchable. |
| **`FanOutServiceAreaCommand(nurseId, cityId, districtId)`** | `nurse_service_areas` **add** (b4) | Inserts one index row **per active variant** for the newly-covered area (respecting the `UNIQUE(variant_id, city_id, district_id)` upsert guard), with `is_searchable` per §5. |
| **`RemoveServiceAreaRowsCommand(nurseId, cityId, districtId)`** | `nurse_service_areas` **remove** (b4) | Deletes (soft-deletes) the index rows for that nurse×area across all variants. Removing an area must drop exactly those rows — **don't collapse areas or you break geo filtering** (§5). |
| **`RebuildSearchIndexCommand`** [job] | manual admin trigger / first-launch / nightly reconciliation | **Idempotent full rebuild**: truncates+repopulates (or upserts+prunes) the entire index from `nurse_profiles` × `nurse_service_variants` × `nurse_service_areas`, applying §5. This is the convergence/reconciliation path — the index must be **re-derivable from source** at any time. Batched/paginated so it scales. Admin-only endpoint `POST api/v1/admin_search/rebuild_index` (rate-limited, admin policy). |
- **Transactionality:** the reindex step runs **inside the same `IUnitOfWork` transaction** as its source
write (single `CommitAsync`) so the projection can never diverge from the source on a successful commit; a
source write that rolls back rolls back its index change too. (The seam is shaped so a later Elastic
feeder can instead consume these as outbox events — see §4 — but the **SQL path applies them inline
today**.)
- **No `ISearchIndexWriter` controller surface.** These commands are internal; they are invoked from the
owning domain's handlers, never exposed as their own REST routes (except the admin rebuild job).
### 3.3 The search query + seam (the read side)
**`INurseSearch`** (Application contract) — the search-service seam. SQL implementation `SqlNurseSearch`
(Infrastructure) reads **only** `nurse_search_index WHERE is_searchable = 1`. **All callers depend on the
interface**, never on raw SQL or an Elastic client, so the MVP→Elastic swap is config-only.
**`SearchNursesQuery`** [CORE] — the single family-facing discovery query, delegating to `INurseSearch`.
- Route: **`GET api/v1/search/nurses`** (public — discovery is pre-auth; **rate-limited** as an unauthenticated
public endpoint).
- Filters (all optional except category + city per the product doc's "city required, district optional"):
- `service_category_id` (required) — the primary dimension.
- `city_id` (required), `district_id` (optional) — **geography matching:** a city search matches **both**
the city-only rows (`district_id IS NULL`, "whole city") **and** any row for a district in that city; a
district search matches that district's rows **plus** the whole-city rows (NULL district covers it). Get
this exactly right (§5).
- `nurse_gender` (optional, `male`/`female`) — the **first-class same-gender filter**.
- `min_price` / `max_price` (optional, IRR `BIGINT`) — price range over the copied `price`.
- (Optional, surfaced for UI) `price_unit` filter so "per_hour" and "per_day" listings can be compared
like-for-like; not required.
- Sort: by `average_rating` **descending** (stable tiebreak on `total_reviews` desc then `nurse_id` so
paging is deterministic). Rating sort is the only MVP sort.
- **Always paginated** (`page`/`page_size`, default/max per conventions) — `AsNoTracking()` + `.Select(...)`
projection to a `NurseSearchResultDto` (nurse_id, variant_id, category, price + unit, nurse_gender,
average_rating, total_reviews, total_completed_bookings, city/district) — never hydrate entities to map
them.
- **Caching:** optionally cache hot (category, city, gender) result pages behind **`ICacheService`** (reuse
the b0/b1 seam) with a short TTL, invalidated on index writes for the affected city/category — or ship
no-cache at MVP and add the decorator later. Geo-lookup dropdowns (provinces/cities/districts) are already
cacheable via b4; don't duplicate them here.
- **Controller:** `SearchController` (`sealed : BaseController`, inject `ISender`, snake_case
`[controller]`/`[action]` routes, `base.OperationResult(...)`, `CancellationToken` threaded). Plus
`AdminSearchController` for the rebuild job (admin policy).
- **Validators:** FluentValidation on `SearchNursesQuery` (category + city required; `min_price ≤ max_price`;
`nurse_gender ∈ {male,female}` when present; `page_size` ≤ max).
### 3.4 DEFERRED (do not build; leave the seam/pointer)
- **Booking-side same-gender capture** — `booking_requests.required_caregiver_gender` (`male`/`female`/`any`)
and the "surface the chosen gender *into* the booking flow before booking" guarantee are owned by
**[b8](./backend-phase-8.md)**. This phase makes `nurse_gender` a **first-class search facet** (so families
can narrow up front) and stops there. (DEFERRED → b8.)
- **Elasticsearch backend** (`ElasticNurseSearch` behind `INurseSearch`) and the **feeder/outbox daemon**
that streams source changes into Elastic. DEFERRED — the SQL index is the MVP backend and remains the
projection/fallback. Build the **seam**, not the Elastic impl. (See §4.)
- **Availability as a hard filter** — `nurse_availability_slots`/`nurse_availability_exceptions` are **soft
guidance only**; never block a search result on availability ([`product/business/04-search-and-matching.md`](../../../product/business/04-search-and-matching.md) §(c)). DEFERRED.
- **Map-based discovery / geocoding** (`IGeocoder` for radius search), **algorithmic ranking beyond rating**,
**"preferred nurse" continuity-of-carer** suggestions. DEFERRED.
## 4. Mocks & seams in this phase
| Seam | Owner | Behaviour | Registry |
| --- | --- | --- | --- |
| **`INurseSearch`** | **introduced here** | The search-service seam. **MVP impl `SqlNurseSearch` is the real backend, not a mock** — keep it production-grade: it reads `nurse_search_index WHERE is_searchable=1`, applies the category/city/district/gender/price filters, rating sort, and pagination. The DEFERRED `ElasticNurseSearch` is a config-selected drop-in later; callers depend only on `INurseSearch`. | **add a new row** (🟢 SQL is real; Elastic 🟡 deferred) |
| **`ISearchIndexWriter`** (events) | **introduced here** (shape only) | The index-maintenance seam. The SQL path applies the reindex/fan-out/remove **inline** today (§3.2). Shape it so the same change events can later be routed to an **outbox/queue** for the Elastic feeder instead of an inline upsert — record the swap path, but **do not** build the queue now. | **add a new row** (🟡 outbox deferred) |
| `ICacheService` | reuse from **b0/b1** | in-memory; optional decorator over hot search result pages + the typed config accessor. | reuse row |
| `IDateTimeProvider` | reuse from **b0** | stamps `updated_at` on every index upsert (deterministic in tests). | reuse row |
The Elastic implementation is a **DI-registered drop-in** behind `INurseSearch` (selection by config,
**never** an `if (mock)` branch in a handler). Append the `INurseSearch` + `ISearchIndexWriter` rows to
[`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md)
(seam, file, what's real/faked, config keys, **step-by-step how to make it real** — the Elastic client
package, the index mapping, the feeder daemon that consumes the writer's events via outbox/CDC, the
config switch that points `INurseSearch` at `ElasticNurseSearch`, and the fact that the SQL index stays
the fallback/reconciliation source).
## 5. Critical rules you must not get wrong
- **The visibility invariant is the safety rule.** A row is `is_searchable = 1` **only** when the nurse is
`is_verified = 1` AND **not suspended** (`nurse_verifications.status != 'suspended'`) AND
`is_accepting_bookings = 1` AND the variant `is_active = 1`. **An unverified, suspended, paused, or
deactivated nurse/variant must never appear in results.** Get this wrong and you leak unvetted or
unbookable nurses to families — the single highest-stakes bug in this phase. Recompute `is_searchable` on
**every** relevant source write; never trust a stale value.
- **The bookable unit is the variant, not the nurse.** Search, results, and (later) booking operate on
`nurse_service_variants`. The index is keyed per-variant-per-area; never collapse a nurse's variants into
one row.
- **The index is a read-only projection — never let search mutate source.** All writes re-derive from
`nurse_profiles` / `nurse_service_variants` / `nurse_service_areas` / `reviews`. The `RebuildSearchIndexCommand`
must reconstruct the entire index from source with the same result as the incremental hooks — incremental
maintenance and full rebuild must **converge**. If they can diverge, the maintenance logic is wrong.
- **Maintain inside the source's transaction.** The reindex runs in the **same unit of work** as the write
that changed the projected fact (one `CommitAsync`), so a committed source change always carries its index
change and a rolled-back one carries neither. The projection is written **only by the code path owning the
source row** — don't reindex a variant from the profile handler, or vice-versa.
- **`district_id = NULL` means the whole city — a real coverage value, not missing data.** A **city search
matches both city-only rows (NULL district) and every district row in that city**; a **district search
matches that district's rows *and* the whole-city (NULL) rows.** NULL participates correctly in the
`UNIQUE(variant_id, city_id, district_id)` upsert guard. Reimplementing geography as GPS radii is wrong —
think named districts.
- **Fan-out cardinality is exact.** One index row per (variant × covered area). Adding a service area
**inserts** one row per active variant; removing it **deletes** exactly those rows; deactivating a variant
flips its rows to `is_searchable=0`. Don't collapse, dedupe-away, or orphan rows.
- **Same-gender matching is near-hard and first-class.** `nurse_gender` is an **exposed, up-front search
filter** — for bodily-care, a gender mismatch is culturally unacceptable. Make same-gender easy to select
and the facet prominent; never silently default or drop it. (Carrying the chosen gender *into* the booking
request is b8.)
- **Money is IRR `BIGINT`, no floats.** The copied `price` and the `min_price`/`max_price` filters are
`long`/`BIGINT`; price-unit display + `session_count` totals are not recomputed here. No float path,
anywhere.
- **Rating-sort consistency.** The copied `average_rating`/`total_reviews`/`total_completed_bookings` must
track the source recompute (every review status transition, booking completion/dispute reversal, nightly
reconciliation). A hidden 1★ review must lower the rating **in the index**, not leave it inflated — which
is why the rating-recompute path (b3/b14) calls `ReindexNurseCommand`.
- **Seam discipline.** Controllers/handlers depend on **`INurseSearch`**, never on raw SQL or an Elastic
client directly, so the MVP→Elastic swap is config-only. The SQL impl is real and production-grade, not a
throwaway mock.
- **Availability is soft.** Availability slots/exceptions never hard-filter search results at MVP; the nurse
still accepts/rejects each request.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] `nurse_search_index` exists via one migration with its `IEntityTypeConfiguration<T>`, the covering
search index, the **`UNIQUE(variant_id, city_id, district_id)` filtered** upsert guard (NULL district
participating), the `nurse_id` secondary index, and soft-delete/audit/`updated_at` wiring.
- [ ] The §3.2 maintenance commands (`ReindexVariantCommand`, `ReindexNurseCommand`,
`FanOutServiceAreaCommand`, `RemoveServiceAreaRowsCommand`, `RebuildSearchIndexCommand`) are
implemented and **wired into the b3/b4/b5/b6 source handlers** so every relevant write maintains the
index **in the same transaction**. `is_searchable` is recomputed correctly per §5 on each.
- [ ] `SearchNursesQuery` + `GET api/v1/search/nurses` implemented behind **`INurseSearch`** (`SqlNurseSearch`
impl), reading **only** `is_searchable=1` rows, with correct NULL-district geography, the same-gender
filter, price range, rating sort, projected + paginated reads, and the FluentValidation validator.
- [ ] **`INurseSearch`** (+ the `ISearchIndexWriter` event shape) introduced as Application interfaces with
Infrastructure impls, **DI-registered via a `ServiceConfiguration/` extension** (config-selected; no
`if (mock)` in handlers).
- [ ] Handler/unit tests (NSubstitute): the `is_searchable` predicate (verified+accepting+not-suspended+
active true; each missing condition false), the geography NULL-district matching, the gender/price
filters, the rating sort, the fan-out on area add/remove, and the **`is_verified` flip → index
`is_searchable` update**; ≥1 `WebApplicationFactory` integration test for `/search/nurses` (happy path,
validation 400). The convergence test: incremental maintenance == full rebuild for a seeded fixture.
`dotnet build Baya.sln` zero new warnings; `dotnet test Baya.sln` green.
- [ ] The `Baya.Application/Features/Search/**` area + the `INurseSearch` seam are reflected in the **Project
map** in `server/CLAUDE.md`.
- [ ] The contract `dev/contracts/domains/search.md` is written and the `swagger.json` snapshot republished.
## 7. How to test (what a human can verify after this phase)
Seed (or reuse from prior phases) a small fixture: a province/city with ≥2 districts; **Nurse A** = verified
(`is_verified=1`, `nurse_verifications.status=approved`), `is_accepting_bookings=1`, female, with one
**active** elder-care variant priced in IRR and a service area = (city, district 3); **Nurse B** = **not
verified** (or paused) with an otherwise identical variant + area; **Nurse C** = verified, accepting, male,
active variant, service area = **whole city** (`district_id=NULL`). Give the nurses different
`average_rating`s.
1. **Verified+accepting+active appears**`GET api/v1/search/nurses?service_category_id=…&city_id=…`
**Nurse A** and **Nurse C** appear; **Nurse B does not** (unverified/paused never surfaces).
2. **Whole-city vs district geography** — search with `district_id=` district 3 → Nurse A (district 3) **and**
Nurse C (whole-city NULL row) both match; search the same city with **no district** → both still match;
search a *different* district in that city → only Nurse C (the whole-city row) matches.
3. **Same-gender filter** — add `nurse_gender=female` → only Nurse A; `nurse_gender=male` → only Nurse C.
4. **Price range** — set `min_price`/`max_price` straddling the variants → only the in-range variant(s) appear
(IRR `BIGINT`, exact).
5. **Rating sort** — give Nurse A a higher `average_rating` than Nurse C → results come back A-before-C;
paging is deterministic.
6. **Flip verification updates searchability** — flip **Nurse A** to suspended/`is_verified=0` (via the b6
path) → re-run the search → **Nurse A disappears** (its rows' `is_searchable` went 0 in the same
transaction). Flip back → it reappears.
7. **Service-area fan-out** — add a second service area to Nurse C → new index rows appear and Nurse C now
matches that area too; remove the area → those rows (and only those) drop out.
8. **Variant deactivate** — deactivate Nurse A's variant → it stops appearing (rows `is_searchable=0`),
without deleting the index rows.
9. **Rebuild convergence**`POST api/v1/admin_search/rebuild_index` → the index is identical to its
incrementally-maintained state (counts and `is_searchable` flags match); no duplicate (variant×area) rows.
## 8. Hand off & document (close the phase)
- **Docs to update:** the **Project map** in [`server/CLAUDE.md`](../../../server/CLAUDE.md) (add the
`Features/Search/**` area, the `nurse_search_index` projection, and the `INurseSearch` seam + where it's
registered). If you discover/confirm a rule the product docs don't capture (e.g. the exact city↔district
NULL-matching semantics in the query, or the incremental-vs-rebuild convergence guarantee), record it in
[`../../../product/business/04-search-and-matching.md`](../../../product/business/04-search-and-matching.md)
or [`../../../product/data-model/03-services-and-pricing.md`](../../../product/data-model/03-services-and-pricing.md)
— don't invent rules.
- **Contract to write:** **`dev/contracts/domains/search.md`** (per
[`../../contracts/domains/_TEMPLATE.md`](../../contracts/domains/_TEMPLATE.md)) — the public
`GET api/v1/search/nurses` endpoint (filters: `service_category_id`, `city_id`, optional `district_id`,
optional `nurse_gender`, optional `min_price`/`max_price`, pagination; rating sort; the
**NULL-district = whole-city** matching rule documented explicitly), the `NurseSearchResultDto` shape
(IRR `BIGINT` `price` + `price_unit` enum, `nurse_gender`, rating fields), the admin
`POST api/v1/admin_search/rebuild_index`, auth/rate-limit notes, and that **only `is_searchable=1` rows are
ever returned**. Republish the `swagger.json` snapshot per
[`../../contracts/openapi/README.md`](../../contracts/openapi/README.md). This is what **f6-b7** consumes.
- **Handoff & report:** write `dev/shared-working-context/backend/handoff/after-backend-phase-7.md` (search
is live, what **f6** can now build — search + filters (C1), results (C2), nurse profile (C3) — which
endpoint/contract is live, that the backend is the SQL index with `INurseSearch` allowing a later Elastic
swap, and that booking-side `required_caregiver_gender` capture lands in b8), append to `backend/STATUS.md`,
write `dev/shared-working-context/reports/backend-phase-7-report.md` (what was built, **what is now
testable and exactly how** per §7, what is deferred + how to make it real — Elastic + feeder, contracts
produced, follow-ups: the booking-side gender capture, the optional result cache, the Elastic backend),
and update `dev/shared-working-context/reports/mocks-registry.md` (the `INurseSearch` row → 🟢 SQL real,
Elastic 🟡; the `ISearchIndexWriter` outbox row → 🟡).
- **Memory:** save a `project` memory note for the non-obvious decisions this phase fixes — the
`is_searchable` four-condition predicate, the **NULL-district = whole-city** matching (both directions),
the (variant × area) fan-out cardinality + the `UNIQUE(variant_id, city_id, district_id)` upsert guard,
the "maintain-in-the-source-transaction, projection owned by the source's code path" rule, the
incremental↔rebuild convergence requirement, and the `INurseSearch` (SQL-now/Elastic-later) seam — with a
one-line pointer in `MEMORY.md`.
+480
View File
@@ -0,0 +1,480 @@
# Backend Phase 8 — Booking requests & lifecycle (pre-payment intent)
> **Mission:** build the **pre-payment intent** layer of the engagement lifecycle — the money-free
> `booking_requests` table and its full state machine (request → accept → pay-window → expire/reject).
> A customer requests a specific nurse, patient, service variant, address, date, and **required caregiver
> gender**; the nurse sees only the limited unencrypted `customer_notes` (never the full clinical
> instructions) and accepts or rejects before a config-driven **response deadline**; on accept a
> config-driven **30-minute payment window** opens. **No `bookings` row and no money exist yet** — that
> conversion happens in b9/b10. This phase owns the two-stage clinical-disclosure boundary's *first*
> stage, the tenancy invariant, the same-gender filter, and the deadlines that the whole booking flow
> hinges on.
>
> **Track:** backend · **Depends on:** [backend-phase-3](backend-phase-3.md) (`customer_profiles`, `nurse_profiles`, `patients`, tenancy via `ICurrentUser`), [backend-phase-4](backend-phase-4.md) (`customer_addresses`), [backend-phase-5](backend-phase-5.md) (`nurse_service_variants` + the `IVariantSnapshotSerializer`), [backend-phase-1](backend-phase-1.md) (typed cached config accessor `IPlatformConfig`, the `IJobScheduler`/`BackgroundService` pattern, `notifications`), [backend-phase-7](backend-phase-7.md) (search & matching — the gender/match data customers discover nurses through) · **Unlocks:** bookings · sessions · care · EVV ([backend-phase-9](backend-phase-9.md)), and the booking-request UI ([frontend-phase-7-b8](../frontend/frontend-phase-7-b8.md))
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
This is the **booking-request** phase — the first half of the request→accept→pay→confirm engagement
model. The product deliberately splits the lifecycle into **two tables** so each keeps clean invariants:
a **money-free request phase** (`booking_requests`, this phase) and a **payment-backed booking phase**
(`bookings`, [backend-phase-9](backend-phase-9.md)). A `booking_requests` row can be rejected, time out,
or have its payment window lapse **without a booking ever existing** — merging the two would mean a swamp
of nullable money fields and tangled status logic. This phase ends in a complete, testable request
state machine; b9 picks it up at "payment captured" and converts an `accepted_awaiting_payment` request
into a confirmed `bookings` row.
Everything this phase needs already exists. A customer (with a `customer_profiles` row) has `patients`
and `customer_addresses`; a nurse (with a `nurse_profiles` row and a `gender`) has priced, bookable
`nurse_service_variants`; config exposes the deadline durations; search lets the customer find the nurse
in the first place. This phase wires those into a request a nurse can accept or reject.
**What already exists (do not rebuild) — confirmed from the prior phases:**
- **Customers, nurses, patients & tenancy** — [backend-phase-3](backend-phase-3.md) built
`customer_profiles`, `nurse_profiles` (carrying `is_verified`, `is_accepting_bookings`, and the
`average_rating`/`total_reviews` aggregates), and `patients` (the care recipient, separate from the
payer, carrying a load-bearing `gender`). **Tenancy is resolved off `ICurrentUser.UserId`
`customer_profiles`/`nurse_profiles`** — a patient belongs to exactly one `customer_id`; b3 started the
ownership scoping and **b8 enforces the full booking-request tenancy invariant** (§5). Read those
entities; do not re-model them.
- **Addresses** — [backend-phase-4](backend-phase-4.md) built `customer_addresses` (encrypted address
line + `decimal(9,6)` coordinates, `customer_id` FK → `customer_profiles`, filtered
`UNIQUE(customer_id) WHERE is_primary=1`). The request points at one via `customer_address_id`. Do not
geocode here — b4 already did; b9/EVV consumes the coordinates.
- **Service variants** — [backend-phase-5](backend-phase-5.md) built `nurse_service_variants` (the
**atomic bookable unit**: a nurse + category + chosen option-set at a `price` (IRR `BIGINT`) +
`price_unit`), and shipped **`IVariantSnapshotSerializer`** (a pure serializer for the canonical
`variant_snapshot_json`). The request FKs a `variant_id`; **a variant belongs to exactly one nurse**,
which the tenancy invariant verifies. **Do not compute any price/total here** — no money lives on a
request; the serializer/snapshot/total are b9's concern.
- **Config, the job runner & notifications** — [backend-phase-1](backend-phase-1.md) built
`platform_configs` + the typed cached accessor **`IPlatformConfig`** (`GetConfig<T>(key)`), seeded the
deadline keys **`nurse_response_deadline_hours`** and **`booking_payment_deadline_minutes`** (= **30**),
introduced the in-process **`IJobScheduler`/`BackgroundService`** interval-runner pattern (used for
`PurgeOldReadNotifications`), and shipped the real in-app **`INotificationDispatcher`** that writes a
`notifications` row. **Reuse all three** — read deadlines through `IPlatformConfig` (never hardcode),
schedule the expiry sweep through the same job pattern, and emit request/accept/expiry notifications
through `INotificationDispatcher`.
- **Search & matching** — [backend-phase-7](backend-phase-7.md) built the `nurse_search_index` and the
`INurseSearch` query (filterable by category/city/district/**gender**/price). The customer arrives at
this phase *from* a search result, having already filtered on `required_caregiver_gender` against nurse
gender; this phase **re-validates** that same-gender match server-side at request time (search is a
discovery aid, not the authority).
- **Cross-cutting plumbing** — [backend-phase-0](backend-phase-0.md): the REST surface (`BaseController`,
snake_case routing, rate limiting), the CQRS pipeline (`ISender`/`ICommand`/`IQuery`,
`ValidateCommandBehavior`, `OperationResult<T>`), `IDateTimeProvider` (use it — **never `DateTime.Now`**
— deadlines and expiry are time-sensitive and must be testable), `ICurrentUser`, the audit-field
interceptor, and `ICacheService`. **Reuse these; introduce no new seam.**
> **`bookings`, `booking_sessions`, `booking_care_instructions`, the three-amount money split, the
> conversion command, and `dispute_window_ends_at`** are owned by **[backend-phase-9](backend-phase-9.md)**
> (with payment capture from [backend-phase-10](backend-phase-10.md)). This phase **ends at
> `accepted_awaiting_payment`**; it must **not** create a booking, post a ledger entry, compute a price,
> or persist `variant_snapshot_json`/`address_snapshot_json`. **(DEFERRED → b9/b10.)** The request's
> `converted` terminal status is *set by b9* when it consumes the accepted request — this phase models the
> status value and the 1:1 link but does not perform the conversion.
>
> **`cancellation_policies`, `CancelBooking`, refunds, and `recurring_booking_schedules`** are also b9+
> / DEFERRED — do not build them here. A customer cancelling a *request* (before any booking) is in scope
> as the `cancelled_by_customer` transition (§3.3); a *booking* cancellation is not.
## 2. Required reading (do this first)
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md).
- **Product — business rules (source of truth):**
[`product/business/05-booking-and-scheduling.md`](../../../product/business/05-booking-and-scheduling.md)
— the **request → accept → pay → confirm lifecycle**, the two-table phase split (*no money on a
request*), the **response deadline** computed-and-frozen-from-config, the **30-minute payment window**,
the request statuses, and (b) the Iran-specific note that the platform **deliberately keeps the nurse's
per-request accept/reject autonomy** (availability is soft guidance, never a hard auto-accept). Read
(c) MVP vs DEFERRED so you don't pull booking/session/cancellation scope forward.
- **Product — data model (source of truth):**
[`product/data-model/05-booking-and-scheduling.md`](../../../product/data-model/05-booking-and-scheduling.md)
— the **canonical `booking_requests` schema**: the FK set + the **tenancy invariant** (patient & address
belong to `customer_id`; variant belongs to `nurse_id`), `required_caregiver_gender`
(`male`/`female`/`any`), `requested_date`/`requested_time_start`/`requested_time_end`,
**unencrypted request-stage `customer_notes`**, the exact `status` set, `nurse_response_deadline_at`
(frozen from config), `payment_deadline_at` (set on accept), and `nurse_rejection_reason`. Note the
**1:1 → `bookings` on conversion** relation (which b9 owns).
- **Type, money & gender rules on the wire:**
[`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md) —
**gender is load-bearing** (`required_caregiver_gender` = `male`/`female`/`any`, matched against nurse
gender; never default or drop it silently); timestamps are **UTC ISO-8601**; enums cross the wire as
stable string codes; ids are `BIGINT`. (There is **no money** on a request — but read this so the
request's wire shape is consistent with the rest of the contract.)
- **Contract conventions:**
[`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
(envelope, snake_case routes, status codes — `400` validation/business, `403`/`404` tenancy, `409`
state-machine conflict — mandatory list pagination, locale handling).
- **Code to mirror (existing patterns):** a b3/b4/b5 feature folder under
`Baya.Application/Features/<Area>/{Commands|Queries}/<Name>/` (request `record` + `internal sealed`
handler + `OperationResult`, validator picked up by `ValidateCommandBehavior`); an
`IEntityTypeConfiguration<T>` under `Persistence/Configuration/<Area>Config/`; a `sealed` controller
under `Baya.Web.Api/Controllers/V1/`; b1's **`IPlatformConfig.GetConfig<T>`** usage and its
**`IJobScheduler`/`BackgroundService`** retention job (mirror it for the expiry sweep); how b3 resolves
tenancy off `ICurrentUser`; how reads use `AsNoTracking()` + `.Select()` projection + pagination.
- **Prior handoffs:** `dev/shared-working-context/backend/handoff/after-backend-phase-5.md` (the variant
shape + `IVariantSnapshotSerializer`), `.../after-backend-phase-4.md` (the `customer_addresses` shape),
`.../after-backend-phase-3.md` (profiles/patients + the tenancy + role policies), and
`.../after-backend-phase-1.md` (the config accessor keys + the job-runner pattern + notifications).
## 3. Scope — build this
A vertical slice per capability: entity + EF config + **one additive migration** → command/query
handler(s) → controller endpoint → contract. Everything is `async` with `CancellationToken` threaded
through; reads are `AsNoTracking()` + `.Select()` projection + pagination; writes go through
`IUnitOfWork` with a single `CommitAsync`. **There is no money and no `bookings` row anywhere in this
phase.**
### 3.1 Entity, config & migration
Add **`booking_requests`** as **one additive EF Core migration** on top of the existing baseline, with one
`IEntityTypeConfiguration<BookingRequest>` in `Persistence/Configuration/BookingConfig/`. The entity lives
in the Booking domain area (`Baya.Domain/Entities/Booking/`); ids are `BIGINT`.
- **`booking_requests`** — the pre-payment intent. **No money columns, ever.**
- **FKs:** `id` (BIGINT PK), `customer_id` (BIGINT FK → `customer_profiles`), `nurse_id` (BIGINT FK →
`nurse_profiles`), `patient_id` (BIGINT FK → `patients`), `variant_id` (BIGINT FK →
`nurse_service_variants`), `customer_address_id` (BIGINT FK → `customer_addresses`).
- `required_caregiver_gender` (NVARCHAR(10), **nullable**) — closed code set `male` | `female` | `any`.
Same-gender care is decisive for bodily care; this is a **first-class filter**, matched against the
nurse's `gender` at request time (§5), not a soft preference.
- `requested_date` (DATE), `requested_time_start` (TIME), `requested_time_end` (TIME) — for multi-day
engagements these are the **engagement start**; per-visit scheduling is b9's `booking_sessions`.
- `customer_notes` (NVARCHAR(1000), **nullable, UNENCRYPTED**) — the **only** clinical context the nurse
sees pre-accept (Principle 6 / two-stage disclosure, stage 1). **Do not** route this through
`IFieldEncryptor`; it is deliberately limited and plaintext. The full encrypted care instructions are
b9's `booking_care_instructions`.
- `status` (NVARCHAR(50)) — closed code set:
`pending_nurse_response``accepted_awaiting_payment``converted` / `rejected_by_nurse` /
`expired_no_response` / `payment_deadline_expired` / `cancelled_by_customer`. Guarded by a forward-only
transition check (§3.4).
- `nurse_response_deadline_at` (DATETIME2(7), **not null**) — **computed once from config at creation
and frozen** (`now + nurse_response_deadline_hours`); immune to later config changes.
- `payment_deadline_at` (DATETIME2(7), **nullable**) — null until accept; set to
`now + booking_payment_deadline_minutes` (= 30) **on accept**, then frozen.
- `nurse_rejection_reason` (NVARCHAR(500), nullable) — set on reject.
- Audit fields (`CreatedAt/ModifiedAt/CreatedById/ModifiedById`, stamped by the b0 interceptor) +
soft-delete (`deleted_at`) with the `!IsDeleted` global query filter.
- **Indexes:** `(nurse_id, status)` for the **nurse inbox** ordered list; `(customer_id, status)` for
the **customer inbox**; `(status, nurse_response_deadline_at)` and
`(status, payment_deadline_at)` so the **expiry sweep** (§3.4) selects stale rows by a covering index
instead of scanning. Do **not** add a `variant_snapshot_json` / `address_snapshot_json` column here —
those land on `bookings` in b9.
> **Do not add `bookings`, `booking_sessions`, `booking_care_instructions`, `visit_verifications`, or
> `cancellation_policies` in this migration.** They are b9's. This migration adds exactly one table.
### 3.2 Customer-side — create & cancel a request
Feature folder `Baya.Application/Features/Booking/` (Commands/Queries sub-folders per the surrounding
convention). **Customer-owner-only** for these writes; tenancy resolved via `ICurrentUser.UserId`
`customer_profiles`.
- **`CreateBookingRequestCommand`** (`Commands/CreateBookingRequest/`) — the customer requests a nurse.
Input: `nurse_id`, `variant_id`, `patient_id`, `customer_address_id`, `requested_date`,
`requested_time_start`, `requested_time_end`, `required_caregiver_gender`, optional `customer_notes`.
The handler, in one transaction:
1. **Resolve the caller's `customer_id`** from `ICurrentUser``customer_profiles` (never trust a
`customer_id` from the body).
2. **Tenancy invariant (§5):** load the `patients` row and assert `patient.customer_id == customer_id`;
load the `customer_addresses` row and assert `address.customer_id == customer_id`; load the
`nurse_service_variants` row and assert `variant.nurse_id == nurse_id`. Any mismatch ⇒ a clean
`NotFoundResult`/`FailureResult` (never a raw 500, never leaking another customer's data).
3. **Bookability checks:** the variant is active (`is_active`), the nurse `is_verified` and
`is_accepting_bookings`. A request against an inactive variant or an unbookable nurse fails cleanly.
4. **Same-gender match (§5):** if `required_caregiver_gender` is `male`/`female`, assert the nurse's
`gender` equals it; `any` matches either. A mismatch is a validation failure naming the conflict.
5. **Compute & freeze `nurse_response_deadline_at`** = `IDateTimeProvider.UtcNow +
IPlatformConfig.GetConfig<int>("nurse_response_deadline_hours")`. **Read from config — never
hardcode.** Store the absolute timestamp on the row so a later config change cannot move it.
6. Insert the `booking_requests` row with `status = pending_nurse_response`, `payment_deadline_at =
null`; `CommitAsync` once.
7. **Notify the nurse** of the new request via `INotificationDispatcher` (a `booking_request_received`
notification type, `data_json` carrying the request id + patient display name + requested date — no
PII beyond what stage-1 disclosure allows).
- FluentValidation: all FKs present/positive; `requested_time_end > requested_time_start`;
`requested_date` not in the past; `required_caregiver_gender` in the closed set when supplied;
`customer_notes` ≤ 1000 chars.
- **`CancelBookingRequestCommand`** (`Commands/CancelBookingRequest/`) — the customer withdraws a request
that is still `pending_nurse_response` **or** `accepted_awaiting_payment` (before they pay).
Owner-tenancy; transition to `cancelled_by_customer` through the guard (§3.4). A request already
`converted`/`rejected_by_nurse`/`expired_*` cannot be cancelled ⇒ `409` conflict. (This is a *request*
cancellation only — a *booking* cancellation with refund tiers is b9+/DEFERRED.)
### 3.3 Nurse-side — accept & reject
**Nurse-owner-only**; tenancy resolved via `ICurrentUser.UserId``nurse_profiles`; the request's
`nurse_id` must equal the caller's nurse id (else `403`/`NotFound`).
- **`AcceptBookingRequestCommand`** (`Commands/AcceptBookingRequest/`) — the assigned nurse accepts a
`pending_nurse_response` request. The handler:
1. Load the request scoped to the caller's `nurse_id`; assert `status == pending_nurse_response` (else
`409`). Assert `nurse_response_deadline_at` has **not** already passed (`IDateTimeProvider.UtcNow`);
an accept after the deadline is rejected with a clear message (the sweep may not have run yet — the
command must self-guard, not rely on the job).
2. **Set & freeze `payment_deadline_at`** = `IDateTimeProvider.UtcNow +
IPlatformConfig.GetConfig<int>("booking_payment_deadline_minutes")` (= 30). **From config, never
hardcode 30.**
3. Transition `status → accepted_awaiting_payment` through the guard; `CommitAsync`.
4. **Notify the customer** (`booking_request_accepted`, `data_json` with the request id + the
`payment_deadline_at` so the client can show the 30-minute countdown).
- **Two-stage disclosure (§5):** the accept handler — and the nurse inbox/detail queries — expose
**only `customer_notes`**, never any encrypted care instructions (those don't exist until b9 and are
gated to post-confirmation). There is nothing encrypted to leak here *because there is nothing
encrypted on a request* — but the contract and queries must make the stage-1-only boundary explicit so
b9 inherits it correctly.
- **`RejectBookingRequestCommand`** (`Commands/RejectBookingRequest/`) — the assigned nurse declines a
`pending_nurse_response` request with a required `nurse_rejection_reason`. Transition →
`rejected_by_nurse`; notify the customer (`booking_request_rejected`). Reject is only valid from
`pending_nurse_response` (else `409`). FluentValidation: reason non-empty, ≤ 500 chars.
### 3.4 Status guard & the expiry sweep (this phase owns the scheduled job)
- **Forward-only transition guard.** Add an allowed-transition table/helper for `booking_requests.status`
and route **every** write through it; an illegal transition returns a `409` `OperationResult` conflict,
never a silent overwrite. Allowed edges:
- `pending_nurse_response``accepted_awaiting_payment` | `rejected_by_nurse` | `expired_no_response`
| `cancelled_by_customer`
- `accepted_awaiting_payment``converted` (b9 only) | `payment_deadline_expired` |
`cancelled_by_customer`
- all of `converted` / `rejected_by_nurse` / `expired_no_response` / `payment_deadline_expired` /
`cancelled_by_customer` are **terminal** (no outgoing edges).
- **`ExpireBookingRequestsCommand`** (`Commands/ExpireBookingRequests/`) + a hosted **`BackgroundService`**
registered through the b1 `IJobScheduler` pattern, running on a short interval (e.g. every minute —
config-driven if b1 exposes an interval key, else a sensible constant documented in the report). On each
tick, in a single bounded, paginated pass (do **not** load the whole table):
- select `pending_nurse_response` rows where `nurse_response_deadline_at <= UtcNow` → transition →
`expired_no_response`; notify the customer (`booking_request_expired_no_response`).
- select `accepted_awaiting_payment` rows where `payment_deadline_at <= UtcNow` → transition →
`payment_deadline_expired`; notify the customer (`booking_request_payment_window_expired`).
- The sweep uses the covering indexes from §3.1, batches its updates, threads `CancellationToken`, and is
**idempotent/re-entrant** (a row already moved by a concurrent accept/reject is simply skipped — the
`status` predicate in the `WHERE` is the guard; never assume a row is still pending just because the
previous tick saw it). Expiry is computed against `IDateTimeProvider.UtcNow` so tests can drive it
deterministically.
- The command is also exposed as an **admin/manual trigger** endpoint (§3.6) so a human/test can fire the
sweep on demand without waiting for the interval.
### 3.5 Queries — customer inbox, nurse inbox, single request
- **`ListBookingRequestsQuery`** (`Queries/ListBookingRequests/`) — paginated, **role-aware**: a customer
sees their own requests (scoped to `customer_id`); a nurse sees requests addressed to them (scoped to
`nurse_id`). Optional `status` filter. `AsNoTracking()` + `.Select()` projection returning: request id,
status, the **counterparty** (nurse display name + rating for the customer view; patient display name +
requested date for the nurse view), `requested_date`/times, `required_caregiver_gender`,
`nurse_response_deadline_at`, `payment_deadline_at`, and — for the **nurse view**
`customer_notes` (stage-1 only). Order: actionable first (e.g. `pending_nurse_response` /
`accepted_awaiting_payment` before terminal states), then by deadline. **Never** project encrypted/care
fields (there are none, and there must never be any added to this query).
- **`GetBookingRequestQuery`** (`Queries/GetBookingRequest/`) — a single request, visible to its
**customer or its nurse** (admin too). Returns the full request projection: the resolved variant label
(via the b5 variant projection — read the canonical offering, do not duplicate price math), patient +
address summary (the **customer** sees their own full address; the **nurse** sees only what stage-1
disclosure allows — a coarse location/the request fields, **not** the encrypted full address line),
both deadlines, status, and `customer_notes`. A caller who is neither the request's customer nor its
nurse ⇒ `403`/`NotFound`.
### 3.6 REST endpoints
Controllers under `Baya.Web.Api/Controllers/V1/` (`sealed`, `BaseController`, inject `ISender`,
`[controller]`/`[action]` snake_case tokens, `base.OperationResult(...)`, narrowest authorize policy,
lists paginated). Routes shown logically; the snake_case transformer produces the real segments.
| Verb & route | Maps to | Auth |
| --- | --- | --- |
| `POST /v1/booking_requests` | `CreateBookingRequestCommand` | customer (owner) |
| `POST /v1/booking_requests/{id}/cancel` | `CancelBookingRequestCommand` | customer (owner) |
| `POST /v1/booking_requests/{id}/accept` | `AcceptBookingRequestCommand` | nurse (assigned) |
| `POST /v1/booking_requests/{id}/reject` | `RejectBookingRequestCommand` | nurse (assigned) |
| `GET /v1/booking_requests` | `ListBookingRequestsQuery` | customer or nurse (role-scoped) |
| `GET /v1/booking_requests/{id}` | `GetBookingRequestQuery` | customer / nurse (party) / admin |
| `POST /v1/admin/booking_requests/expire` | `ExpireBookingRequestsCommand` (manual trigger) | admin |
### 3.7 Out of scope (DEFERRED — do not build)
- **`bookings`, the three-amount money split (`gross_price_irr`/`balinyaar_commission_irr`/
`nurse_payout_amount`), `psp_fee_amount`, the conversion command, `booking_sessions`,
`booking_care_instructions`, `visit_verifications`, `dispute_window_ends_at`,
`variant_snapshot_json`/`address_snapshot_json`** — **(DEFERRED → [backend-phase-9](backend-phase-9.md)
+ [backend-phase-10](backend-phase-10.md)).** This phase stops at `accepted_awaiting_payment`; b9
consumes the accepted request, captures payment (b10), and sets the request to `converted`.
- **`cancellation_policies`, `CancelBooking`/`CancelSession`, refund tiers, `recurring_booking_schedules`,
`nurse_availability_slots`/`_exceptions` hard-blocking** — **(DEFERRED)**; availability stays *soft
guidance* (search-only). The nurse always individually accepts/rejects — never auto-accept from
availability.
- **SMS/push notification channels**`INotificationDispatcher` writes **in-app only** (the channel
abstraction exists from b0/b1; SMS/push are DEFERRED). Emit the in-app notification; do not add a real
SMS path.
## 4. Mocks & seams in this phase
**None introduced.** This phase owns no third-party integration — booking-request data is fully Balinyaar's.
It **reuses** existing seams; it must **not** redefine them:
| Reused seam | From | Used for |
| --- | --- | --- |
| `IPlatformConfig` | [b1](backend-phase-1.md) | reading `nurse_response_deadline_hours` & `booking_payment_deadline_minutes` (cached) |
| `INotificationDispatcher` | [b1](backend-phase-1.md) (real in-app) | request-received / accepted / rejected / expired in-app notifications |
| `IJobScheduler` / `BackgroundService` | [b1](backend-phase-1.md) | the recurring expiry sweep |
| `IDateTimeProvider` | [b0](backend-phase-0.md) | deadline computation + expiry, testably |
| `ICurrentUser` | [b0](backend-phase-0.md) | tenancy resolution (customer/nurse) |
Because this phase **mocks nothing**, there is **no `mocks-registry.md` row to add** — state that
explicitly in your report (§8) so the next agent doesn't go looking. The expiry job is an *internal*
hosted service, not an external seam; it does not go in the mock registry.
## 5. Critical rules you must not get wrong
- **NO money exists on a `booking_requests` row — and no `bookings` row exists yet.** The request phase is
deliberately money-free; a `bookings` row exists **only** when the nurse accepted **AND** payment was
captured (b9/b10). Never add a money column to `booking_requests`, never compute a price/total here,
never create a booking on accept alone. Accept only opens the payment window.
- **Two-stage clinical disclosure (Principle 6), stage 1.** Pre-accept (and pre-payment), the nurse sees
**only the unencrypted, limited `customer_notes`** — never any full clinical/care instructions. Those
are b9's encrypted `booking_care_instructions`, readable **only post-confirmation** by the assigned
nurse + admin. Do not add an encrypted clinical field to this table; do not surface anything beyond
`customer_notes` in the nurse inbox/detail queries. This boundary is the *entire reason* the request and
booking phases are split — preserve it exactly so b9 inherits a clean seam.
- **Tenancy invariant — enforced before the request is created.** The `patient` **and** the
`customer_address` must belong to the caller's `customer_id`; the `variant` must belong to the
requested `nurse_id`. Resolve `customer_id`/`nurse_id` from `ICurrentUser`, never from the request body.
A cross-customer patient/address or a variant that isn't the nurse's is a clean failure, never a
success and never a leak of the other party's data.
- **`required_caregiver_gender` is a first-class same-gender filter, not a soft preference.** When it is
`male`/`female`, it **must** be matched against the nurse's `gender` at request time and rejected on
mismatch; `any` matches either. Same-gender bodily care is decisive in the Iranian context. Never
default it silently or treat it as advisory.
- **Deadlines come from config and are frozen on the row — never hardcoded, never recomputed.** Compute
`nurse_response_deadline_at` from `nurse_response_deadline_hours` at **create** time and
`payment_deadline_at` from `booking_payment_deadline_minutes` (= 30) at **accept** time, both via
`IPlatformConfig` + `IDateTimeProvider`, and **store the absolute timestamp**. A later config change must
**not** move an existing request's deadlines. Do not read "30 minutes" as a literal anywhere.
- **The nurse always individually accepts or rejects.** Availability slots are soft guidance only (b5-area,
DEFERRED) — never auto-accept a request from availability. This deliberate per-request autonomy also
underpins the worker-misclassification posture; do not erode it.
- **Status transitions go through the forward-only guard.** Every accept/reject/cancel/expire/convert
edge is validated; illegal transitions return `409`, never a silent overwrite. Terminal states have no
outgoing edges. The expiry sweep's `WHERE status = …` predicate is the concurrency guard — a row moved
by a racing accept is simply skipped.
- **Time is injected, expiry is idempotent.** Use `IDateTimeProvider.UtcNow` everywhere (no `DateTime.Now`);
accept/reject **self-guard** against an already-passed deadline rather than trusting that the sweep ran;
the sweep is bounded, paginated, re-entrant, and safe to run concurrently with user actions.
- **The request → booking link is 1:1 and owned by b9.** Model the `converted` status and let b9 create
the `bookings` row that points back at the request; this phase never writes that link itself.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] `booking_requests` exists via **one additive migration** with the §3.1 columns, the closed `status`
set, the **unencrypted** `customer_notes`, `nurse_response_deadline_at` (not null) + nullable
`payment_deadline_at`, the FK set, the inbox/expiry indexes, soft-delete filter, and **no money
column**.
- [ ] `CreateBookingRequestCommand` enforces the **tenancy invariant** (patient + address ∈ `customer_id`,
variant ∈ `nurse_id`), the **same-gender** match, bookability (active variant, verified/accepting
nurse), and **freezes `nurse_response_deadline_at` from `nurse_response_deadline_hours`**; it
notifies the nurse.
- [ ] `AcceptBookingRequestCommand` sets `payment_deadline_at = now + booking_payment_deadline_minutes`
(**30, from config**), transitions to `accepted_awaiting_payment`, notifies the customer, and
self-guards against an expired response deadline. `RejectBookingRequestCommand` requires a reason and
transitions to `rejected_by_nurse`. `CancelBookingRequestCommand` handles `cancelled_by_customer`.
- [ ] The forward-only **status guard** rejects illegal transitions with `409`; `ExpireBookingRequestsCommand`
+ its `BackgroundService` transition `pending_nurse_response → expired_no_response` and
`accepted_awaiting_payment → payment_deadline_expired`, paginated, idempotent, time-injected, and is
also reachable via the admin manual-trigger endpoint.
- [ ] `ListBookingRequestsQuery` (role-scoped customer/nurse inbox, paginated, projected) and
`GetBookingRequestQuery` (party/admin only) are implemented; the **nurse inbox exposes only
`customer_notes`** and never any care/clinical field.
- [ ] Tests prove the §7 scenarios (incl. **cross-customer patient rejected**, **same-gender mismatch
rejected**, accept sets the 30-min window, the **expiry job transitions stale rows**); `dotnet build
Baya.sln` zero new warnings; `dotnet test Baya.sln` green including this phase's tests.
- [ ] The contract `dev/contracts/domains/booking-requests.md` is written, the `swagger.json` snapshot is
refreshed, and the `server/CLAUDE.md` *Project map* notes the new `Features/Booking` area, the
`BookingConfig` configuration folder, and the request-expiry `BackgroundService`.
## 7. How to test (what a human can verify after this phase)
Run the API (`dotnet run --project src/API/Baya.Web.Api/...`) against a reachable SQL Server; use Swagger
or curl, signing in as the relevant role. These expected results become the "what can be tested" section
of your report.
1. **Create a request (happy path).** As a customer, `POST /v1/booking_requests` with your own
`patient_id` + `customer_address_id`, a nurse's `variant_id` (that nurse `is_verified` &
`is_accepting_bookings`), a future `requested_date`, `required_caregiver_gender: any`, and a short
`customer_notes``200`, status `pending_nurse_response`, `nurse_response_deadline_at` set to
`now + nurse_response_deadline_hours`, `payment_deadline_at` null. The nurse receives an in-app
notification.
2. **Cross-customer patient is rejected.** Repeat the create but with a `patient_id` belonging to a
**different** customer → a clean tenancy failure (`403`/`404` `OperationResult`), **not** a 500 and
**no** request row created. Same for an `customer_address_id` you don't own and a `variant_id` that
isn't the requested nurse's.
3. **Same-gender mismatch is rejected.** Request `required_caregiver_gender: female` against a `male`
nurse → a validation failure naming the gender conflict; `any` against any nurse succeeds.
4. **Nurse accepts → 30-minute window.** As the assigned nurse, `POST /v1/booking_requests/{id}/accept`
`200`, status `accepted_awaiting_payment`, `payment_deadline_at = now + booking_payment_deadline_minutes`
(30 min). The customer gets a `booking_request_accepted` notification carrying the deadline. No
`bookings` row exists (there is no bookings table yet).
5. **Nurse rejects.** `POST /v1/booking_requests/{id}/reject` with a reason on a fresh pending request →
`200`, status `rejected_by_nurse`, `nurse_rejection_reason` stored, customer notified. Rejecting a
non-pending request → `409`.
6. **Nurse inbox shows only `customer_notes`.** As the nurse, `GET /v1/booking_requests?status=pending_nurse_response`
→ the request appears with `customer_notes` and the per-request countdown to `nurse_response_deadline_at`,
and **no** encrypted/clinical field of any kind.
7. **Customer cancels before paying.** On an `accepted_awaiting_payment` request, the customer
`POST /v1/booking_requests/{id}/cancel``200`, status `cancelled_by_customer`. Cancelling a
`converted`/`rejected`/`expired` request → `409`.
8. **The expiry job transitions stale requests.** Create a request, then (using the injected clock in a
test, or by waiting / a short test config) advance past the response deadline and
`POST /v1/admin/booking_requests/expire` (admin) → the pending request becomes `expired_no_response`;
an accepted request past `payment_deadline_at` becomes `payment_deadline_expired`. Both notify the
customer. Running the trigger again is a no-op (idempotent).
9. **Tenancy on read.** As a third party (neither the request's customer nor nurse),
`GET /v1/booking_requests/{id}``403`/`NotFound`, never the request's data.
## 8. Hand off & document (close the phase)
- **Docs to update (same change):** `server/CLAUDE.md` *Project map* — add the `Features/Booking`
(booking-requests) feature area, the new `booking_requests` table + the
`Persistence/Configuration/BookingConfig/` folder, and the request-expiry `BackgroundService` (note it
reuses the b1 `IJobScheduler` pattern). If you established the forward-only status-guard helper as a
reusable pattern (b9 will reuse it for the `bookings` machine), note it in `server/CONVENTIONS.md`. If
you discovered/decided any business rule not already in the product docs, reflect it in
[`product/business/05-booking-and-scheduling.md`](../../../product/business/05-booking-and-scheduling.md)
or [`product/data-model/05-booking-and-scheduling.md`](../../../product/data-model/05-booking-and-scheduling.md)
(no invented rules — record decisions, and regenerate the HTML view per `product/CLAUDE.md` if you
touched Markdown).
- **Contract to write:** publish **`dev/contracts/domains/booking-requests.md`** — the §3.6 routes;
request/response shapes for create/accept/reject/cancel and the two queries; the `status` enum
(`pending_nurse_response`/`accepted_awaiting_payment`/`converted`/`rejected_by_nurse`/
`expired_no_response`/`payment_deadline_expired`/`cancelled_by_customer`) and `required_caregiver_gender`
enum (`male`/`female`/`any`); the deadline timestamps (UTC ISO-8601); the **tenancy** and
**stage-1-disclosure** notes (nurse view exposes only `customer_notes`); the failure cases and status
codes (`400` validation/same-gender, `403`/`404` tenancy, `409` illegal transition / already-expired) —
per [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
and [`money-and-types.md`](../../contracts/conventions/money-and-types.md) (gender section), starting
from the [domain contract template](../../contracts/domains/_TEMPLATE.md). Refresh the `swagger.json`
snapshot per [`../../contracts/openapi/README.md`](../../contracts/openapi/README.md) so
[frontend-phase-7-b8](../frontend/frontend-phase-7-b8.md) can derive its types (it does not guess
shapes).
- **Handoff & report:** write `shared-working-context/backend/handoff/after-backend-phase-8.md` — the
request lifecycle is live end-to-end (create/accept/reject/cancel/expire); the deadlines are config-
driven and frozen; **what b9 must consume** (an `accepted_awaiting_payment` request → create the
`bookings` row, set the request to `converted` through the guard, and that the **full encrypted care
instructions are b9's stage-2** — this phase intentionally exposes only `customer_notes`); **what
f7-b8 can now build** (the request form C4, the awaiting-acceptance/status tracker C5 with the response
+ payment countdowns, and the nurse incoming-requests inbox with accept/reject). Append your phase
summary to `shared-working-context/backend/STATUS.md`, and write `reports/backend-phase-8-report.md`
(what was built, **what is now testable and exactly how** — the §7 steps, that **nothing is mocked**
here, the contract produced, and follow-ups for b9: conversion, sessions, two-stage stage-2 care
instructions). State explicitly in the report that this phase adds **no** `mocks-registry.md` row.
- **Memory:** save a `project`-type memory note for the non-obvious decisions here — the **two-table phase
split / no-money-on-a-request** rule, the **stage-1 (`customer_notes`-only) vs stage-2 (encrypted
care-instructions) disclosure boundary**, the **deadlines-frozen-from-config** rule (which config keys),
the **tenancy invariant** (patient+address ∈ customer, variant ∈ nurse), the **same-gender match at
request time**, and the **forward-only status guard + idempotent expiry sweep** — with a one-line
`MEMORY.md` pointer.
+452
View File
@@ -0,0 +1,452 @@
# Backend Phase 9 — Bookings, sessions, care instructions & EVV
> **Mission:** turn an accepted, paid request into a real **engagement**. On payment capture, convert a
> `booking_requests` row into a `bookings` row with the **three-amount money split**
> (`gross_price_irr = balinyaar_commission_irr + nurse_payout_amount`), freeze the service/address/policy
> as **snapshots**, and fan out **N `booking_sessions`** (always ≥ 1, even for a single visit) so every
> visit has its own schedule, its own **EVV** check-in/out, and its own payout accrual. Build the
> **two-stage clinical disclosure** boundary (`booking_care_instructions`, encrypted, readable only
> post-confirmation by the assigned nurse + admin), the **EVV** records (`visit_verifications` — GPS +
> timestamps, an *advisory* address match that flags review but never blocks), and the **dispute-window**
> gate that — and only that — makes a session payout-eligible. This phase is the spine the payments
> capture (b10), refunds (b11), payouts (b13), and reviews (b14) all hang off.
>
> **Track:** backend · **Depends on:** [b8](./backend-phase-8.md) (`booking_requests` lifecycle), [b1](./backend-phase-1.md) (`platform_configs`, `support_alerts`, `INotificationDispatcher`), [b0](./backend-phase-0.md) (`IFieldEncryptor`, `ICurrentUser`, audit interceptor, REST/`OperationResult`), [b4](./backend-phase-4.md) (`IGeocoder`, `customer_addresses` coordinates) · **Unlocks:** payments capture **b10**, reviews **b14**, payouts **b13**; frontend **f8-b9**
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
This is **backend phase b9**, the hinge between the **request** arc (b8) and the **money** arc (b10b13).
Balinyaar splits the engagement lifecycle into two tables on purpose: a money-free **request phase**
(`booking_requests`, built in b8) and a payment-backed **booking phase** (`bookings`, built **here**). A
`bookings` row exists **only** when the nurse accepted **and** payment was captured — never on accept
alone. This phase builds the booking, its sessions, the encrypted care instructions, the EVV proof of
service, and the dispute-window gate; the actual card capture that *triggers* the conversion lands in
**b10**, so this phase ships a **mock-confirm path** (a DI seam) to make conversion testable now.
The product framing: home nursing in Iran is dominantly **multi-visit / شبانه‌روزی live-in** care, so a
booking carries a `session_count` and owns **N `booking_sessions`**, each independently scheduled,
verified (EVV), and paid out per completed session — money releases per session, not as one whole-month
escrow ([`product/business/05-booking-and-scheduling.md`](../../../product/business/05-booking-and-scheduling.md)).
EVV ([`product/business/06-evv-and-service-delivery.md`](../../../product/business/06-evv-and-service-delivery.md))
is the authoritative GPS-and-timestamp proof that a visit happened, and is the gate that — together with a
closed dispute window — releases escrow. A single-visit booking still creates **exactly one** session so
the EVV/payout path is uniform.
**What already exists (do not rebuild) — built by prior phases:**
- **`booking_requests` + its lifecycle** — [b8](./backend-phase-8.md) built `booking_requests`
(`customer_id`, `nurse_id`, `patient_id`, `variant_id`, `customer_address_id`,
`required_caregiver_gender`, `requested_date`/`requested_time_start`/`requested_time_end`,
unencrypted request-stage `customer_notes`, frozen `nurse_response_deadline_at` + `payment_deadline_at`,
`nurse_rejection_reason`, and the `status` machine `pending_nurse_response → accepted_awaiting_payment →
converted | rejected_by_nurse | expired_no_response | payment_deadline_expired | cancelled_by_customer`),
the create/accept/reject commands, the expiry job, and the **same-gender + tenancy validation** at
request time. **This phase reads an `accepted_awaiting_payment` request and converts it; it does not
re-validate gender/tenancy from scratch — those were enforced at request creation and are frozen.** The
conversion flips the request to `converted`.
- **`platform_configs` typed cached accessor + `support_alerts` + notifications** — [b1](./backend-phase-1.md)
built the typed, cached config reader (read `dispute_window_hours` default `72`,
`evv_location_tolerance_meters`, and the no-show late threshold through it — **never hardcode**), the
`support_alerts` table + raise API (this phase raises `location_mismatch` and `no_show` alerts), and the
real in-app `notifications` write behind **`INotificationDispatcher`**.
- **`IGeocoder` + address coordinates** — [b4](./backend-phase-4.md) built `customer_addresses` (with
lat/lng) and the **`IGeocoder`** seam. This phase **reuses `IGeocoder`** for the EVV address-match
distance computation; it does not introduce a new geo seam.
- **`IFieldEncryptor`, `ICurrentUser` + audit interceptor, the REST surface** — [b0](./backend-phase-0.md)
built `IFieldEncryptor` (encrypts `address_snapshot_json` and the `booking_care_instructions` columns;
never logs plaintext), `ICurrentUser` + the audit-field SaveChanges interceptor, the rate limiter, the
`BaseController` + `OperationResult<T>` envelope, CQRS via **`martinothamar/Mediator`**, and
`IDateTimeProvider`.
- **`nurse_service_variants`, `patients`, `customer_addresses`** — the priced variant, the patient, and the
service address the request points at, built in catalog (b5) / identity (b3) / geo (b4). This phase reads
them only to **snapshot** them — it never mutates them.
**What this phase introduces:** the five booking-domain tables (`bookings`, `booking_sessions`,
`booking_care_instructions`, `visit_verifications`, `cancellation_policies`), the conversion / session /
EVV / dispute-window / cancellation capabilities, and **one new seam — `IPaymentCaptureSimulator`** (the
mock-confirm path that stands in for b10's real card capture so conversion is testable now). The actual
card gateway, ledger postings, and refund execution are **DEFERRED** to b10/b11 (pointers in §3.6).
## 2. Required reading (do this first)
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md) —
especially the *Performance, caching, money, idempotency* block (IRR `BIGINT`, the three-amount split,
encrypted PII columns through the field-encryptor seam, projected + paginated reads).
- [`product/business/05-booking-and-scheduling.md`](../../../product/business/05-booking-and-scheduling.md)
**the business rules**: the two-phase split (no money on a request; a booking implies captured
payment), single-visit *and* multi-session engagements, the booking status machine, snapshots, and MVP
vs DEFERRED (recurring schedules modeled-but-inactive).
- [`product/business/06-evv-and-service-delivery.md`](../../../product/business/06-evv-and-service-delivery.md)
**the EVV rules**: per-session GPS check-in/out, the *advisory* address-match tolerance
(`evv_location_tolerance_meters`) that flags review but never auto-cancels, no-show alerting, and that
**payout is gated on EVV completion + a closed dispute window** (never on `completed` alone).
- [`product/data-model/05-booking-and-scheduling.md`](../../../product/data-model/05-booking-and-scheduling.md)
**the canonical schema** for `bookings` (the three amounts + `platform_fee_rate` + `session_count` +
`dispute_window_ends_at` + snapshots + the guarded status), `booking_sessions`,
`booking_care_instructions`, `visit_verifications` (FK now on `booking_session_id`), and
`cancellation_policies`. **Mirror these field names and the CHECK exactly.**
- [`product/overview/platform-summary.md`](../../../product/overview/platform-summary.md) — the four ground
truths (no cash custody → escrow is a ledger state, the weekly-payout / hold-then-pay model that *requires*
EVV proof) and the **IRR-Rials-always** money rule.
- **Code to mirror:** b8's `Features/Bookings/**` (or `Features/BookingRequests/**`) command/query structure,
validators, and the `booking_requests` config; b4's `customer_addresses` config + `IGeocoder` usage; b1's
typed config accessor and `support_alerts` raise API + `INotificationDispatcher`; b0's `IFieldEncryptor`
usage on encrypted columns and the `BaseController`/`OperationResult` pattern.
- **Contract conventions:** [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
and [`money-and-types.md`](../../contracts/conventions/money-and-types.md) (IRR `BIGINT`, the envelope,
enum casing).
- **Prior handoffs:** `dev/shared-working-context/backend/handoff/after-backend-phase-8.md`, `…-4.md`,
`…-1.md`, `…-0.md`, and `reports/mocks-registry.md` (the seam rows you reuse — `IGeocoder`,
`IFieldEncryptor`, `INotificationDispatcher` — plus the one you add).
## 3. Scope — build this
All money is IRR `long` / `BIGINT`. Features live under
`Baya.Application/Features/Bookings/{Commands|Queries}/<Name>/`; entities in
`Baya.Domain/Entities/Bookings/`; one `IEntityTypeConfiguration<T>` per entity in
`Persistence/Configuration/BookingsConfig/`; one EF migration for the five tables. Encrypted columns
(`address_snapshot_json`, the `booking_care_instructions` clinical fields, EVV GPS detail) go through
`IFieldEncryptor` — never stored or logged in plaintext.
### 3.1 Entities + migration
**`bookings`** [CORE] — the confirmed engagement; source of truth for the service event + its money split.
- Fields: `id` (BIGINT PK), `booking_request_id` (BIGINT FK → `booking_requests`, **UNIQUE** — 1:1),
`customer_id`, `nurse_id`, `patient_id`, `variant_id`, `customer_address_id` (denormalized FKs for query
performance), `partner_center_id` (BIGINT FK → `partner_centers`, **nullable** — the licensed center /
merchant-of-record; `partner_centers` is DEFERRED to b15, so leave the FK nullable and unset for now),
`variant_snapshot_json` (NVARCHAR(MAX) — variant + option labels at booking time),
`address_snapshot_json` (NVARCHAR(MAX), **encrypted** — full address at booking time),
`gross_price_irr` (BIGINT — total charged the customer), `balinyaar_commission_irr` (BIGINT — platform's
cut), `platform_fee_rate` (DECIMAL(5,4) — **rate snapshot for audit**, frozen at conversion),
`nurse_payout_amount` (BIGINT — `= gross_price_irr balinyaar_commission_irr`, **derived, not
free-entered**), `psp_fee_amount` (BIGINT, **nullable** — gateway cost for true margin; the mock-confirm
path may set it, real capture sets it in b10), `session_count` (SMALLINT NOT NULL DEFAULT 1),
`scheduled_date` / `scheduled_time_start` / `scheduled_time_end` (engagement-level; per-visit lives in
`booking_sessions`), `status` (NVARCHAR(30) — the guarded machine below), `confirmed_at`,
`cancelled_at`, `cancellation_reason`, `cancelled_by`, `completed_at`,
`dispute_window_ends_at` (DATETIME2, **nullable** — set on completion = `completed_at +
config(dispute_window_hours, 72)`), audit + soft-delete fields.
- **CHECK (DB constraint, not handler-only):** `gross_price_irr = balinyaar_commission_irr +
nurse_payout_amount`; **all three ≥ 0**.
- **`payout_released` was CUT — do NOT add any boolean "paid" flag.** Paid-ness is derived later from a
`nurse_payout_booking_links` row + the ledger (b13).
- Relations: 1:1 ← `booking_requests`; 1:N → `booking_sessions`; 1:1 → `booking_care_instructions`;
referenced later by `payment_transactions`/`ledger_entries`/`reviews`/`invoices`/`refunds`/`nurse_clawbacks`/
`nurse_payout_booking_links` (those tables land in later phases — do not create them here).
**`booking_sessions`** [MVP] — one row per **visit**; always ≥ 1, even for a single-visit booking.
- Fields: `id` (BIGINT PK), `booking_id` (BIGINT FK → `bookings`), `session_index` (INT — 1-based ordinal),
`scheduled_date` / `scheduled_time_start` / `scheduled_time_end` (per-visit), `visit_payout_amount`
(BIGINT — this session's portion of `nurse_payout_amount`), `status` (NVARCHAR(20) — `scheduled` |
`in_progress` | `completed` | `missed` | `cancelled`), `payout_eligible_at` (DATETIME2, **nullable**
per-session dispute-window close, set on completion), `cancellation_event_id` (BIGINT, **nullable** — set
when this session is cancelled, references the cancellation snapshot recorded on the booking/session),
audit + soft-delete fields.
- **Invariant (handler-enforced):** `Σ(booking_sessions.visit_payout_amount) = bookings.nurse_payout_amount`
for the booking — the split must reconcile exactly (distribute the remainder of integer division onto the
last session so no Rial is lost or created). All `visit_payout_amount ≥ 0`.
- Relations: N:1 → `bookings`; 1:1 → `visit_verifications`.
**`booking_care_instructions`** [CORE] — encrypted clinical/logistical context; **post-confirmation +
assigned-nurse/admin only**.
- Fields: `id` (BIGINT PK), `booking_id` (BIGINT FK → `bookings`, **UNIQUE** — 1:1), and the encrypted
fields (all NVARCHAR(MAX) **enc**): `current_conditions`, `medications`, `allergies`,
`special_instructions`, `emergency_contact_name`, `emergency_contact_phone`, audit + soft-delete fields.
- **Why separate + encrypted:** keeps the financial/scheduling table clean and enforces the two-stage
disclosure boundary with stricter access control. **Never** project these fields into a list query or log
them; decrypt only in the gated `GetCareInstructionsQuery` (§3.2).
- Relations: 1:1 → `bookings`.
**`visit_verifications`** [CORE] — the EVV record; **required for payout**.
- Fields: `id` (BIGINT PK), `booking_session_id` (BIGINT FK → `booking_sessions`, **UNIQUE** — 1:1; the FK
is on the *session*, not the booking, so each visit is verified independently),
`check_in_at` (DATETIME2, nullable), `check_in_lat` / `check_in_lng` (decimal, nullable),
`check_out_at` (DATETIME2, nullable), `check_out_lat` / `check_out_lng` (decimal, nullable),
`check_in_address_match` (BIT/bool, **nullable***advisory*: did check-in fall within
`evv_location_tolerance_meters` of the booking address?), `check_in_distance_meters` (decimal, nullable —
the computed distance, for the admin review screen), `status` (NVARCHAR(20) — `pending` | `checked_in` |
`completed`), audit + soft-delete fields.
- **GPS detail is sensitive** — treat with the same access discipline as PII; only the owning nurse + admin
read raw EVV detail. `visit_verifications.status` and the parent `bookings.status` must stay consistent
via the documented mapping (§5).
- Relations: 1:1 → `booking_sessions`.
**`cancellation_policies`** [MVP] — config-driven, snapshot-able refund/penalty tiers by lead time + actor.
- Fields: `id` (BIGINT PK), `code` (NVARCHAR(50) **UNIQUE** — e.g. `standard_24h`, `nurse_no_show`),
`applies_to` (NVARCHAR(20) — `customer` | `nurse` | `admin`), `hours_before_start_min` /
`hours_before_start_max` (INT, **nullable** — tier bounds, half-open ranges), `refund_percentage`
(DECIMAL(5,2) — 0100), `fee_amount_or_rate` (cancellation fee / nurse penalty — store as a BIGINT IRR
fee plus an optional DECIMAL rate, or a discriminator + value; pick one and document it), `is_active`
(bool), audit + soft-delete fields.
- **Seed** the baseline tiers (admin CRUD is below): e.g. `standard_24h` (customer, ≥ 24h before start →
100% refund), `standard_inside_24h` (customer, < 24h → 50% refund), `nurse_no_show` (nurse → 100% refund
+ nurse penalty). Confirm exact tiers against the product doc; if the doc leaves a number open, pick the
safe default, make it config-seeded, and flag it in the report.
- **Resolution + snapshot:** the applicable row is resolved by `(applies_to, lead-time bucket)` at cancel
time and its **`code` + `refund_percentage`** are **frozen onto the cancellation** — a later edit to the
policy row must not change a past cancellation.
**Status enums** (define as proper enums; persist as string per project convention so the contract is
readable):
- `BookingStatus`: `pending_payment` | `confirmed` | `in_progress` | `completed` | `disputed` | `closed` | `cancelled`.
- `BookingSessionStatus`: `scheduled` | `in_progress` | `completed` | `missed` | `cancelled`.
- `VisitVerificationStatus`: `pending` | `checked_in` | `completed`.
- `CancellationActor` (for `applies_to`): `customer` | `nurse` | `admin`.
**Allowed booking transitions** (encode as a transition table consulted by `TransitionBookingStatusCommand`
— a CHECK constraint can back the terminal states; the table is the authoritative guard):
`pending_payment → confirmed | cancelled`; `confirmed → in_progress | cancelled`;
`in_progress → completed | cancelled`; `completed → disputed | closed`; `disputed → closed`. `closed`,
`cancelled` are terminal. **No transition may contradict EVV** (e.g. you cannot move a booking to
`in_progress` with no session checked-in; you cannot move to `completed` while a session is still
`in_progress`).
### 3.2 Commands & queries (CQRS, `OperationResult`, never throw for expected failures)
| Capability | Type | Route | What it does |
| --- | --- | --- | --- |
| **`ConvertRequestToBookingCommand`** | Command (internal step + mock-confirm trigger) | `POST api/v1/bookings/convert` (mock/test path — see §4) | The conversion engine, **invoked by payment capture in b10**. Loads an `accepted_awaiting_payment` `booking_requests` row, verifies capture succeeded (via `IPaymentCaptureSimulator` now / real `payment_transactions.succeeded` in b10). Creates a `bookings` row 1:1 (`pending_payment → confirmed`), writes `variant_snapshot_json` + **encrypted** `address_snapshot_json` from the current variant/address, **computes the three amounts** (`gross_price_irr` from the variant price × sessions/units; `balinyaar_commission_irr = round(gross × platform_fee_rate)` from the *config rate snapshotted into* `platform_fee_rate`; `nurse_payout_amount = gross commission`, asserting `gross = commission + payout`), sets `session_count`, flips the request → `converted`, and orchestrates **`GenerateBookingSessions`** in the same unit of work. **Idempotent:** the `booking_request_id` UNIQUE means a replay can't create a second booking — detect the existing booking and return it. |
| **`GenerateBookingSessions`** | Command (internal step) | — | Creates `session_count` `booking_sessions` (`session_index` 1…N, status `scheduled`), splitting `nurse_payout_amount` into `visit_payout_amount` so **Σ exactly equals `nurse_payout_amount`** (integer split + remainder on the last session). **Always creates ≥ 1 session**, even for a single visit, so the EVV/payout path is uniform. Per-visit schedule defaults from the engagement schedule; multi-session schedules can be filled later. |
| **`GetBookingDetailQuery`** | Query | `GET api/v1/bookings/{id}` | Booking header + money summary (the three amounts, `platform_fee_rate`, `psp_fee_amount`) + sessions (schedule, status, EVV state) + status timeline. **Tenancy-scoped:** customer sees own bookings, nurse sees assigned bookings, admin sees all — never cross-tenant. Projected (AsNoTracking + `.Select`). **Never** includes care-instruction clinical fields. |
| **`ListBookingsQuery`** | Query | `GET api/v1/bookings?role=customer\|nurse&status=&page=&page_size=` | The role-scoped "My bookings" list (customer / nurse), status-filterable, **projected + paginated**. Admin variant lists all. |
| **`ListSessionsForNurseQuery`** | Query | `GET api/v1/booking_sessions/today?date=` | The nurse's sessions for a day (today's visits), with per-session check-in/out CTA state. Tenancy-scoped to the nurse via `ICurrentUser`, projected + paginated. |
| **`TransitionBookingStatusCommand`** | Command | `POST api/v1/bookings/{id}/transition` | Applies a status change **only if allowed** by the transition table (§3.1) **and** consistent with EVV/session state; otherwise `OperationResult.FailureResult` (no throw). Records `confirmed_at`/`cancelled_at`/`completed_at` as appropriate. Most transitions are driven internally (capture → `confirmed`, first check-in → `in_progress`, last check-out → `completed`); the explicit endpoint covers admin/dispute moves. |
| **`SubmitCareInstructionsCommand`** | Command | `POST api/v1/bookings/{id}/care_instructions` | Writes/updates the 1:1 `booking_care_instructions` (**encrypted**) for a **confirmed** booking. Customer-authored (or admin). Validates the booking is `confirmed`+ (not `pending_payment`/`cancelled`). |
| **`GetCareInstructionsQuery`** | Query | `GET api/v1/bookings/{id}/care_instructions` | **Decrypts and returns** the clinical fields **only** to (a) the **assigned nurse** of that booking and (b) **admin**, and **only post-confirmation**. Any other caller (the customer, an unassigned nurse, pre-confirmation) → `403`/`NotFoundResult`**the two-stage disclosure boundary; do not leak.** |
| **`CheckInVisitCommand`** | Command | `POST api/v1/booking_sessions/{id}/check_in` | The assigned nurse clocks in: captures GPS + timestamp into `visit_verifications`, computes `check_in_distance_meters` to the booking address via **`IGeocoder`** and sets `check_in_address_match` against `config(evv_location_tolerance_meters)`. **On mismatch:** raise a `support_alerts` (`location_mismatch`) for admin review and notify via `INotificationDispatcher`**never block, never cancel.** Sets the session → `in_progress` and the booking → `in_progress` (first relevant check-in). Tenancy: only the assigned nurse. |
| **`CheckOutVisitCommand`** | Command | `POST api/v1/booking_sessions/{id}/check_out` | Must follow an open check-in (else `FailureResult`). Captures GPS + timestamp, sets `visit_verifications.status = completed`, the session → `completed`, and — when **all** of the booking's sessions are `completed`/`cancelled`/`missed` — the booking → `completed`, which fires **`SetDisputeWindow`** (below). |
| **`SetDisputeWindow`** | Command (internal step on completion) | — | On booking completion sets `dispute_window_ends_at = completed_at + config(dispute_window_hours, 72)`; on **each** session completion sets that session's `payout_eligible_at` from the same/per-session window. This is the **only** thing that makes a session payout-eligible — `completed` alone never is. |
| **`GetVisitVerificationQuery`** / **`ListSessionEvvQuery`** | Query | `GET api/v1/booking_sessions/{id}/evv`, `GET api/v1/admin_evv?type=mismatch\|no_show&page=&page_size=` | Per-session EVV detail (owning nurse + admin only) and the **admin EVV-review queue** (mismatch / no-show, joined to `support_alerts`). Projected + paginated; raw GPS gated to nurse(own)+admin. |
| **`CancelBookingCommand`** / **`CancelSessionCommand`** | Command | `POST api/v1/bookings/{id}/cancel`, `POST api/v1/booking_sessions/{id}/cancel` | Resolve the applicable `cancellation_policies` row by **lead time + actor**, **snapshot its `code` + `refund_percentage`** onto the cancellation (record `cancellation_event_id` on the session/booking, `cancelled_by`, `cancellation_reason`), set the session(s) → `cancelled` and (if whole booking) the booking → `cancelled`. **Refund only un-started sessions** (those still `scheduled` with no EVV check-in); a session already `in_progress`/`completed` is not refunded. **Refund *execution* is b11** — this phase records the cancellation + computes/snapshots the refundable amount and policy; it does **not** post the refund ledger or call a refund channel. |
| **`ManageCancellationPoliciesCommand`** (CRUD) | Command | `POST/PUT api/v1/admin_cancellation_policies` | Admin CRUD + the seed for the baseline tiers. Editing a policy **never** mutates an already-snapshotted cancellation. |
- **Controllers:** `BookingsController` (customer/nurse/admin, tenancy-scoped), `BookingSessionsController`
(nurse EVV + session views), `AdminEvvController` (admin review queue), and
`AdminCancellationPoliciesController` (admin). All `sealed : BaseController`, inject `ISender`, return
`base.OperationResult(...)`, snake_case `[controller]`/`[action]` routes, `CancellationToken` threaded.
Cancellation and EVV endpoints carry the **admin/nurse** narrowest-fitting policy; the cancel/convert
endpoints are **rate-limited**.
- **Validators:** FluentValidation on the input-bearing commands (`ConvertRequestToBookingCommand`
request id present + in `accepted_awaiting_payment`; `SubmitCareInstructionsCommand` — booking confirmed,
field lengths; `CheckIn/CheckOutVisitCommand` — GPS present, session belongs to the nurse;
`CancelBooking/CancelSessionCommand` — reason present; `ManageCancellationPoliciesCommand` — percentage
0100, non-overlapping tiers per actor).
### 3.3 No-show / late detection (job)
A scheduled sweep: if a session has no check-in by `scheduled_time_start + config(no_show_threshold)`,
create a `no_show` `support_alerts` row and notify the family via `INotificationDispatcher`, and mark the
session `missed` (per the EVV doc). **The recurring scheduler itself is DEFERRED** — build the
`DetectNoShowSessions` command (the unit of work the cron will call) and a config key for the cadence;
trigger it from an admin/test endpoint now and note it in the report. (Roadmap: a hosted scheduler — same
pattern as b8's `ExpireBookingRequests` and b13's `SchedulePayoutJob`.)
### 3.4 DEFERRED (build the seam/flag, not the feature)
- **`recurring_booking_schedules`** — open-ended recurring engagements: **modeled-but-inactive** per the
product doc. Do **not** create the table or any activation logic/UI this phase; launch is all finite
engagements. Note the deferral in the report.
- **Hard availability-based booking blocks** — availability slots/exceptions remain **soft** (search
guidance only, owned by the nurse domain); the nurse always individually accepts/rejects. Do not add a
hard block here.
- **Continuous geofencing during a live-in shift, supervisory tele-check-ins, family-visible live care
logs, consented in-home cameras** — DEFERRED per [`product/business/06-evv-and-service-delivery.md`](../../../product/business/06-evv-and-service-delivery.md) §(c).
Build only the per-session check-in/out EVV.
### 3.5 What this phase does NOT do (handed to later phases)
- **Real card capture, `payment_transactions`, `payment_webhook_events`, `ledger_entries`** — b10. This
phase only **consumes a capture signal** via the `IPaymentCaptureSimulator` seam to drive conversion.
- **Refund execution / refund ledger / `refunds`** — b11. This phase records the cancellation +
snapshots the policy + computes the refundable un-started-session amount; it posts no refund.
- **Payout batching / `nurse_payout_booking_links` / `dispute_window`-gated eligibility selection** — b13.
This phase only **sets** `dispute_window_ends_at` / `payout_eligible_at`; b13 consumes them.
- **Reviews on a completed booking** — b14.
## 4. Mocks & seams in this phase
| Seam | Owner | Mock behaviour | Registry |
| --- | --- | --- | --- |
| **`IPaymentCaptureSimulator`** | **introduced here** | `ConfirmCaptureAsync(bookingRequestId, ct)` returns a deterministic *succeeded* capture result (a fake `gateway_reference`, an optional `psp_fee_amount`) so `ConvertRequestToBookingCommand` is testable now. **In b10 the real card capture replaces this** by calling `ConvertRequestToBooking` directly after a real `payment_transactions.succeeded`; this seam is the temporary trigger, not a parallel money path. A config switch can force a *failed* capture so the "capture failed → no booking" path is testable. | **add a new row** (🟡) |
| `IGeocoder` | reuse from **b4** | mock returns fixed/deterministic coordinates + a haversine distance; used for the EVV `check_in_address_match` advisory and `check_in_distance_meters`. | reuse row |
| `IFieldEncryptor` | reuse from **b0** | local symmetric key; encrypts `address_snapshot_json` + the `booking_care_instructions` clinical fields; **never logs plaintext**. | reuse row |
| `INotificationDispatcher` | reuse from **b1** | real in-app `notifications` write; used for no-show/late + location-mismatch alerts to family/admin. | reuse row |
| `ICacheService` | reuse from **b0/b1** | in-memory; behind the typed config accessor (`dispute_window_hours`, `evv_location_tolerance_meters`, no-show threshold). | reuse row |
The `IPaymentCaptureSimulator` mock lives behind a **DI-registered interface** in Infrastructure (selected
by config; **no `if (mock)` branch in a handler**), so b10 swaps in the real capture trigger cleanly.
Append its row to [`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md)
(seam, file, what's faked, config keys, **step-by-step how to make it real** — in b10 this seam is removed
and `ConfirmPaymentAndPostLedger` calls `ConvertRequestToBooking` directly on a real `succeeded`
transaction).
## 5. Critical rules you must not get wrong
**Money correctness is sacred — the following must hold verbatim:**
- **Money is IRR `BIGINT`, no floats, ever.** `gross_price_irr`, `balinyaar_commission_irr`,
`nurse_payout_amount`, `psp_fee_amount`, `visit_payout_amount` are all `long`/`BIGINT`. Commission is
computed by integer-rounding `gross × platform_fee_rate` at conversion and the rate is **snapshotted**
into `platform_fee_rate`; no float survives into storage.
- **`gross_price_irr = balinyaar_commission_irr + nurse_payout_amount`, all amounts ≥ 0** — a DB CHECK on
`bookings`, and `nurse_payout_amount` is **derived** (`gross commission`), never free-entered. Never
store a split that doesn't sum.
- **A booking exists ONLY when the nurse accepted AND payment was captured.** Never create a `bookings`
row from an unpaid or unaccepted request; conversion runs **only** from an `accepted_awaiting_payment`
request with a successful capture (the `IPaymentCaptureSimulator` now / real `succeeded` transaction in
b10). On capture, flip the request → `converted`.
- **Append-only / snapshot discipline — snapshots freeze history.** `variant_snapshot_json`,
`address_snapshot_json`, `platform_fee_rate`, and the resolved cancellation `code` +
`refund_percentage` are frozen at their moment; **later edits to the variant/address/policy rows must
not mutate an existing booking.** Read snapshots from the booking, never re-resolve from live source rows.
- **The `payout_released` boolean was CUT — never reintroduce it.** Do not add any boolean "paid"/
"payout done" flag to `bookings` or `booking_sessions`. Paid-ness is derived later from a
`nurse_payout_booking_links` row + the ledger (b13).
- **Payout is eligible ONLY after `dispute_window_ends_at` passes with no open dispute — never on
`completed` alone.** `SetDisputeWindow` sets `dispute_window_ends_at = completed_at +
config(dispute_window_hours, 72)` (and per-session `payout_eligible_at`); b13 gates the payout on that,
not on the `completed` status. EVV check-out is necessary but not sufficient.
- **`Σ(visit_payout_amount) = nurse_payout_amount`** across the booking's sessions — reconcile exactly,
remainder on the last session; no Rial created or lost in the split.
**Domain invariants you must not get wrong:**
- **Two-stage clinical disclosure.** Pre-accept, the nurse sees **only** the unencrypted request-stage
`customer_notes` (b8). The full **encrypted** `booking_care_instructions` are readable **only
post-confirmation** and **only** by the **assigned nurse** + **admin** — never the customer, never an
unassigned nurse, never pre-confirmation. `GetCareInstructionsQuery` enforces this; the fields are never
projected into a list or logged.
- **A single-visit booking still creates exactly one `booking_session`** so EVV and payout follow one
uniform path. `GenerateBookingSessions` always produces ≥ 1.
- **EVV address mismatch is *advisory only*.** On a check-in outside `evv_location_tolerance_meters`, raise
a `location_mismatch` `support_alerts` + notify for admin review — **never auto-cancel, never block the
visit, never withhold based on mismatch alone.** GPS-permission-denied/unavailable still allows check-in
(flagged). Tolerance radius + no-show threshold come from `platform_configs`, not hardcoded constants.
- **EVV is per session, not per booking.** The FK is `booking_session_id`; a multi-day engagement accrues
payout per completed session; one EVV cannot represent a multi-day engagement.
- **Booking and EVV state machines must not diverge.** Transitions go through
`TransitionBookingStatusCommand`'s allowed-transition guard; `visit_verifications.status` and
`bookings.status` stay consistent via the documented mapping (`checked_in` ↔ session `in_progress`
booking `in_progress`; all sessions `completed` ↔ booking `completed`). No transition may contradict EVV.
- **Cancellation refunds only un-started sessions.** Mid-engagement cancel refunds only sessions still
`scheduled` with no check-in; `in_progress`/`completed` sessions are not refunded. The applicable policy
is resolved by lead time + actor and **snapshotted** at cancel time.
- **Tenancy + access discipline.** `GetBookingDetail`/`ListBookings`/`ListSessionsForNurse` are scoped to
the authenticated customer or nurse via `ICurrentUser` — a customer/nurse can never read another's
bookings or sessions; admin endpoints sit behind the admin policy. Raw EVV GPS detail and care
instructions are gated to the owning nurse + admin only.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] The five tables (`bookings`, `booking_sessions`, `booking_care_instructions`, `visit_verifications`,
`cancellation_policies`) exist via one migration, each with its `IEntityTypeConfiguration<T>`: the
`gross = commission + payout` (all ≥ 0) DB CHECK on `bookings`, the `booking_request_id` /
`booking_id` (care) / `booking_session_id` (EVV) UNIQUE 1:1 indexes, the encrypted
`address_snapshot_json` + care-instruction columns, the `cancellation_policies.code` UNIQUE + seeded
tiers, and soft-delete/audit wiring per conventions.
- [ ] All §3.2 commands/queries implemented (CQRS, `OperationResult`, projected + paginated reads,
validators), with `BookingsController`, `BookingSessionsController`, `AdminEvvController`,
`AdminCancellationPoliciesController`.
- [ ] **`IPaymentCaptureSimulator`** introduced (Application interface, Infrastructure mock, DI via a
`ServiceConfiguration/` extension, config-selected). No `if (mock)` in handlers.
- [ ] Conversion computes the three amounts correctly (`gross = commission + payout`), writes both
snapshots (address encrypted), sets `session_count`, generates ≥ 1 session with reconciling
`visit_payout_amount`, and flips the request → `converted` — idempotently (replay returns the
existing booking).
- [ ] Care instructions are hidden pre-confirmation and from the customer/unassigned nurse, and decrypt
only for the assigned nurse + admin post-confirmation. EVV check-in/out marks a session completed; a
GPS mismatch raises a `location_mismatch` alert **without blocking**; the last check-out completes the
booking and `SetDisputeWindow` sets `dispute_window_ends_at` (+ per-session `payout_eligible_at`).
- [ ] Cancellation resolves + snapshots the policy code/percentage and refunds only un-started sessions
(no refund ledger posted — that's b11). The no-show `DetectNoShowSessions` command works
(admin/test-triggered; cron DEFERRED).
- [ ] Handler unit tests (NSubstitute) for: the three-amount split + session reconciliation; the
two-stage disclosure gate; the EVV mismatch-raises-alert-without-blocking path; `SetDisputeWindow` on
completion; the cancellation policy resolution/snapshot + un-started-only refund computation; the
transition guard. ≥ 1 `WebApplicationFactory` integration test per controller (happy path, 401,
validation 400, and a 403 for the disclosure boundary). `dotnet build Baya.sln` zero new warnings;
`dotnet test Baya.sln` green.
- [ ] The `Baya.Application/Features/Bookings/**` area is reflected in the **Project map** in
`server/CLAUDE.md`; the `IPaymentCaptureSimulator` seam noted where seams are documented.
- [ ] The contract `dev/contracts/domains/bookings-evv.md` written and the `swagger.json` snapshot
republished.
## 7. How to test (what a human can verify after this phase)
Seed (or reuse from b8) an `accepted_awaiting_payment` `booking_requests` row pointing at a real variant,
patient, and address; have the nurse identity and a customer identity available. Keep the
`IPaymentCaptureSimulator` mock in *succeeded* mode unless a step says otherwise.
1. **Convert a request → booking (mock capture)**`POST api/v1/bookings/convert` for the accepted
request → a `bookings` row appears (`confirmed`), the request flips to `converted`, the **three amounts
sum** (`gross_price_irr = balinyaar_commission_irr + nurse_payout_amount`, all ≥ 0), `platform_fee_rate`
and the snapshots are populated (`address_snapshot_json` encrypted), `session_count` is set, and **N
`booking_sessions` are generated** with `Σ visit_payout_amount = nurse_payout_amount`. Re-`convert` the
same request → the **same** booking is returned (no second booking — idempotent).
2. **Single-visit uniformity** — convert a `session_count = 1` request → **exactly one** `booking_sessions`
row is created.
3. **Care instructions — disclosure boundary**`POST .../care_instructions` on the confirmed booking,
then `GET .../care_instructions`: **as the customer or an unassigned nurse → `403`/not-found** (hidden);
**as the assigned nurse (post-confirmation) → the decrypted fields** are returned; **as admin →**
returned. Confirm the clinical fields never appear in `GET api/v1/bookings/{id}` or any list.
4. **EVV check-in/out marks a session completed** — as the assigned nurse, `POST
.../booking_sessions/{id}/check_in` with in-range GPS → the session → `in_progress`, the booking →
`in_progress`; `POST .../check_out``visit_verifications.status = completed`, the session →
`completed`.
5. **GPS mismatch raises an alert without blocking** — check in with out-of-range GPS (force the
`IGeocoder` mock distance past `evv_location_tolerance_meters`) → the check-in **still succeeds**, the
session goes `in_progress`, `check_in_address_match = false`, and a `location_mismatch` `support_alerts`
row + a notification are created. Confirm it appears in `GET api/v1/admin_evv?type=mismatch`.
6. **Completion sets the dispute window** — check out the **last** remaining session → the booking →
`completed`, `dispute_window_ends_at = completed_at + 72h` (from config), and each completed session's
`payout_eligible_at` is set. Confirm the booking is **not** payout-eligible before that timestamp passes.
7. **Cancellation**`POST api/v1/bookings/{id}/cancel` (or a session) → the applicable
`cancellation_policies` tier is resolved by lead time + actor, its `code` + `refund_percentage` are
**snapshotted** onto the cancellation, only **un-started** sessions are marked refundable, and the
booking/session → `cancelled`. Edit the underlying policy row afterward → the snapshot on the past
cancellation is **unchanged**. (No refund ledger is posted — that's b11.)
8. **Transition guard** — attempt an illegal transition (e.g. `confirmed → completed` skipping
`in_progress`, or `completed` while a session is still `in_progress`) → `OperationResult` failure, no
state change.
9. **No-show** — trigger `DetectNoShowSessions` (admin/test endpoint) for a session past
`scheduled_time_start + threshold` with no check-in → a `no_show` `support_alerts` + family
notification; the session → `missed`.
## 8. Hand off & document (close the phase)
- **Docs to update:** the **Project map** in [`server/CLAUDE.md`](../../../server/CLAUDE.md) (add the
`Features/Bookings/**` area + the `IPaymentCaptureSimulator` seam). If you discover/confirm a rule the
product docs don't capture (e.g. the exact `visit_payout_amount` remainder-on-last-session split, the
EVV-state ↔ booking-state mapping table, the seeded cancellation tiers, or a `no_show` threshold default),
record it in [`product/business/05-booking-and-scheduling.md`](../../../product/business/05-booking-and-scheduling.md)
/ [`product/business/06-evv-and-service-delivery.md`](../../../product/business/06-evv-and-service-delivery.md)
(and regenerate the HTML view per `product/CLAUDE.md`) — **don't invent rules**, record decisions.
- **Contract to write:** **`dev/contracts/domains/bookings-evv.md`** (per
[`../../contracts/domains/_TEMPLATE.md`](../../contracts/domains/_TEMPLATE.md)) — the booking endpoints
(convert/detail/list, transition), the care-instruction submit/read (with the **two-stage disclosure**
note: assigned-nurse/admin only, post-confirmation), the session/EVV endpoints (check-in/out, today's
sessions, EVV detail, admin EVV-review queue), the cancellation endpoints + admin policy CRUD; the
`BookingStatus` / `BookingSessionStatus` / `VisitVerificationStatus` / `CancellationActor` enums; the
booking/session/EVV/care-instruction DTO shapes (IRR `BIGINT`; the three amounts; **address snapshot
masked/omitted** in list views; **raw GPS gated**); auth/tenancy/rate-limit notes; and the side-effects
(dispute-window set on completion, `support_alerts` on mismatch/no-show, snapshot freezing). Republish
the `swagger.json` snapshot per [`../../contracts/openapi/README.md`](../../contracts/openapi/README.md).
This is what **f8-b9** consumes.
- **Handoff & report:** write `dev/shared-working-context/backend/handoff/after-backend-phase-9.md` (the
booking engine is live; what f8 can now build — booking detail & sessions, nurse EVV check-in/out, the
post-confirmation care-instructions form, the status timeline; which endpoints/contracts are live; that
capture is mocked behind `IPaymentCaptureSimulator` and the real conversion trigger arrives with b10
payments). Append to `backend/STATUS.md`. Write
`dev/shared-working-context/reports/backend-phase-9-report.md` (what was built, **what is now testable and
exactly how** per §7, what is mocked + how to make it real, contracts produced/consumed, follow-ups: the
no-show cron, refund execution in b11, payout eligibility consumption in b13, `partner_centers` wiring in
b15). Update `dev/shared-working-context/reports/mocks-registry.md` (the `IPaymentCaptureSimulator` row →
🟡).
- **Memory:** save a `project` memory note for the non-obvious decisions this phase fixes — the
conversion-only-on-accept+capture rule, the three-amount split + `Σ visit_payout_amount =
nurse_payout_amount` reconciliation, the snapshot-freezes-history discipline, the **two-stage clinical
disclosure** gate, the advisory (never-blocking) EVV mismatch behaviour, `SetDisputeWindow` as the *only*
payout-eligibility trigger, the cut `payout_released` boolean, and the `IPaymentCaptureSimulator` seam —
with a one-line pointer in `MEMORY.md`.