# Shop · Services catalogue — Logic & Flows

Canonical: React demo `portals/shop/Services.tsx` (+ `lib/finance.ts`, `store/selectors.ts`, `types.ts`).
Ours: `frontend/controllers/ShopServiceController.php`, `frontend/views/shop-service/*`, `common/models/ShopService.php`.

## 1. Top-level structure

### Demo
`ShopServices()` (`Services.tsx:69`) renders ONE unified catalogue with a 5-way `Segmented`
tab control (`Services.tsx:124-134`) over a single shop-scoped surface:

| Tab | Source | ref |
|---|---|---|
| Services | `state.services.filter(shopId)` | `Services.tsx:80` |
| Routines (freebie kind `routine`) | `freebiesForShop(state, shop.id,'routine')` | `Services.tsx:81` |
| Add-ons (freebie kind `addon`) | `freebiesForShop(..., 'addon')` | `Services.tsx:82` |
| Bundles | `state.bundles.filter(shopId)` | `Services.tsx:83` |
| Packages | `state.packages.filter(shopId)` | `Services.tsx:84` |

Tab labels carry live counts (`tabServices`, `{ n }`). A `showInactive` toggle
(`Services.tsx:135-138`) controls whether inactive items appear. All create/edit happens in
modals (`CatalogueModal` for services/bundles/packages, `FreebieModal` for routines/addons) —
no page navigation.

### Ours
`actionIndex` (`ShopServiceController.php:76`) renders ONE grid of `ShopService` records only —
**services tab only**. No tabs, no routines, no add-ons, no bundles. Packages exist but live in a
**separate** controller/route (`frontend/controllers/PackageController.php`, `frontend/views/package/*`),
not on this surface. Create/edit are **full pages** (`create.php`, `update.php` → `_form.php`),
not modals (though `actionCreate`/`actionUpdate` also support `renderAjax('update')` for a
modal-ish path, `ShopServiceController.php:176,285`).

Our index additionally has a "Service Categories" cover-image customiser
(`index.php:67-186`, driven by `ServiceCategoryAssignment`) — this has **no demo equivalent**.

## 2. Pricing / discount / VAT

### Demo (`lib/finance.ts`)
- `applyServiceDiscount(base, type?, value?)` (`finance.ts`): `fixed` → `max(0, base - value)`;
  `percent` → `base * (1 - clamp(value,0,100)/100)`; none/0 → base. Result is the stored `price`.
- `vatBreakdown(price, vatRegistered, vatPct)`: VAT-**inclusive** model. `base = price/(1+vatPct/100)`
  when registered, else `base = price`; `vat = price - base`. Presentation-only.
- `pricePerSession(price, sessions) = price/sessions` (packages, `Services.tsx:468`).
- In `CatalogueModal` (`Services.tsx:749-760`) both the before-discount and after-discount prices
  get a live VAT split rendered in three cards (before / discount toggle / after), entirely
  client-side, synchronous, with live "saves X (Y%)" and "discount zeroes the price" feedback.

### Ours
- `actionCalculateVat` (`ShopServiceController.php:183-230`) — an **AJAX** endpoint hit on every
  price/discount input (`_form.php:505`). Server computes before/after discount + VAT via
  `ShopService::calculateVat($totalInclVat, $vatRate, $discountInclVat)`
  (`ShopService.php:56-67`): `vat = netTotal*rate/(1+rate)`, i.e. also **VAT-inclusive** — matches
  the demo's model. Discount amount: percentage `total*value/100`, fixed = value (`:200-204`).
- Taxable flag from `Shop::is_taxable`; rate from `Settings::taxes` (`:195-196`).
- The discounted net is stored in `price_excl_vat_after_discount` (hidden input, `_form.php:327`).
- **Parity**: math matches. Difference is demo=pure client function, ours=server round-trip.
  There is no "saves X% / discount zeroes price" feedback on our side.
- Extra: `actionCheckServicePrice` (`:455`) shows price-after-platform-commission — no demo analog.

## 3. Duration

### Demo
Duration is a fixed dropdown of 15…240 min in 15-min steps (`DURATIONS`, `Services.tsx:67`).
`totalDuration(state, service) = durationMin + attachedFreebieMinutes` (`selectors.ts:100`) —
attached routines/add-ons add minutes, surfaced as "+N" on the card (`Services.tsx:336-337`) and
in the form hint (`Services.tsx:1006-1010`).

### Ours
`service_period` is a free dropdown (`_form.php:344`) **validated to be a multiple of the shop's
`slot_time_step`** (`base/ShopService.php:143-160`) — a real booking-engine rule the demo lacks.
No freebie-minute roll-up (no freebies exist).

## 4. Lifecycle (active / hidden / delete)

### Demo
- `isActive(item) = item.active !== false` (`selectors.ts:106`).
- `isShownInApp = isActive && !hidden` (`selectors.ts:108`).
- `LifecycleActions` (`Services.tsx:178-260`) gives every card 4 actions: toggle visibility
  (no confirm), toggle active (confirm-on-deactivate), edit, delete (confirm). Optimistic store
  patches + toasts.
- `catalogueVisible(list, showInactive)` filters inactive unless toggled (`selectors.ts:112`).

### Ours
- Only `status` ACTIVE(1)/ARCHIVED(0) (`ShopService.php:14-15`). **No `hidden`/visible flag**,
  **no active/inactive toggle** on cards, **no showInactive filter**.
- Default `find()` hard-filters to `status = ACTIVE` (`ShopService.php:77-83`) — archived services
  are simply invisible; there is no UI to view or reactivate them.
- "Delete" = `softDelete()` → sets status ARCHIVED (`ShopService.php:114-133`), blocked if the
  service is linked to any package (`ShopServiceController.php:385-414`). The card delete button is
  the only lifecycle action (`index.php:229-241`).

## 5. Agent / specialist assignment

### Demo
Read-only on the catalogue form (`Services.tsx:1128-1147`): shows assigned specialist badges and
states assignment is managed in Team. `specialistsForService` resolves who performs a service
(`selectors.ts:118`); empty list ⇒ "Any specialist".

### Ours
**Editable here** via a Select2 multi-select `userIds` (`_form.php:372`), persisted through the
`users` M2M (`ShopServiceController.php:149-155, 257-263`). Card shows a count, not names
(`index.php:198,255-258`). This is a behavioural divergence (edit-here vs Team-managed) but
functionally richer.

## 6. Shop scoping

### Demo
Every list is `.filter(x => x.shopId === activeShopId)` (`Services.tsx:80-84`).

### Ours
`actionCreate` stamps `shop_id` from the logged-in identity (`ShopServiceController.php:147`);
`actionView/Update/Delete` call `checkOwnership($model)`. **Gap**: `actionIndex` relies on
`ShopServiceSearch` for scoping (verify it filters by `shop_id`); `findModel` itself does NOT
scope by shop (`:425-432`) — ownership is enforced separately via `checkOwnership` on view/update/
delete but NOT inside `findModel`.
