# Shop · Services catalogue — Business Rules

Rules the demo encodes (`Services.tsx`, `lib/finance.ts`, `store/selectors.ts`, `types.ts`),
numbered + implementable. Each notes our status.

## Catalogue scope

1. The catalogue is ONE surface with five item types: **services, routines, free add-ons,
   bundles, subscription packages** (`Services.tsx:63`). — Ours: services only on this surface;
   packages on a separate page; routines/add-ons/bundles **absent**.
2. Every list is scoped to the active shop (`shopId`) (`Services.tsx:80-84`). — Ours: enforced via
   search + `shop_id` stamp + `checkOwnership`, but **not** inside `findModel`.

## Pricing & VAT (VAT-inclusive model)

3. `basePrice` (before-discount) is **required** and must be `> 0`; Save is blocked otherwise
   (`Services.tsx:765-766`). — Ours: `service_amount` required (`base/ShopService.php:87`); no
   explicit `> 0` rule (verify).
4. Discount types: **none / fixed / percent** (`DiscountType`, `Services.tsx:663`). Fixed subtracts
   an amount floored at 0; percent is clamped 0–100 (`finance.ts applyServiceDiscount`). — Ours:
   `DISCOUNT_TYPE_PERCENTAGE=1`, `DISCOUNT_TYPE_FIXED_AMOUNT=2` (`ShopService.php:16-17`); percent
   path divides by 100, fixed subtracts (`ShopServiceController.php:200-204`). Matches; clamping
   not enforced server-side (UI hint says 1–100, `_form.php:291`).
5. Final/effective `price = applyServiceDiscount(base,...)` and is what the booking engine consumes
   (`Services.tsx:752`, `finance.ts`). — Ours: stored as `price_excl_vat_after_discount`
   (the **net** value), `_form.php:327`.
6. VAT is **inclusive**: `base = price/(1+vatPct/100)` when VAT-registered, else `base = price`;
   `vat = price - base` (`finance.ts vatBreakdown`). — Ours: `calculateVat` uses
   `vat = netTotal*rate/(1+rate)` (`ShopService.php:56-67`) — equivalent. ✅
7. VAT only applies when the shop is VAT-registered (`shop.vatRegistered`). — Ours:
   `Shop::is_taxable == IS_TAXABLE_YES`, rate from `Settings::taxes` (`ShopServiceController.php:195`). ✅
8. Show "discount saves {amount} ({pct}%)" and warn when a discount **zeroes** the price
   (`finance >0`, `Services.tsx:758-760, 984-991`). — Ours: **missing**.
9. Package `pricePerSession = price / sessions` (`finance.ts`, shown on card `Services.tsx:468`).
   — Ours: packages are a separate feature (`PackageController`) — out of this surface's scope.

## Duration

10. Service duration chosen from 15…240 min in 15-min steps (`Services.tsx:67`). — Ours: dropdown
    too, but **additionally validated to be a multiple of the shop's `slot_time_step`**
    (`base/ShopService.php:143-160`) — stricter, real booking rule. ✅ (ours stronger).
11. A service's displayed total duration = base + attached routine/add-on minutes
    (`selectors.ts:100`). — Ours: **missing** (no freebies).

## Lifecycle / visibility

12. `active` defaults true; `active !== false` ⇒ active (`selectors.ts:106`). — Ours: `status`
    ACTIVE(1)/ARCHIVED(0); default find() shows ACTIVE only (`ShopService.php:77-83`).
13. Shown in customer app only when `active && !hidden`; inactive auto-hides (`selectors.ts:108`).
    — Ours: **no `hidden` flag** — only archived/active. Partial.
14. Deactivating requires confirmation; visibility toggle does not (`Services.tsx:201-213`). — Ours:
    no toggles; delete requires confirm (`index.php:236-239`).
15. `showInactive=false` hides inactive items from the shop list (`selectors.ts:112`). — Ours: list
    always excludes archived; no toggle. Partial.

## Deletion constraints

16. Demo delete removes the item from the store after confirm (`Services.tsx:214-222`); no linkage
    guard. — Ours: delete = **soft archive**, and is **blocked if the service is linked to any
    package** (`ShopServiceController.php:385-414`). ✅ (ours stronger / business-correct).

## Validation (Save gate — demo `CatalogueModal`)

17. Missing-field collector blocks Save and lists what's missing: name, price (>0), and — for
    bundles/packages — at least one included service (`Services.tsx:763-767, 1149-1153`). — Ours:
    standard Yii `required` on name/amount/period/service_id; no "missing list" banner; no
    included-services rule (bundles absent).
18. Routine name (FreebieModal) requires non-empty EN name (`Services.tsx:566`). — Ours: n/a.

## Bilingual

19. Name + description are bilingual (`{en, ar}`) for every catalogue item
    (`BilingualField`, `Services.tsx:876-892`). — Ours: **name** is bilingual via
    `MultiLanguageBehavior` (`base/ShopService.php:270-280`); **description field appears absent**
    from the form. Partial.

## Variants

20. A service may carry image **variants** (e.g. Blonde/Red), each a square image + bilingual name
    (`ServiceVariant`, `Services.tsx:1286-1355`). — Ours: **missing** (a gallery manager exists but
    is unnamed image samples, not customer-selectable named variants).

## Attached freebies (routines + free add-ons)

21. A service/bundle can attach `routineBeforeIds`, `routineAfterIds`, `addonIds` (Freebie ids);
    these add minutes and render as chips (`types.ts CatalogueLifecycle`, `Services.tsx:1090-1124`).
    — Ours: **missing**.

## Agent assignment

22. Specialist assignment is **read-only** on the catalogue and managed in Team; empty ⇒ "Any
    specialist" (`Services.tsx:1128-1147`, `selectors.ts:118`). — Ours: **editable here** via
    `userIds` M2M (`ShopServiceController.php:149-155`). Divergent (ours richer, different model).

## Category

23. Service category is optional, chosen from a 2-level group→category optgroup list
    (`Services.tsx:1020-1040`); `categoryId` is source of truth, `category` string is legacy
    fallback (`types.ts`). — Ours: category derived from the linked `Service`/`ShopCategory`
    (`ShopService.php:148-151`), surfaced via `ServiceCategoryAssignment`; different model.
