# 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.
