# Shop · Packages / Bundles — Business Rules

Numbered, implementable rules the demo encodes, each with its demo ref and our status.

## Entity model

1. **Two entities exist.** A `ServiceBundle` = services bought together once
   (`serviceIds`, `activeDays`, price/discount). A `SubscriptionPackage` = N pre-paid
   `sessions` valid for `validityDays`, optionally `autoRenew` on a `billingPeriod`.
   *(types.ts:229-263)* — Ours: only one `Package` ≈ Bundle. **SubscriptionPackage
   missing.**

2. **Shop-scoping.** Bundles/packages are filtered to the current shop on every read
   (`b.shopId === shop.id`). *(Services.tsx:83-84,369,430)* — Ours: enforced via
   `andFilterWhere(['shop_id' => current])` *(PackageController.php:71)* and ownership
   checks `checkOwnership($model)` on view/update/delete/toggle. **Done.**

## Validation (create/edit)

3. **Name is required** (English name must be non-empty). *(Services.tsx:765)* — Ours:
   `name` required *(base/Package.php:105)*. **Done.**

4. **Base price must be > 0.** Save is blocked otherwise. *(Services.tsx:766)* —
   Ours: `price` required + numeric *(base/Package.php:89,105)*, but **no `> 0`
   guard** (0 / negative could pass server validation). **Partial.**

5. **A bundle/package must include at least one service.** *(Services.tsx:763,767 —
   `needsServices`)* — Ours: `servicesIDs` is `safe`, **not required**; a package with
   zero services can be saved (index even has a "No services yet" empty card,
   `index.php:206-210`). **Missing.**

6. **Sessions ≥ 1, validity ≥ 1** (number inputs `min=1`; defaults 1 / 30 on submit).
   *(Services.tsx:814-815,1047,1050)* — Ours: **N/A (no fields).** **Missing.**

## Pricing / discount / VAT

7. **Discount types: none / fixed / percent.** Fixed → `max(0, base-value)`; percent →
   `base*(1 - clamp(value,0,100)/100)`; round to 2 dp. *(lib/finance.ts:43-51)* —
   Ours: percent (type 1) / fixed (type 2) computed in JS *(\_form.php:525-541)* and
   authoritatively on the server VAT endpoint. Percent is **not clamped to 100** on our
   side. **Partial.**

8. **Prices are VAT-inclusive; VAT is split out.** `base = price/(1+vatPct/100)` when
   the shop is VAT-registered, else base=price; `vat = price-base`. *(finance.ts:32-33,
   67-70)* — Ours: delegated to `GET /shop-service/calculate-vat` (shared service);
   `price_before` "must include VAT" hint *(\_form.php:239,270)*. **Done (server-side).**

9. **Effective price = after-discount price; the card may also show the list price**
   (Σ member service prices) struck through to convey savings (bundle). Savings amount
   + percent surfaced in the editor. *(Services.tsx:377,415,758-759,984-988)* — Ours:
   shows only the stored `price`; **no list-price/savings**. **Missing.**

10. **Per-session price = round2(price / sessions)** shown on package cards.
    *(finance.ts:654-655; Services.tsx:466-470)* — Ours: **N/A.** **Missing.**

11. **Discount-zeroes warning:** if a discount drives the final price to ≤ 0, warn
    instead of silently selling for free. *(Services.tsx:760,989-991)* — Ours: none.
    **Missing.**

## Lifecycle / status

12. **Active vs inactive:** `active !== false` ⇒ active. Inactive items are hidden from
    the shop catalogue list unless "Show inactive" is on. *(selectors.ts:106,112-113)*
    — Ours: `STATUS_ACTIVE=1 / STATUS_ARCHIVED=0` *(Package.php:31-32)*; toggle action
    flips them *(PackageController.php:226-249)*. **No "show inactive" filter** — both
    statuses always listed. **Partial.**

13. **Visible-in-app (hidden) flag is separate from active:** customer app shows an item
    only when `active && !hidden`. *(selectors.ts:108-109)* — Ours: **no `hidden`
    flag** (only active/archived). **Missing.**

14. **Delete removes the item from the catalogue.** *(store.ts:1841,1846)* — Ours:
    `delete()` is overridden to **archive (soft-delete)** *(Package.php:74-77)* — a
    deliberate, safer divergence. **Done (divergent, acceptable).**

## Scheduling

15. **Bundle availability can be limited to specific weekdays** (`activeDays`).
    *(types.ts:241)* — Ours: `active_days` CSV. **Done**, and ours **adds**
    `start_date`/`end_date` + a runtime `isScheduledActive()` gate
    *(Package.php:37-61; m260218_184500…:15-17)* — **NEW on our side / richer.**

16. **Auto-renew / billing period** (monthly / 6mo / 12mo) applies only to
    SubscriptionPackages and only when `autoRenew` is on. *(Services.tsx:817,1067-1085)*
    — Ours: **N/A.** **Missing.**

## Attachments & relations

17. **Bundles/packages bundle services via a join**; in the demo `serviceIds[]`, in
    ours the `packages_service` join table rebuilt on every save
    *(PackageController.php:122-129,184-192)*. **Done.**

18. **Agents can deliver a package**; in the demo this is **read-only here and managed
    in Team** *(Services.tsx:1127-1146)*, in ours it is **editable** via the agents
    Select2 and stored on the `package`↔`user` link *(\_form.php:303-316;
    PackageController.php:115-121,177-183)*. **Divergent.**

19. **Free routines / add-ons can be attached** (before/after/free) and add minutes.
    *(Services.tsx:1089-1125; selectors.ts:84-99)* — Ours: **no freebie attachment.**
    **Missing.**

## Period

20. **(Ours, no demo equivalent)** A package `period` = Σ `service_period` of included
    services, auto-computed *(PackageController.php:266-303; \_form.php:58-83)*. The
    demo derives total duration per service from attached freebies instead; there is no
    single package "period" field. **NEW on our side.**
