# Backend Phase 5 — Service catalog & nurse pricing variants — report **Status:** complete · build clean (0 new code warnings) · `dotnet test Baya.sln` green (122 pass: +21 over b4's 101 — 10 new handler/serializer unit tests, +11 API integration & adversarially reviewed). **Nothing is mocked** in this phase. ## What was built The two-tier service model the whole marketplace is priced/searched on — one additive migration (`20260702132758_ServiceCatalogAndNurseVariants`, new **`catalog`** schema), five tables, 14 endpoints across 3 controllers, plus the b8 snapshot serializer. - **Entities** (`Domain/Entities/Catalog/`): `ServiceCategory`, `ServiceOptionGroup` (nullable `ServiceCategoryId` = cross-category), `ServiceOptionValue`, `NurseServiceVariant` (`Price` **BIGINT IRR**, `PriceUnit`, `SessionCount?`, `DisplayName`, `OptionSetHash`), `NurseServiceVariantOption`, and the closed `PriceUnits` set (`per_hour`/`per_session`/`per_half_day`/`per_day`/`per_24h`). - **EF configs + seed** (`Persistence/Configuration/CatalogConfig/`): soft-delete query filters; `UNIQUE(variant_id, option_group_id)`; filtered `UNIQUE(nurse_id, service_category_id, option_set_hash) WHERE deleted_at IS NULL`; `(is_active, sort_order)` / `(service_category_id, sort_order)` / `(option_group_id, sort_order)` / `(nurse_id, is_active)` / `(service_category_id)` indexes. **Five seed categories** (`nameFa`+`nameEn`, ids 1–5) via `HasData`. - **Admin catalog CQRS** (`Features/Catalog/`): `CreateServiceCategory`/`UpdateServiceCategory`/ `SetServiceCategoryActive`, `CreateServiceOptionGroup`/`UpdateServiceOptionGroup`, `CreateServiceOptionValue`/`UpdateServiceOptionValue`, and the public cached `GetCatalogCategories` (paginated) / `GetCategoryOptionGroups` (applicable = own + cross-category). Every mutation invalidates the `CatalogCache` generation token. - **Nurse variant CQRS** (`Features/Variants/`): `CreateVariant` (required-group enforcement incl. cross-category, one-value-per-dimension, value-in-group, duplicate-listing pre-check + auto display-name), `UpdateVariant` (price/unit/session/display; option-set immutable), `SetVariantActive` (deactivate, never delete), `ListMyVariants` (active + inactive), `GetVariant` (owner/admin full · public active-only). - **`OptionSetHash`** helper (deterministic, order-independent SHA-256) + **`IVariantSnapshotSerializer`** (pure, singleton; canonical `variant_snapshot_json` for b8). - **Controllers** (`Controllers/V1/`): `AdminCatalogController` (dynamic-permission), `CatalogController` (public), `NurseVariantsController` (`[Authorize]`; `get/{id}` is `[AllowAnonymous]`). ## What is now testable — and exactly how (the phase §7 steps) Run the API (`dotnet run --project src/API/Baya.Web.Api/...`) against a reachable SQL Server; use Swagger/curl. 1. **Catalog seeded** — `GET /api/v1/catalog/categories` → `200`, five categories, `nameFa`+`nameEn`, ordered by `sortOrder`, active only. 2. **Admin builds a dimension** — as admin, `POST /api/v1/admin_catalog/create_option_group` `{ serviceCategoryId: 1, nameFa: "نوع شیفت", nameEn: "Shift type", isRequired: true, sortOrder: 1 }` → `200`; `POST /api/v1/admin_catalog/create_option_value` twice → `200`; `GET /api/v1/catalog/option_groups?category_id=1` → the required group **plus any cross-category groups**, each with its values. 3. **Nurse builds a valid variant** — `POST /api/v1/nurse_variants/create` `{ serviceCategoryId: 1, options: [{ optionGroupId, optionValueId }], price: "8000000", priceUnit: "per_24h" }` → `200`, `isActive: true`, auto `displayName` = category + value labels. 4. **Duplicate identical listing** — repeat the exact create → clean **`409`** (not a `500`). 5. **Missing required dimension** — create with `options: []` → **`400`** naming the missing group. 6. **One value per dimension** — two values for the same group in one create → rejected (handler + the `UNIQUE(variant_id, option_group_id)` backstop). 7. **List active + inactive** — `GET /api/v1/nurse_variants/list`; then `POST /api/v1/nurse_variants/set_active/{id}` `{ isActive: false }` → the deactivated variant still appears, flagged `isActive: false` and unbookable; the row is **never** hard-deleted. 8. **Tenancy** — a *different* nurse `POST /api/v1/nurse_variants/update/{id}` on the first nurse's variant → **`404`** (existence not leaked). 9. **Snapshot serializer** — unit-tested: the JSON carries the category labels, each option label, `price` (as a digit string), `priceUnit`, and `sessionCount`. Automated coverage: `Baya.Test.Foundation/Catalog/` (CreateVariant valid/missing-required/duplicate/ one-value-per-dimension/value-not-in-group/inactive-category/non-nurse/override; admin category cache invalidation + parent checks + cross-category null group; serializer) and `Baya.Test.Api/` (`CatalogPublicApiTests`, `NurseVariantsApiTests` — full lifecycle incl. 409/400/tenancy-404/public-get/401). ## Adversarial review A 4-dimension review (tenancy/authorization, EAV/cross-category/required-group, money integrity, EF-translation/ caching/soft-delete) with independent per-finding verification ran over the diff (159 tool-uses, ~382k tokens): **0 confirmed findings**. Spot-checked by hand: cross-nurse `get/{id}` returns the public (never full) view; the duplicate hash includes cross-category selections; missing a required cross-category group → 400. ## Contracts produced / consumed - **Produced:** [`dev/contracts/domains/catalog.md`](../../contracts/domains/catalog.md) (routes, shapes, `price_unit` enum, IRR-string money, `409`/`400` failure cases, examples). `swagger.v1.json` **refreshed** (71 paths total; +14 catalog/variant paths) — verified the API boots and applies the migration on a real SQL Server. - **Consumed:** `nurse_profiles` (b3), `ICacheService`/CQRS/`OperationResult`/`BaseController` (b0), admin dynamic-permission policy (b1/b2). No geography coupling (b7 joins the two later). ## Mocks **None.** This phase introduces no cross-cutting seam and adds **no** row to [`mocks-registry.md`](mocks-registry.md) — stated here so the next agent doesn't go looking. `IVariantSnapshotSerializer` is an internal application contract with a single real implementation (not a mock seam). `ICacheService` is reused (already 🟡 from b0), not redefined. ## Follow-ups for later phases - **b7 (search & matching)** — owns `nurse_search_index`, `INurseSearch`, the search query, and index fan-out. Reads per active variant: `service_category_id`, `price`, `price_unit`, `is_active`, `nurse_id`, fanned across the nurse's `nurse_service_areas` (b4). The maintenance trigger points are `CreateVariantCommand` / `SetVariantActiveCommand` (and future option/price edits). - **b8 (booking)** — owns `booking_requests.variant_snapshot_json`; calls `IVariantSnapshotSerializer` at booking time. The serializer is shipped and unit-tested here. - **Deferred (not built):** `nurse_availability_slots`/`_exceptions` (soft guidance); holiday/surge pricing; a Companionship *tier* pricing model (ships only as a seeded category); tiered per-category commission. ## Notable decisions (recorded in product/eng docs, not invented) - Routes are **action-style POST** with camelCase bodies (matching b3/b4), not the PUT/PATCH the phase table sketched — documented in the contract's routing note. - `update_option_value` does **not** re-parent a value to another group (would silently change the meaning of variants that already answered with it). - `price` crosses the wire as a **string of digits** (`money-and-types.md`); the DTO/command use `string`. - The `OptionSetHash` + filtered-unique duplicate-listing strategy is noted as a reusable pattern in `server/CONVENTIONS.md` §6. No new *business* rules were discovered — the product docs (`business/03`, `data-model/03`) already matched; left unchanged.