# NVG-BEA-008 — Service Bundles (Rename) & Subscription Packages — Gap Analysis + Plan

**Status: COMPLETE — started 2026-07-26, finished 2026-07-26 (W1–W6).** Live tracker at the bottom.
Spec: NVG-BEA-008 BRD (user-provided). Audited 2026-07-26 (agent, file:line evidence).
Largest BRD of the program — a net-new customer-facing purchase/redeem feature.

## Two systems (do not conflate)
- **OLD** `Package`/`UserPackage`/`PackagesService` = a multi-service BUNDLE booked once, pays
  via Paymob each time. This is what `api/` actionBookPackage uses + what Part-A renames.
- **NEW** `subscription_package` (m260623_211200; sessions/validity_days/per_session_price) =
  the Part-B session-block. Today wired to SHOP PORTAL + admin read only; **zero api/**.

## Verdict
- **Part A (rename): NOT STARTED.** Shop nav/sub-nav/titles/buttons still say "Package".
- **Part B schema/CRUD: PARTIAL.** Table + shop CRUD exist; 6 form fields missing (image,
  bilingual name, included services, eligible specialists, active days, date range).
- **Part B customer lifecycle (api / mobile CUSTOMER_APP): MISSING ENTIRELY** — no
  package_entitlement table, no Paymob purchase endpoint, no session-redemption booking
  flow (skip-payment + decrement), no My-Packages endpoint, no fee/expiry/cancel logic.

## Open question (flagged → Muhannad)
Marketing fee for multi-service packages: BRD main text says per-session value = price ÷
sessions_total. **Decision for this build: use price ÷ sessions_total** as the redemption
marketing-fee basis (documented; revisit if Muhannad rules otherwise).

## Waves
- **W1 Schema (additive):** extend `subscription_package`: `image`, `name_ar` (bilingual),
  `active_days` JSON, `start_date`/`end_date` DATE, + junctions
  `subscription_package_service` (pkg↔shop_service) and `subscription_package_specialist`
  (pkg↔agent). NEW `package_entitlement` table: id, shop_id, customer_id, package_id,
  sessions_total, sessions_remaining, expiry_date, purchase_date, price snapshots
  (price/vat/per_session/marketing-basis), status (active|expired|exhausted|cancelled),
  paymob refs, created_at. Models + relations. Migration applied + verified.
- **W2 Shop portal (Part A rename + Part B form fields):** rename Package→Service Bundle
  across `ShopNav.php`, legacy `Menu.php`, and `views/package/*` titles/buttons + i18n
  (nav group 'Services & Packages', sub-nav 'Service Bundles'). Add the 6 missing fields to
  the subscription-package create/edit form (+ services/specialist multi-selects,
  server-side shop-scope whitelist) under a clear 'Subscription Packages' sub-nav. Kartik
  Select2 idiom; JsonExpression for JSON cols.
- **W3 Purchase + fees (common + additive api):** `PackagePurchaseService` (common):
  create entitlement on paid purchase (sessions_remaining=sessions_total, expiry=purchase+
  validity_days). `FinanceLedgerService` ADDITIVE: `buildPackagePurchaseCharges` (processing
  fee on purchase + marketing fee on FULL amount, navagoo-sourced). api ADDITIVE:
  `POST /subscription-package/purchase` (Paymob full-price, actionPay-style FOR UPDATE
  idempotency; entitlement created on settle) + `GET /my-packages` (owned pkgs, remaining,
  expiry) + subscription_packages block in the shop-page payload. API_CONTRACTS. Mobile flag.
- **W4 Redemption + cancel-reinstate (api + common):** booking flow "pay with a package
  session": new booking path/param that, for an eligible service at the selling shop with an
  active entitlement, SKIPS Paymob, decrements sessions_remaining (transaction + row lock),
  stamps the booking's redemption link, and derives ONLY a marketing fee on the per-session
  value (NO processing fee) for navagoo-sourced. Exhaust → status. Customer cancel within the
  full-refund window reinstates the session (sessions_remaining++, no cash). Additive api
  action + eligibility endpoint; existing pay flow untouched.
- **W5 Expiry lifecycle (console) + admin visibility:** `package-expiry` cron: forfeit
  (status→expired) at expiry_date; 7d + 1d pre-expiry in-app notifications. Admin: entitlements
  visible in the Admin booking/payment records (read-only per BRD — "no direct management").
- **W6 Translations + tests (all 5 acceptance criteria) + smoke + docs.**

### Sequencing
W1 → W2 ∥ W3 → W4 → W5 → W6.

### Out of scope (per BRD)
Gifting/transfer · auto-renew on expiry · cross-shop redemption · automated partial refund
(admin-manual).

## Live tracker
- [x] W0 audit + this plan
- [x] W1 schema (subscription_package fields + junctions + package_entitlement) — 2026-07-26.
  Migration `m260726_160000_create_subscription_package_purchase_schema` (additive,
  idempotency-guarded): `subscription_package` +image/+name_ar/+active_days(JSON)/
  +start_date/+end_date; new `subscription_package_service`, `subscription_package_specialist`
  junctions; new `package_entitlement` (purchase record: sessions balance, expiry, price
  snapshot incl. per_session_price marketing-fee basis, status, Paymob refs). Applied +
  verified via SHOW COLUMNS. Models: base+concrete `SubscriptionPackage` (new
  getServices()/getSpecialists() relations, serviceIds()/specialistIds(),
  activeDayInts()/setActiveDayInts() JsonExpression-safe helpers), new
  `SubscriptionPackageService`/`SubscriptionPackageSpecialist` (thin junctions), new
  `PackageEntitlement` (STATUS_* consts, relations, isRedeemable(),
  decrementSession()/reinstateSession() instance mutators). No api/frontend/backend/i18n
  edits (schema+models only, per scope). Functionally smoke-tested in a bootstrapped
  console app (relations, JSON round-trip, session decrement/reinstate transitions).
- [x] W2 shop portal rename + form fields — 2026-07-26. **Part A (rename, Package →
  Service Bundle, standalone /package system only):** `ShopNav.php` sidebar item label
  + legacy `Menu.php` nav group ('Packages / Services' → 'Services & Packages') / sub-item
  label; `views/package/{index,view,create,update,_form,_detail}.php` titles, breadcrumbs,
  'Create/Edit/New Service Bundle' buttons, section headers; `PackageController` flash
  messages. `shop-service/index.php` catalogue "Packages" tab relabelled 'Subscription
  Packages' (+ 'Add subscription package') to disambiguate from the now-renamed Service
  Bundles nav item; card thumbnail now shows the SubscriptionPackage image when set. New
  `Package`/`SubscriptionPackage` (old bundle-booked-once) system untouched — only labels.
  **Part B (subscription-package form, 6 missing fields):** `subscription-form.php` +
  `PackageController::saveSubscription()`. Image: trntv Upload widget bound to the single
  `image` column (W1 has no image_path/image_base_url split) — `resolveUploadedImage()`
  normalizes the widget's posted `{path,base_url}` into one string, preserving the
  existing value when the widget wasn't touched (verified against the actual
  yii2-file-kit upload-kit.js posted shape). name_ar: plain bilingual text field (reuses
  W1's `Name (Arabic)` attribute label). Included services / eligible specialists:
  standalone (non-`$form->field()`) kartik Select2 widgets posting
  `SubscriptionPackage[serviceIds][]` / `[specialistIds][]` — junction-backed, so the
  controller reads the raw POST and whitelists ids to the shop's own `shop_service` /
  `user_type=AGENT` rows (mirrors `savePackageWithRelations()`'s IDOR guard) before a
  transactional replace of `subscription_package_service`/`_specialist` (batch INSERT;
  `created_at`/`updated_at` are INT unix timestamps on these junctions — NOT DATETIME
  like the legacy `packages_service` table, caught via a live round-trip test that
  surfaced a `NOW()`-into-INT overflow and was fixed to `time()`). Active days: reuses
  W1's `activeDayInts()`/`setActiveDayInts()` JsonExpression helpers, bound via
  `$form->field($model,'active_days')` (Yii auto-decodes the JSON column to an array).
  start_date/end_date: kartik DatePicker, empty string normalized to NULL before save
  (DATE columns reject '' in strict mode). 15 new bilingual keys added to
  `common/messages/{en,ar}/backend.php` (2 more to `frontend.php` for the flash
  messages); `Service Bundles`/`Subscription Packages` reused pre-existing keys. i18n-check
  hook enforced pairing on every edit; `php -r` parity check confirms 0 missing-in-ar
  across both files. **Verify:** `php -l` clean on all 15 touched files; browser smoke via
  curl+cookie-jar login (Beautycenter123@123.com) — `/package`, `/package/subscriptions`,
  `/package/subscription-create`, `/shop-service?tab=packages` all 200, renamed strings
  present, all 6 new POST field names present in the rendered form, no PHP
  errors/warnings in any response body. Round-trip: created a SubscriptionPackage via
  raw POST with 2 services + 2 specialists + active_days + date range + Arabic name
  (image upload not exercised — multipart file upload out of scope for a curl smoke
  test; the resolve logic was verified by reading the vendor upload-kit.js source for
  the exact posted shape) → DB SELECT confirmed `name_ar`/`active_days`/`start_date`/
  `end_date` and both junction tables populated correctly (agent-id whitelist correctly
  rejected a non-agent posted id) → deleted (soft-archive action) + hard-deleted the
  test rows via SQL to leave the dev shop clean.
- [x] W3 purchase + fees + api (additive) — 2026-07-26. `PackagePurchaseService`
  (new, `common/components/`): `createEntitlementFromPaidPurchase(SubscriptionPackage,
  customerId, paymentRefs)`, transactional, idempotent per `paymob_tran_ref`
  (`SELECT ... FOR UPDATE` on `package_entitlement`, mirrors actionPay's idiom
  against `payment` — no `payment` row is minted for a package purchase, the
  entitlement itself is the purchase record). `FinanceLedgerService` ADDITIVE
  (appended at end of class, nothing above touched): `buildPackagePurchaseCharges
  (PackageEntitlement, Shop, classification)` — processing fee on the purchase
  amount (reuses `processingRatePct`/`processingFixed`) + marketing fee on the
  FULL package price when navagoo-sourced (`noVat` basis, `minMarketingFee`
  floor, per the plan's documented decision), both stamped `booking_id = NULL`
  (mirrors the existing `TYPE_SUBSCRIPTION` shop-level-charge convention in
  `SubscriptionBillingController::writeSubscriptionCharge`) tagged via
  `meta.entitlement_id` (`JsonExpression`) for idempotency + audit, guarded
  against double-stamping. api ADDITIVE: new `SubscriptionPackageController`
  (`POST /subscription-package/purchase` — Paymob verify via
  `PaymobPaymentHelper::getPaymentStatus` + amount-cents guard + FOR UPDATE
  idempotency, mirrors `GroupBookingController::actionPay`; `GET /my-packages`
  — owned entitlements, `with()` eager-loaded, no N+1); routes added to
  `_CustomerUrls.php` only; `ShopsController::actionView` gained ONE additive
  `subscription_packages` key (active + in-window packages + included
  services), every existing key byte-identical. `git diff --stat api/` = only
  `_CustomerUrls.php` + `ShopsController.php` (+ the new controller file,
  untracked). Verified: `php -l` clean on all 5 touched files; phpcs
  (PSR12) 0 errors; functional smoke in a rolled-back-transaction console
  script (entitlement mint, tran_ref replay idempotency, charge-build
  idempotency, processing/marketing math, `noVat` basis) — all assertions
  passed; unauth curl `POST /subscription-package/purchase` → 401, `GET
  /my-packages` → 401, `GET /shops/1` → 200 with `subscription_packages` key
  present (empty — no packages in the current DB fixture; W2 hasn't shipped
  the create form yet). `FinanceLedgerServiceTest` (43/43) + `PromoCodeServiceTest`
  (37/37) green, 0 regressions. New `Yii::t('backend', …)` keys for W6:
  "Package not found or not available.", "This package is not available right
  now.", "The paid amount does not cover the package price.", "Could not
  complete the purchase. Please try again." (mirrors of existing untranslated
  strings already used by `GroupBookingController`/`BookingController` in this
  same style — not newly added to message files per this wave's scope).
  API_CONTRACTS.md §3.5 added. No frontend/backend edits.
- [x] W4 redemption + cancel-reinstate — 2026-07-26. Migration
  `m260726_170000_add_package_entitlement_id_to_booking` (additive, idempotency-
  guarded): NEW nullable `booking.package_entitlement_id` int (no FK, indexed).
  Deliberately NOT reusing the existing `booking.package_id` column — that's the
  unrelated OLD `Package`/`UserPackage` bundle system, read in several
  api/common places as an id into the `package` table; stamping an entitlement id
  into it would have silently corrupted those reads. Applied + verified.
  `common\models\base\Booking` gained `PAYMENT_MODE_PACKAGE = 'package'` +
  `package_entitlement_id` in the integer rule/docblock; concrete
  `common\models\Booking` added it to the `payment_mode` validation range +
  `getIsPackageRedemption()` / `getPackageEntitlement()` relation.
  `api\controllers\SubscriptionPackageController` gained two NEW actions: `GET
  /subscription-package/redeemable` (ONE join query on
  `subscription_package_service`, re-checks `isRedeemable()` per row) and `POST
  /subscription-package/redeem` — the whole redemption (re-verify entitlement
  `FOR UPDATE` + shop/service/specialist eligibility + the SAME `agent_slots`
  `FOR UPDATE` conflict query `actionBook` uses + booking/BookingService/
  AgentSlots creation + `decrementSession()` + the redemption marketing charge)
  in ONE transaction, all-or-nothing. Routes added to `_CustomerUrls.php`
  (`only` extended; existing `purchase` rule untouched).
  `FinanceLedgerService` ADDITIVE (appended at end of class, nothing above
  touched): `buildPackageRedemptionCharges()` (marketing fee ONLY, basis =
  `noVat(entitlement.per_session_price)`, i.e. price÷sessions per the plan's
  documented decision, gated `navagoo_sourced`, stamped `UNPAID` immediately,
  idempotent per booking, booking_id SET this time — unlike the purchase-charge
  meta-only convention, a redemption has a real booking row) + NO processing fee
  ever (nothing collected online this booking) +
  `reversePackageRedemptionCharge()` (reuses the existing `buildReversal()`
  100%-offset idiom, guarded against double-reversal). Cancel-reinstate: ONE
  additive `if` branch inside `api\controllers\BookingController::
  actionUpdateStatus()`'s pre-existing customer-cancel block + a NEW private
  `reinstatePackageRedemption()` helper (best-effort — logs and never blocks the
  cancel) — gated on `FinanceLedgerService::refundZoneFor() === FULL_REFUND` (no
  partial-reinstatement concept), calls `reinstateSession()` under its own
  `FOR UPDATE` lock + `reversePackageRedemptionCharge()`, own short transaction.
  `api\resources\BookingResource`'s `payment_message` field gained ONE new `if`
  branch for `PAYMENT_MODE_PACKAGE` (avoids the misleading "Paid in full
  online." on a redemption booking) — every other branch byte-identical.
  API_CONTRACTS.md §3.5.1 added. **Verify:** `php -l` clean on all 8 touched/new
  files; unauth curl `GET /subscription-package/redeemable` → 401, `POST
  /subscription-package/redeem` → 401 (resolve, not 404/500);
  `FinanceLedgerServiceTest` (43/43) + `PromoCodeServiceTest` (37/37) green, 0
  regressions; functional smoke in a rolled-back-transaction console script
  (seeded shop_service/package/entitlement(sessions=2) + forced
  `navagoo_sourced` classification) — redeem #1 → sessions_remaining=1, ONE
  marketing charge stamped (basis=noVat(per_session_price), confirmed NO
  processing-fee row), idempotent rebuild returns `[]`; redeem #2 →
  sessions_remaining=0 + status flips EXHAUSTED, isRedeemable() false;
  cancel-in-window (shop refund policy forced full-refund, refundZoneFor
  confirmed FULL_REFUND) → reinstateSession() sessions_remaining=1 + status back
  to ACTIVE, reversePackageRedemptionCharge() returns a 100%-offsetting negative
  charge pointing at the original via `reversal_of_id`, re-calling it is a no-op
  (no double-reversal), booking #2's own charge confirmed untouched by booking
  #1's reversal; ALL assertions passed, transaction rolled back, 0 rows leaked
  (verified via follow-up SELECTs). git diff --stat api/ = `_CustomerUrls.php` +
  `BookingController.php` + `BookingResource.php` (+ the new `_ShopsController.php`
  W3 change, pre-existing) + new `SubscriptionPackageController.php` actions —
  no existing route/action/response shape changed. New `Yii::t('backend', …)`
  keys for W6: "Package not found or not available.", "This package has no
  sessions left, or has expired.", "This service is not included in the
  package.", "This specialist does not offer the selected service.", "This
  specialist cannot redeem this package.", "Could not create the booking.
  Please try again.", "Could not allocate slot.", "Could not complete the
  redemption. Please try again." + one `Yii::t('frontend', …)` key ("Paid using
  a package session.") matching `BookingResource`'s existing category for that
  field.
- [x] W5 expiry console + admin visibility (console-only scope) — 2026-07-26. New
  `console/controllers/PackageExpiryController.php` (`error_reporting` E_DEPRECATED
  mask in `init()`, matches FreezeReminderController/SubscriptionBillingController):
  `actionForfeit` — every `status=active` entitlement with `expiry_date < today` →
  `status=expired` (query itself is the idempotency guard: an already-expired row
  never matches again; forfeits whatever `sessions_remaining` is left, no cash,
  no proration — only a W4 within-window cancellation reinstates a session).
  `actionNotify` — two exact-date windows (`expiry_date = today+7`, `expiry_date =
  today+1`), `status=active AND sessions_remaining>0` only, one in-app
  {@see \common\models\Notifications} row per entitlement per window
  (`topic=public_customer`, `to_id=customer_id`, `module=package_entitlement`,
  `module_id=entitlement id`); de-duped via a lookup on
  `(module, module_id, key_id)` before creating — `key_id` uses two provisional
  local constants (`NOTIF_KEY_EXPIRING_7D=8036` / `_1D=8037`) rather than new
  `Notifications::TYPE_*` constants, since W5's scope excludes `common/models`
  edits (W1 owns that file); a later wave can promote these into real constants
  if the mobile client ever needs to branch on them. No push/FCM — in-app row
  only, per the task ("send an in-app notification"), via a direct
  `Notifications` insert (not the booking-scoped `NotificationDispatchService`/
  `NotificationTrigger` machinery, which is booking- and admin-approval-gated
  and doesn't fit a non-booking customer-lifecycle event without new schema).
  `actionRun` = forfeit → notify (an entitlement expiring exactly today is
  forfeited before the same day's notify pass, so it's never double-counted).
  `--dry=1` on every action (never writes/notifies), matching the sibling
  commands' contract. `console/config/schedule.php`: appended
  `package-expiry/run` `->daily()->withoutOverlapping()` to BOTH the qc and
  prod blocks (re-read fresh immediately before editing; file was unchanged by
  other agents at edit time). **Admin visibility: SKIPPED, deferred nicety.**
  The only existing admin surface that could naturally host this
  (`backend/views/shop/_tabs.php` + the shop detail-tab family —
  `subscriptions.php`/`plans.php`/`offers.php`/`payment_methods.php`) is a
  *different* concept (`ShopSubscription`, shop→Navagoo SaaS billing, wired via
  `ShopController`), not `package_entitlement` (customer→shop purchases); there
  is no existing read-only surface for the latter. Adding one cleanly needs a
  new controller action + route + view + a new tab registered in the shared
  `_tabs.php` (edited by multiple shop-admin waves) — real scope creep and file
  contention for an "optional, low-risk" ask per the task brief, so it was
  skipped in favor of a future small wave (natural home once W4 lands: stamp
  entitlement info onto the booking/charge admin records the redemption
  produces, which the admin already views). **Verify:** `php -l` clean on both
  touched files; `vendor/bin/phpcs` 0 errors on both (only pre-existing-style
  line-length + const-visibility warnings, same as every sibling console
  controller). `--dry=1` run against the live dev DB: clean 0/0/0/0 summary (no
  entitlements exist yet — W2/W3 shipped the purchase/CRUD plumbing but no real
  customer purchase has been made in this DB). Functional proof: seeded 3
  `package_entitlement` rows via raw SQL (no FK constraints on the table,
  verified via `information_schema.KEY_COLUMN_USAGE`) — one `expiry_date`
  yesterday/active/`sessions_remaining=2`, one `expiry_date=+7d`/active/3
  remaining, one `expiry_date=+1d`/active/1 remaining — `--dry=1` correctly
  identified all 3; a real (non-dry) `run` flipped the yesterday-expiry row to
  `expired` (sessions_remaining left untouched at 2, confirming forfeit-not-
  refund) and created 2 `notifications` rows with correct `key_id`/`to_id`/
  bilingual message content (UTF-8 em-dash verified via
  `--default-character-set=utf8mb4`, a `mysql` CLI charset display artifact,
  not a bug); a second `run` immediately after was a clean no-op
  (`forfeited=0`, both notify windows `skipped` on the dedupe check) proving
  idempotency; all 3 seeded `package_entitlement` rows + both `notifications`
  rows were hard-deleted via SQL afterward, verified 0 remaining. New
  `Yii::t('frontend', …)` keys for W6: "Your package is expiring soon",
  "Your package \"{package}\" at {shop} expires tomorrow — you have {sessions}
  session(s) left. Use them before they expire.", "Your package \"{package}\"
  at {shop} expires in {days} days — you have {sessions} session(s) left. Use
  them before they expire.", "Subscription Package" (fallback package-name
  string, `frontend` category). No `common/models`/`common/messages`/`api`/
  `frontend`/`FinanceLedgerService` edits — console + schedule.php only, per
  scope.
- [x] W6 translations + tests + verify — 2026-07-26. **Translations:** tokenized every
  `Yii::t()` call (PHP `token_get_all`, handles both `Yii::t(` and `\Yii::t(`) across
  every BEA-008 file (all `frontend/views/package/*`, `subscription-form.php`,
  `PackageController.php`, `shop-service/index.php`, `ShopNav.php`, `Menu.php`,
  `common/components/{PackagePurchaseService,FinanceLedgerService}.php`,
  `common/models/{PackageEntitlement,SubscriptionPackage,SubscriptionPackageService,
  SubscriptionPackageSpecialist,Booking,base/*}.php`, `api/controllers/
  {SubscriptionPackageController,BookingController,ShopsController}.php`,
  `api/resources/BookingResource.php`, `console/controllers/PackageExpiryController.php`)
  and cross-checked every key against `common/messages/en/{backend,frontend,common,
  shop}.php`. 23 keys were genuinely missing (the W1–W5 waves' own notes only listed
  the strings THEY were aware of; this pass is the authoritative full sweep): 18
  `backend` (8 `PackageEntitlement::attributeLabels()` labels W1 never i18n'd since
  that wave explicitly excluded message-file edits, + 10 `SubscriptionPackageController`
  error strings from W3/W4) and 5 `frontend` (1 `BookingResource`
  `payment_message` branch from W4 + 4 `PackageExpiryController` notification
  strings from W5). Added real Arabic to both `ar/backend.php` and `ar/frontend.php`
  in one W6-tagged block per file, `{placeholder}` tokens kept verbatim. The
  `i18n-check` PostToolUse hook enforced bilingual pairing on every edit (caught and
  blocked the first en-only backend/frontend writes as expected). Final parity:
  en/ar backend 3413/3413 (+18), en/ar frontend 1952/1952 (+5); `php -l` clean on
  all 4 message files. (The `shop`-category strings inside the modified
  `shop-service/index.php` were all pre-existing keys already in `shop.php` —
  false positives from the initial grep, confirmed via the same cross-check and
  excluded.)
  **Tests:** new `common/tests/unit/components/SubscriptionPackageTest.php` (9
  tests, 81 assertions, GroupBookingServiceTest/PromoCodeServiceTest style —
  DB-backed, real dev-DB shop/service/customer/agent fixtures, an outer
  transaction always rolled back). Covers all 5 BRD acceptance criteria through
  the REAL production code (never re-implemented math):
  AC1 (`PackagePurchaseService::createEntitlementFromPaidPurchase` — sessions_remaining
  = sessions_total, expiry_date = purchase + validity_days, + a tran_ref-replay
  idempotency test); purchase charges (`FinanceLedgerService::
  buildPackagePurchaseCharges` — processing fee always + marketing fee on the
  FULL price only when `navagoo_sourced`, verified against the SAME `noVat`/
  `marketingRatePct`/`minMarketingFee` accessors production uses, not
  hand-duplicated arithmetic; shop_owned → processing fee only; idempotent
  replay → `[]`); AC2/AC3 (`buildPackageRedemptionCharges` — exactly ONE
  marketing-fee charge on `noVat(per_session_price)`, confirmed NO processing-fee
  row ever, `booking_id` set (unlike the purchase charge's meta-only
  convention), idempotent per booking; `decrementSession()` → sessions_remaining−1,
  redeem-to-zero flips EXHAUSTED + `isRedeemable()` false + further decrement is a
  safe no-op); AC4 (`PackageExpiryController::actionForfeit()` called DIRECTLY —
  it's a public action and the Codeception unit bootstrap already boots a console
  `Yii::$app`, so no reflection was needed — past-expiry active entitlement →
  expired with `sessions_remaining` untouched; future-expiry entitlement left
  alone, pinning the `status=active AND expiry_date < today` predicate exactly);
  AC5 (two real redemption bookings consume both sessions of a 2-session package,
  then `reinstateSession()` on the cancelled one → sessions_remaining+1 + EXHAUSTED
  back to ACTIVE, `reversePackageRedemptionCharge()` → a `reversal_of_id`-linked
  100%-offsetting negative charge — mirrors `api\controllers\BookingController::
  reinstatePackageRedemption()`'s exact orchestration without touching api/ code —
  the sibling booking's own charge is confirmed untouched, and re-calling the
  reversal is confirmed a no-op). One production fixture gap found and fixed
  IN THE TEST ONLY (not a real bug): `booking.agent_id` is FK-constrained to
  `user.id`, so the fixture booking needed a real agent row, not a placeholder —
  same fixture pattern GroupBookingServiceTest already uses. Full suite:
  447 tests / 1125 assertions, 6 errors / 15 failures — byte-identical to the
  pre-existing baseline (`OtpVerificationRateLimitTest`, `AuroraTest`,
  `CalendarFormatTest`, `SmsLogTest`, `TokenExpirationTest` — all pre-existing,
  none touch BEA-008 code), confirming 0 regressions from the new suite.
  **Smoke:** console `php yii package-expiry/run --dry=1` → clean 0/0/0/0
  against the live dev DB. api unauth (curl, no bearer): `GET /my-packages` →
  401, `GET /subscription-package/redeemable` → 401, `POST
  /subscription-package/redeem` → 401, `POST /subscription-package/purchase` →
  401 (all resolve, not 404/500); `GET /shops/1` → 200 with both
  `subscription_packages` and `active_deals` keys present (empty arrays — no
  packages purchased in the dev DB yet). frontend (curl + cookie-jar login,
  `Beautycenter123@123.com`, `LoginForm[username]`/`LoginForm[password]` —
  note the model is `LoginForm`, not `SignInForm` as an earlier session memory
  claimed): `/package` → 200 with "Service Bundle(s)" strings present; `/package/
  subscription-create` → 200 with all 6 new POST field names present
  (`SubscriptionPackage[image]`, `[name_ar]`, `[serviceIds]`, `[specialistIds]`,
  `[active_days]`, `[start_date]`/`[end_date]`); no PHP fatal/parse/warning/notice
  strings in either response body.
  **No production code was touched** — the test suite exercised the real W1–W5
  code as-is and found no bug requiring a fix.
  **Docs:** this plan's status header flipped to COMPLETE; `ai_specs/05_PLANS/
  HANDOVER.md` got the full BEA-008 completion section (all 6 waves, the two
  schema decisions, the marketing-fee-per-session open question flagged to
  Muhannad, the api-additive mobile-contract flag, the deferred admin-visibility
  nicety, and the BRD out-of-scope list).
