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