# 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`), 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//{Commands|Queries}//` (request `record` + `internal sealed` handler + `OperationResult`, validator picked up by `ValidateCommandBehavior`), an `IEntityTypeConfiguration` under `Persistence/Configuration/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` 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.