add build development phases
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 b11–b13, 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`.
|
||||
@@ -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 **7–10 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 ~7–10 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 7–10-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 ~7–10 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`.
|
||||
@@ -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 7–15%; Torob Pay's published 6.6%; **read the actual deducted amount
|
||||
from the settlement, never hardcode**), **settlement timing is NOT instant** (daily/T+1–3/weekly/15-day,
|
||||
per-transaction `settled_at`), Toman↔Rial conversion at the boundary, and the async ~7–10-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+1–3/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` (~7–10 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+1–3/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 ~7–10 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` (~7–10 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 ~7–10-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`.
|
||||
@@ -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`.
|
||||
@@ -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 1–5 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` (1–5) + `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 1–5, 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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 (b10–b13).
|
||||
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) — 0–100), `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
|
||||
0–100, 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`.
|
||||
Reference in New Issue
Block a user