# Shop · Packages / Bundles — Logic Parity

Canonical target: React+TS demo at `/private/tmp/Navagoo_MI_dev/navagoo-app/src`.
Our implementation: Yii2 advanced template (frontend = shop portal).

## 0. CRITICAL STRUCTURAL FINDING

The demo splits this area into **TWO distinct catalogue entities**, surfaced as two
separate tabs on the Services page:

| Demo entity | Tab | Defining fields | Purpose |
|---|---|---|---|
| `ServiceBundle` | "Bundles" | `serviceIds[]`, `activeDays[]`, price/discount | A discounted set of services bought together once. |
| `SubscriptionPackage` | "Packages" | `serviceIds[]`, `sessions`, `validityDays`, `autoRenew`, `billingPeriod`, price/discount | A pre-paid block of N sessions valid for D days, optionally auto-renewing. |

Types: `types.ts:229-263`. Tabs + grids: `portals/shop/Services.tsx:131-149,364-475`.

**Our app has only ONE entity** — `common\models\Package` — which is effectively a
**Bundle** (services + scheduling: `active_days`, `start_date`, `end_date`). It has
**no concept of sessions, validity window, auto-renew, or billing period**. Confirmed:
- `common/models/base/Package.php:30-50` (property list — no sessions/validity/renew).
- Schema columns: `m231018_190030_create_packages_table.php` + scheduling added in
  `m260218_184500_add_scheduling_to_package_table.php:15-17` (only `active_days`,
  `start_date`, `end_date`).

So our single `Package` is a partial Bundle. The demo's **SubscriptionPackage is
entirely missing** on our side. The demo's Bundle `activeDays` ≈ our `active_days`,
but the demo Bundle has **no** `start_date`/`end_date` (those are our additions) and
the demo Package has **no scheduling at all**.

## 1. Listing / visibility flow

**Demo** (`Services.tsx:364-475`):
- `BundlesGrid` / `PackagesGrid` filter `state.bundles|packages` by `shopId`
  (shop-scoping), then `catalogueVisible(list, showInactive)` (`store/selectors.ts:112`)
  hides `active===false` rows unless the "Show inactive" toggle is on (`:135-138`).
- `isActive(item) = item.active !== false` (`selectors.ts:106`). `isShownInApp =
  active && !hidden` (`:108-109`) — inactive auto-hides from the customer app.
- Bundle card derives **list price** = Σ of member service prices and shows it
  struck-through next to the bundle's effective price (`Services.tsx:377,415-416`).
- Package card derives **price per session** = `pricePerSession(price, sessions)` =
  `round2(price/sessions)` (`lib/finance.ts:654-655`; rendered `Services.tsx:466-470`).

**Ours** (`PackageController::actionIndex` + `views/package/index.php`):
- `PackageSearch` + `andFilterWhere(['shop_id' => current shop])` → shop-scoped
  (`PackageController.php:71`). Default sort `id DESC` (`:68-70`).
- No "show inactive" toggle — the grid shows whatever the search returns (both
  statuses appear). `index.php:112` computes `$isActive` only to render the chip.
- Card shows price chip + included-service count + first 4 service names
  (`index.php:176-205`) and a scheduling summary ("Always Available" when no
  day/date constraints) (`index.php:60-85`). **No list-price strikethrough**, **no
  per-session price** (concept absent).

Gaps: no `hidden`/visible-in-app flag; no per-session math; no list-price savings;
inactive rows are not auto-filtered (shown inline with a red chip instead).

## 2. Create / Edit flow

**Demo** (`CatalogueModal`, `Services.tsx:665-1157`): a single modal reused for
service/bundle/package, branching on `view`. It seeds local state from the edit
target (`:709-731`), renders bilingual name+desc, image, variants, a pricing block,
type-specific detail fields, freebies (routines/add-ons), and a read-only agents
section, then calls the matching store action on submit (`:791-824`).

- `submit()` for **bundles** (`:802-809`): payload `{ ...pricing, serviceIds: picked,
  activeDays: existing or ALL_DAYS }` → `addBundle`/`updateBundle`.
- `submit()` for **packages** (`:810-821`): payload `{ ...pricing, serviceIds,
  sessions, validityDays, autoRenew, billingPeriod: autoRenew ? period : undefined }`
  → `addPackage`/`updatePackage`.
- Store actions are pure array upserts with generated ids (`store/store.ts:1838-1846`).

**Ours** (`PackageController::actionCreate/actionUpdate` + `views/package/_form.php`):
- Real Yii `ActiveForm` posting `Package[...]`. On save: encodes `active_days` to CSV
  (`PackageController.php:113,175`), `saveAll()`, then re-links `users` (agents) and
  rebuilds `PackagesService` join rows (`:115-129` create / `:177-192` update — update
  deletes all join rows then re-inserts). Redirect to index.
- Form fields (`_form.php`): image, name (multilang), **period** (auto-filled),
  price_before, discount + discount_type, price (readonly, VAT-inclusive),
  servicesIDs (Select2 multi), userIds/agents (Select2 multi), active_days (Select2),
  start_date/end_date (DatePicker), status dropdown.
- **No sessions / validity / autoRenew / billingPeriod fields** (entity absent).
- **No variants, no bilingual description, no freebies (routines/add-ons), no
  discount-savings feedback.** Agents here are **editable** (demo says agents are
  managed Team-side and read-only here, `Services.tsx:1127-1146`).

## 3. Pricing / discount / VAT computation

**Demo** (`lib/finance.ts`):
- `applyServiceDiscount(base, type, value)` (`:43-51`): `fixed` → `max(0, base-value)`;
  `percent` → `base*(1 - clamp(value,0,100)/100)`; rounded to 2 dp.
- `vatBreakdown(price, vatRegistered, vatPct)` (`:67-70`): treats price as
  VAT-**inclusive**; `base = price/(1+vatPct/100)` when registered (`:32-33`), else
  base=price; `vat = price - base`. `vatPct` comes from `state.config.vatPct`,
  `vatRegistered` from the shop.
- Modal shows three live cards: before-discount (VAT split), discount selector
  (none/fixed/percent icon radios), after-discount (final price + savings + VAT split)
  (`Services.tsx:752-760, 898-997`). Discount-zeroes warning when final ≤ 0 (`:760,989`).

**Ours**:
- Discount/VAT computed **client-side in `_form.php`** + a **server AJAX**:
  - `recomputeAmountAfter()` (`_form.php:525-541`): type `'1'`=percent
    (`base*(1-d/100)`), `'2'`=fixed (`base-d`). Note: demo uses string labels
    `'fixed'/'percent'`; ours uses `ShopService` constants 1=PERCENTAGE, 2=FIXED
    (`ShopService.php:16-17`) — the JS reads `'1'`=percent / `'2'`=fixed, consistent.
  - `updateVatSummary()` (`_form.php:543-581`) calls `GET /shop-service/calculate-vat`
    with total/discount/discountType → server returns subtotal/vat/net_total and the
    readonly `#model-price` is set to `net_total`. So VAT is **server-authoritative**
    via the shared ShopService VAT endpoint (good — single source of truth).
- `period` is summed server-side: `actionGetPeriod` / `actionCalculatePeriod`
  (`PackageController.php:266-303`) Σ `service_period` of selected services; AJAX fills
  `#package-period` on service change (`_form.php:58-83`).

Parity on discount/VAT math: **equivalent** (ours defers to the same VAT service the
rest of the app uses). The demo additionally shows a savings amount + percent and a
"discount zeroes the price" warning that we don't surface.

## 4. Scheduling / "active days"

- **Demo Bundle** has `activeDays: string[]` only (`types.ts:241`); no date range.
  There is no runtime `isScheduledActive` check shown in the catalogue UI.
- **Ours** has `active_days` (CSV) **plus** `start_date`/`end_date`, and a real
  runtime check `Package::isScheduledActive($date)` (`Package.php:37-61`) used by the
  booking flow to decide whether a package is bookable on a given date. This is
  **richer than the demo** (NEW on our side, kept from the legacy app).

## 5. Delete / status transitions

- **Demo**: `deleteBundle`/`deletePackage` hard-remove from the array
  (`store.ts:1841,1846`); a toast confirms. Active/hidden toggles happen via
  `LifecycleActions` → `updateBundle/Package(patch)` (`Services.tsx:393,456`).
- **Ours**: `actionDelete` calls `$model->delete()` which is **overridden to archive**
  (soft-delete → `status = ARCHIVED`) (`Package.php:74-77, 68-72`). `actionToggleStatus`
  flips ACTIVE↔ARCHIVED (`PackageController.php:226-249`). So our "delete" is a soft
  archive, not a removal — a deliberate, safer divergence. There is **no separate
  `hidden`/visible-in-app flag** (demo distinguishes inactive vs hidden).

## 6. Bilingual i18n

- Demo carries `nameAr`/`descriptionAr` on every entity (`types.ts:233-235,249-251`).
- Ours uses `MyMultiLanguageActiveField` for `name` only (`_form.php:201-212`); there
  is **no package description field at all**, so description bilingual parity is N/A
  but the missing description itself is a gap.
