# Flow — nurse catalog & pricing

> Last verified: 2026-08-02 against commit `c841bde`

**Actor(s):** nurse (browse side: customer, anonymous) · **Status:** partial
**Client:** real · **Server:** real
**Business source:** [product/business/03-service-catalog-and-pricing.md](../../product/business/03-service-catalog-and-pricing.md)
**Integration:** [docs/integration/domains/catalog.md](../integration/domains/catalog.md)

## What it does

A nurse turns the platform's service catalogue into her own price list. She picks a category, answers the
dimensions the catalogue defines for it, and puts a price on that exact configuration. The result — a
**variant** — is the atomic bookable unit: it is what a customer searches for, taps, and pays for. The
catalogue skeleton itself (categories → option groups → option values) is admin-owned reference data;
the nurse only composes on top of it.

## Screens

| Step | Route | Component / notes |
| --- | --- | --- |
| Offerings list | `/fa/nurse/services` | [`MyServicesList.tsx`](../../client/src/app/%5Blocale%5D/%28private-routes%29/nurse/services/MyServicesList.tsx) — `useMyVariants()`, per-row activate/deactivate via `useSetVariantActive`, `EmptyState` when none |
| Go-live gate | same page (top card) | [`PublishGate.tsx`](../../client/src/app/%5Blocale%5D/%28private-routes%29/nurse/services/PublishGate.tsx) — reads `useActivationChecklist`, fires the real `profiles` `set_accepting_bookings`; a listed variant is not a *visible* variant |
| Build / edit | same page, in-place | [`VariantBuilder.tsx`](../../client/src/app/%5Blocale%5D/%28private-routes%29/nurse/services/VariantBuilder.tsx) — 3-step stepper (category → options → price). No extra route; `page.tsx:18-28` swaps the body |
| Practice hub | `/fa/nurse/practice` | `NursePracticeScreen.tsx:25` reads `useMyVariants()` only for the "how many offerings" count |
| Public preview | `/fa/nurse/profile/preview` | `preview/page.tsx:47,68` — renders **only `isActive` variants** through `ServicePriceRow`, entirely from the nurse's own cache (never the search index) |

The builder's middle step is **derived, not fixed**: `VariantBuilder.tsx:140-144` drops the options step for a
category that provably has zero option groups, and blocks *Next* while a required group is unanswered
(`:134`), naming the missing dimension instead of erroring after the tap.

## API

| Call | Endpoint | Notes |
| --- | --- | --- |
| Categories | `GET /api/v1/catalog/categories` | anonymous; `clientApi.ts:28-35` → `CatalogController.Categories` |
| Option groups | `GET /api/v1/catalog/option_groups?category_id=` | anonymous; `clientApi.ts:37-40` → `CatalogController.OptionGroups` (`category_id` is snake_case, the only one) |
| My offerings | `GET /api/v1/nurse_variants/list` | `[Authorize]`, self-scoped; `clientApi.ts:42-49` |
| One variant | `GET /api/v1/nurse_variants/get/{id}` | **`[AllowAnonymous]`** by design (`NurseVariantsController.cs:46`) — a public profile deep-links a variant |
| Create | `POST /api/v1/nurse_variants/create` | `CreateVariantCommand.Handler.cs` |
| Edit price | `POST /api/v1/nurse_variants/update/{id}` | option-set is immutable on update (`UpdateVariantCommand.Handler.cs:33-37`) |
| Retire / restore | `POST /api/v1/nurse_variants/set_active/{id}` | soft only — there is no delete |

Shapes live in [catalog.md](../integration/domains/catalog.md). The seven `admin_catalog/*` authoring routes
exist on the server and have **no client caller at all** (`grep -rn admin_catalog client/src` → 0 hits).

## Rules that must hold

| Rule | Where it is enforced |
| --- | --- |
| **The catalogue is EAV**: categories → option groups → option values. Option groups are **not** seeded with the migration — `CatalogSeed.cs:7` says so explicitly ("those are admin-authored data per category"). A group with `serviceCategoryId = null` is **cross-category**. | `CatalogSeed.cs`, `GetApplicableGroupsAsync` |
| **Duplicate guard = `option_set_hash`.** SHA-256 over the sorted `(groupId:valueId)` pairs → one comparable column, so "same set of choices" becomes expressible as `UNIQUE(NurseId, ServiceCategoryId, OptionSetHash)` filtered on `DeletedAt IS NULL`. Handler pre-checks and returns a clean `409`; the index is the race backstop. | `OptionSetHash.cs:16-25`, `NurseServiceVariantConfig.cs:28-31`, `CreateVariantCommand.Handler.cs:65-69` |
| **Every required dimension must be answered, exactly once**, and every value must belong to an applicable group. | `CreateVariantCommand.Handler.cs:40-62` |
| **`PriceUnit` is a closed set of 5** (`per_hour` `per_session` `per_half_day` `per_day` `per_24h`) and is a **label, never a multiplier** — the client must not derive a total from it. | `Domain/Entities/Catalog/PriceUnits.cs`; §2b of the business-rule map |
| **Money is IRR integer.** The wire carries a digit string; the DB column is `bigint`. The **only** Toman↔Rial boundary is the price field: `tomanToRial` on submit, `rialToToman` to pre-fill an edit. | `utils/money.ts:38` (`tomanToRial`, `×10` via `BigInt`), used at `VariantBuilder.tsx:113,183`; `NurseServiceVariantConfig.cs:13` |
| **The variant snapshot freezes a variant onto a booking** so a later edit or deactivation never mutates a past booking, dispute or invoice. | `IVariantSnapshotSerializer`, `BookingFactory.cs:53` → `Bookings.VariantSnapshotJson` (`Booking.cs:41`) |
| **Search visibility is a separate gate.** Every create/update/set_active reindexes in the same unit of work; a row is searchable only when `is_verified AND is_accepting_bookings AND status != suspended AND variant.is_active` (INV-17). Deactivated rows stay with `is_searchable = 0`, never deleted. | `CreateVariantCommand.Handler.cs:99`, `UpdateVariant…:40`, `SetVariantActive…:34` |
| **Tenancy is 404, not 403** — another nurse's variant id resolves to "not found". | `UpdateVariantCommand.Handler.cs:28-31` |

## How to test

1. Log in as `09120000001` (nurse زهرا عزیزی, verified) — see [testing-setup.md](testing-setup.md).
2. Open `/fa/nurse/practice`. **Expect:** the services row shows a non-zero offerings count.
3. Open `/fa/nurse/services`. **Expect:** a `PublishGate` card at the top and **5** offering cards.
   The task brief and the seeder say *3* — that is stale: live `nurse_variants/list` returned
   `total: 5` (ids 1, 2, 3 seeded + 7, 8 created by earlier manual testing on the real path). Their
   existence is itself proof the create path works end to end.
4. Tap *add*, choose «مراقبت از سالمند», then *Next*. **Expect:** a middle step titled with the
   required dimension «نوع شیفت» offering روزانه / شبانه / شبانه روزی. It is required, so *Next* stays
   disabled until one is picked.
5. Pick **شبانه**, price `2000000` Toman, unit «نیم‌روز», duration `1`, submit. **Expect:** an inline
   duplicate warning (not a toast) offering «ویرایش همان مورد» — this collides with variant `8`
   (`cat 1 / Night / 20000000 IRR / per_half_day`). Server returns exactly
   `409 · "You already offer this exact configuration in this category."`;
   `VariantBuilder.tsx:258-260` maps it to the inline state.
6. Go back, pick **روزانه** instead, price `300000` Toman, unit «ساعتی». **Expect:** success; the list
   now shows the new card, display name auto-built as «مراقبت از سالمند · روزانه»
   (`CreateVariantCommand.Handler.cs:131-134`).
7. Deactivate that card. **Expect:** it stays in the list marked inactive, and disappears from
   `/fa/nurse/profile/preview` (which filters `isActive`, `preview/page.tsx:68`).

Live probes run for this stamp. Anonymous: `GET /catalog/categories` → **5** active categories
(ids 1–5, `sortOrder = id`); `GET /catalog/option_groups?category_id=1` → **1** group (`id 1`,
`serviceCategoryId: null`, `isRequired: true`, 3 values); `GET /nurse_variants/get/{id}` → `200`
without a token. Nurse-token: nurse 1 `total: 5`, nurse 2 `total: 2`.

All three write-path guards were exercised live against the running API and **CONFIRMED**:

| Probe | Result |
| --- | --- |
| `create` with nurse 1's existing `cat 1 / valueId 2` set | `409 · "You already offer this exact configuration in this category."` — and no row was added (`total` stayed 5) |
| `create` on `cat 3` with `options: []` | `400 · {"Options":["Required dimension(s) not answered: نوع شیفت."]}` — the cross-category group is enforced on a category that never declared it |
| `create` with a **customer** token (09120000010) | `403 · "Only a nurse can create a variant."` — the role check is in the handler, not just the attribute |

The 409 fires from the handler pre-check (`:68-69`), ahead of the index; the filtered UNIQUE index is
the race backstop, not the message source.

## Known gaps

- **No catalogue-authoring UI exists.** All seven `admin_catalog/*` routes (create/update category,
  option group, option value) have zero client callers. An admin cannot add a dimension without SQL.
- **Even with a UI, no seeded admin could use it.** `admin_catalog` is `[Authorize(DynamicPermission)]`,
  which `super_admin`/`finance` fail — see [testing-setup.md](testing-setup.md#-the-seeded-admins-cannot-reach-any-admin-endpoint).
- **A production database has zero option groups.** `CatalogSeed.cs` seeds categories only. The single
  «نوع شیفت» group exists because `DemoWorldSeeder.EnsureShiftTypeGroupAsync` runs **in Development only**
  (`DemoWorldDefinitions.cs:36-40` says so). Deployed, every builder collapses to two steps and every
  variant in a category is a duplicate of every other — one price per nurse per category, forever.
- **ZWNJ is stripped from every stored Persian string.** `StringExtensions.FixPersianChars` line 90
  (`.Replace("‌", " ")`), applied to every string property of every entity at
  `ApplicationDbContext.cs:79`. Live proof: the seeder writes «شبانه‌روزی» and the API returns
  «شبانه روزی» (`ش…ه ر…` — a literal U+0020). This contradicts the repo naming rule
  ("with a ZWNJ, always") and silently mangles nurse-typed display names. The API test at
  `NurseVariantsApiTests.cs:50-51` documents the behaviour rather than fixing it.
- **A price edit is not shielded from an in-flight request.** `UpdateVariantCommand.Handler.cs:33` writes
  the new price unconditionally, and `BookingRequestRepository.GetConversionSourceAsync:212-229` reads
  `r.Variant.Price` **live** at conversion. The snapshot freezes at booking creation (post-payment), not
  at request creation — so a nurse editing price inside the 30-min payment window changes what the
  customer pays. Code-traced, not exercised live.
- **`IVariantSnapshotSerializer`'s doc-comment names the wrong table** — it says the JSON is frozen onto
  a `booking_requests` row; the only field is `Bookings.VariantSnapshotJson` (`Booking.cs:41`).
- **Category `iconKey` and both description fields are `null` for all 5 categories** (live probe), so the
  Home A5 grid and the builder's category tiles fall back to a generic icon and show no explainer copy.
- **The «همراهی و مراقبت روزمره» (Companionship) category is data-only** — `CatalogSeed.cs:11` notes it
  "ships only as a seeded category, not a pricing path", but the builder offers it like any other.
- **`sessionCount` is free-typed and unvalidated against `priceUnit`** — nothing stops
  `per_24h` + `sessionCount: 5`; seeded variant `7` is `per_session` + `5`, variant `8` is
  `per_half_day` + `1`, both legal.
