# NVG-BEA-009 — In-App Deals & Promotions — Gap Analysis + Implementation Plan

**Status: COMPLETE — started + finished 2026-07-26 (W1–W5, same day).** Live tracker at the bottom.
Spec: NVG-BEA-009 BRD (user-provided). Audited 2026-07-26 (agent, file:line evidence).

## Verdict: foundation real, constraints absent, enforcement dead

### DONE
- `promo_code` table w/ `shop_id` scoping + `max_uses/uses/remaining_uses` + status;
  **shop-scoping IS enforced at redemption** (BookingForm checks shop_id — BRD's
  cross-shop confirmation ✓).
- Shop portal Marketing CRUD (`frontend/PromoCodeController`) — basic fields.
- **Customer discovery endpoint already exists**: `GET /shops/deals`
  (api ShopsController::actionDeals) with structured deal feed + shop block.
- **Marketing fee already on the discounted amount** ✓ (bookingValue reads post-discount
  `total_amount` → BEA-005 basis; BRD rule satisfied by construction).
- Admin: read-only Marketing hub metrics (platform-wide codes only) + plain CRUD.
- Demo reference: `Promotions.tsx` + promotions-hardening design doc ≈ field-for-field
  match — mined for UI/validation idiom.

### GAPS
1. **Schema**: no per_customer_cap, service_scope, first_time_customer_only,
   active_from/active_until (only legacy `dd/mm/yyyy` string expiry). `max_uses` serves
   as total_usage_cap but…
2. **…enforcement is dead**: redemption checks code+status+shop+expiry ONLY; `uses`/
   `remaining_uses` are NEVER incremented; `user_promo_code` join table exists but is
   completely unused → no cap can bind today.
3. **No auto-deactivate** at cap; no removal from the discovery feed.
4. Shop form lacks all the new constraint fields.
5. Admin lacks a platform-wide Deals overview (shop column, usage vs cap, one-click
   deactivate).
6. Customer app: no shop-page deals block, no auto-apply support. **[api-tier]**

### API-impact flags (recorded)
- **The one sanctioned edit to an existing api file**: `api/models/BookingForm.php`
  `preparingWithPromoCode` (+ package twin) must delegate to the new common validator and
  record redemptions — request/response SHAPES unchanged; behavior = the BRD's required
  enforcement (stricter validation via the existing error path). Flagged explicitly.
- **Second sanctioned edit (W4)**: `api/controllers/BookingController.php`
  `actionBookPackageServices` — that action persists its Booking directly (not via
  BookingForm::save()), so W2's redemption recording never fired for package bookings
  (the W2 flagged gap / chip task_1b33241f). W4 adds ONE block after the successful
  `$booking->save(false)`: look up the applied promo by `promo_code_id` and call
  `PromoCodeService::recordRedemption($promo, $customerId, $booking->id)` — mirrors
  `BookingForm::recordPromoRedemption()`, post-persist only, idempotent per booking.
  NO request/response shape changed; nothing else in that controller touched.
- New surfaces (shop-page deals field, applicable-deals endpoint) are ADDITIVE — mobile
  team builds the UI (same flag as BEA-010/011). Both shipped in W4:
  `GET /shops/<id>/applicable-deals?service_ids=` (optional-auth, mirrors 'deals') and
  the `active_deals` key on `GET /shops/<id>` — existing keys byte-identical
  (same ShopsResource::fields() serialization). Contract doc: API_CONTRACTS.md §3.4.

## Waves
- **W1 Schema + validator core (common):** additive migration on `promo_code`:
  `per_customer_cap` INT NULL (default treated as 1 per BRD), `service_scope` JSON NULL
  (null=all; else service ids), `first_time_customer_only` TINYINT DEF 0, `active_from`
  DATETIME NULL, `active_until` DATETIME NULL. New `common/components/PromoCodeService`:
  `validate(PromoCode, shopId, customerId, serviceIds[], now): ok|reason-code` (status,
  shop match, window incl. legacy expiry fallback, total cap via uses/max_uses,
  per-customer cap via user_promo_code count, first-time via prior bookings at shop,
  service scope) + `recordRedemption(...)`: uses++/remaining_uses--, user_promo_code row
  (revive the dead table), **auto-deactivate at cap**. Unit tests alongside.
- **W2 Enforcement wiring (flagged api edit, minimal):** BookingForm promo paths delegate
  to PromoCodeService::validate + recordRedemption on successful save; shapes untouched.
  `/shops/deals` feed excludes cap-reached/deactivated/window-outside deals (it reads
  status+expiry — align to the validator's visibility rules).
- **W3 Shop portal form:** constraint fields (caps, service multi-select from the shop's
  services, first-time toggle, from/until datetime window) + list columns (usage vs cap,
  window) following the demo Promotions idiom.
- **W4 Admin overview + customer-app additive surfaces:** backend platform-wide Deals
  page (all shops, shop column, usage vs cap, expiry, one-click deactivate w/ ngConfirm)
  linked from the Marketing hub; api ADDITIVE: deals block in the shop-page payload +
  new `GET /shops/{id}/applicable-deals?service_ids=` endpoint (auto-apply support for
  the app) + API_CONTRACTS section.
- **W5 Translations + tests (all 4 acceptance criteria) + smoke + docs.**

### Sequencing
W1 → (W2 ∥ W3 ∥ W4) → W5.

### Out of scope (per BRD)
Navagoo-funded promos · referral codes · deal push notifications.

## Live tracker
- [x] W0 audit + this plan
- [x] W1 schema + validator core — done 2026-07-26. Migration
  `m260726_150000_add_deals_promotions_constraint_fields` (applied + verified via SHOW
  COLUMNS): `promo_code.{per_customer_cap,service_scope,first_time_customer_only,
  active_from,active_until}` + `user_promo_code.booking_id` (+ index). New
  `common/components/PromoCodeService` (`validate()`, `isVisible()`, `recordRedemption()`)
  + `PromoCode::{effectivePerCustomerCap,serviceScopeIds,setServiceScopeIds}`. **Found +
  fixed a latent bug while reviving `user_promo_code`**: its generated base model wired a
  BlameableBehavior for `created_by`/`updated_by` columns that table never had — any save()
  threw `UnknownPropertyException`, which is very likely why the table sat unused since
  2023 (plan GAP #2/#3). Overrode `behaviors()` in `common/models/UserPromoCode.php` to
  match the real columns. Unit suite: `PromoCodeServiceTest` 36/36 green (104 assertions);
  full `common` unit suite 437 tests, 6 errors/15 failures — all pre-existing
  (OtpVerificationRateLimitTest, AuroraTest, CalendarFormatTest, SmsLogTest,
  TokenExpirationTest), zero regressions from this wave. No `api/`, `frontend/`,
  `backend/`, or `common/messages/` files touched — additive only, as required.
- [x] W2 enforcement wiring (flagged api edit) — done 2026-07-26. `api/models/BookingForm.php`:
  `preparingWithPromoCode()` + `preparingPackageWithPromoCode()` now look up the promo by
  `code`+`shop_id` only and delegate every other check (status/window/caps/service-scope) to
  `PromoCodeService::validate()`; the invalid-code path is unchanged (`valid_code = NOT_VALID`,
  no new response fields, discount math untouched). `BookingForm::save()` calls a new private
  `recordPromoRedemption($bookingId)` right after `$booking->save(false)` succeeds (services
  booking flow, `actionBookingServices`) — never inside the preparing*() methods themselves,
  since those also run for preview-only calls (`actionPreparingBooking`/
  `actionPreparingBookingPackage`) that never persist a booking. **Known gap, flagged not
  fixed**: the package-booking flow (`BookingController::actionBookPackageServices`) persists
  its `Booking` row directly in the controller rather than via `BookingForm::save()`, so
  redemption recording is NOT wired for package bookings this wave — touching
  `BookingController.php` was out of the sanctioned two-file scope; needs a small follow-up.
  `api/controllers/ShopsController.php` `actionDeals()`: replaced the ad-hoc `dealIsLive()`
  legacy-expiry-only filter with `PromoCodeService::isVisible()` (status + active_from/
  active_until incl. legacy fallback + total-cap-reached) — response shape unchanged, only the
  filtered set changes; `dealIsLive()` removed (now dead code). `git diff --stat api/` shows
  exactly these two files (61 / 33 lines changed). Lint clean (`php -l` both files). Unit:
  `PromoCodeServiceTest` 36/36 green (104 assertions), full `common` unit suite 437 tests / 6
  errors / 15 failures — identical pre-existing set to the W1 baseline (OtpVerificationRateLimitTest,
  AuroraTest, CalendarFormatTest, SmsLogTest, TokenExpirationTest), zero regressions. Functional
  proof on dev DB (transaction-wrapped, rolled back): a `max_uses=1` promo + synthetic
  `recordRedemption()` auto-deactivates the code (status flips to NOT_ACTIVE), so a second
  `validate()` call returns `reason=inactive` (checked before the cap reason) — confirmed the
  `total_cap_reached` reason directly via a second promo forced `status=ACTIVE` with
  `uses>=max_uses` (bypassing auto-deactivate) → `validate()` returned
  `{"ok":false,"reason":"total_cap_reached"}`. Both cases: `isVisible()` correctly returned
  `false` and the code dropped out of the `actionDeals` visibility pass. `curl -H 'Host:
  api.navagoo.localhost' http://localhost/shops/deals` → `200 {"success":true,"status":200,
  "data":[]}`, no 500, matches `deals` being in the controller's `optional` auth list.
- [x] W3 shop portal form — done 2026-07-26. `frontend/controllers/PromoCodeController.php`:
  new `currentShopId()`/`applyServiceScope()`/`normalizeDatetimeLocal()` helpers wired into
  `actionCreate`/`actionUpdate`; `applyServiceScope()` whitelists posted `service_scope_ids[]`
  against `ShopService::find()->where(['id'=>..,'shop_id'=>currentShopId()])` before
  `PromoCode::setServiceScopeIds()` (same IDOR-guard shape as
  `PackageController::savePackageWithRelations`'s `servicesIDs`) — `service_scope` itself
  stays out of `PromoCode[...]` POST binding entirely. `frontend/views/promo-code/_form.php`:
  relabeled Max Uses -> "Total usage cap"; added Per-customer cap (number,
  placeholder "1 (default)"), First-time-customers-only pill toggle (JS-built, same idiom
  as `payment-settings/index.php`'s pill switch — class toggles, not CSS peer-checked, since
  the knob is a plain inserted sibling), Applies-to segmented toggle (All/Specific services,
  plain JS button state + `style.display` show/hide per the aurora "Tailwind display vs
  `hidden`" gotcha) + shop-scoped kartik Select2 multi-select
  (`ShopService::getAllWhere($shopId)`, wrapped in the existing `.tw-legacy-widget-wrapper`
  restyle already compiled into `tailwind.css` — no `npm run build:css` needed), and an
  Active Window section (`active_from`/`active_until` as native `datetime-local` inputs,
  normalized `T`->` ` + seconds server-side; legacy `expiry_date` untouched, with a hint that
  `active_until` supersedes it). `frontend/views/promo-code/index.php`: Usage now reads
  "X / cap" with "∞" for an unlimited (0/null `max_uses`) code; new Constraints column
  (chips: "First-time only", "{n} service(s)"); new Window column ("from → until", falling
  back to the legacy expiry when neither `active_from` nor `active_until` is set); Status
  chip gained an "Ended (cap reached)" red badge for `status=NOT_ACTIVE` rows where
  `uses >= max_uses` (distinguishes W1's `PromoCodeService` auto-deactivation from a
  manual toggle-off). No `api/`, `backend/`, or `common/` files touched (per scope — W1
  already added `PromoCode::{effectivePerCustomerCap,serviceScopeIds,setServiceScopeIds}`
  and the scenario-writable constraint fields). New `Yii::t('frontend', ...)` keys (not yet
  in `common/messages/{ar,en}/frontend.php` — deferred to W5): `1 (default)`, `Active Window`,
  `Active from`, `Active until`, `All services`, `Fine-tune who can redeem this code and how
  often.`, `First-time customers only`, `How many times one customer may redeem this code.
  Leave empty for the default of 1.`, `Leave both empty to rely on the legacy expiry date
  only.`, `Only customers with no prior booking at this shop`, `Only these services can
  redeem the code. Leave empty to allow all services.`, `Optional start/end window. When
  "Active until" is set, it supersedes the legacy expiry date above.`, `Per-customer cap`,
  `Redemption Limits`, `Specific services`, `Superseded by "Active until" below when that is
  set.`, `Total usage cap`, `Constraints`, `Ended (cap reached)`, `First-time only`, `Window`,
  `{n} service(s)` (`Applies to`, `Choose services`, `Services` reuse pre-existing frontend
  keys). Lint: `php -l` clean on all 3 touched files. Smoke (docker, shop-owner login via
  `/sign-in/login` `LoginForm[...]`): `/promo-code/index` 200 with the new columns rendering;
  `/promo-code/create` 200 with all new fields incl. the real shop-service Select2 options;
  full create->list->update round trip verified end-to-end against a real DB row (per-customer
  cap, first-time toggle, active window, 2-service scope all persisted + re-rendered correctly
  on the edit form; whitelisted service ids matched exactly what was posted) — test row deleted
  after verification. No errors/exceptions in any response.
- [x] W4 admin overview + additive api surfaces — done 2026-07-26.
  **Admin platform-wide Deals overview** (`backend/promo-code/index`, linked from the
  Marketing hub's Deals card which already deep-links `/promo-code/index` — no hub edit
  needed): `backend PromoCodeSearch::search()` no longer pins `shop_id` to the admin
  identity's (nonexistent) shop relation — that leftover silently evaluated to
  `shop_id = NULL`, so the admin list only ever showed platform-wide codes; it now lists
  ALL shops' codes with a shop filter dropdown (sentinel `0` = platform-wide-only) +
  status filter. New columns: Shop ('Platform-wide' for null shop_id), Code, Discount,
  Usage `X / cap` (∞ uncapped), Window (active_from→active_until, legacy
  `Until <expiry_date>` fallback, '—'), scope chips (First-time only / N services), live
  Status badge (Active / Ended / Inactive — 'Ended' computed via
  PromoCodeService::isVisible() so a window-passed or cap-reached code shows Ended even
  if `status` never flipped). **One-click Deactivate**: new
  `PromoCodeController::actionDeactivate` (POST, verb-filtered, idempotent no-op when
  already inactive), audited via `Yii::info(..., 'audit')` → `system_log` (db_action log
  target), client-side `ngConfirm`+`ngPost` (double-bind guarded, mirrors shop/index's
  mode-toggle pattern; never native confirm). Admin CSS rebuilt (`build:css:admin`).
  **API additive** (see API-impact flags + API_CONTRACTS.md §3.4): new
  `ShopsController::actionApplicableDeals` — `GET /shops/<id:\d+>/applicable-deals
  ?service_ids=1,2`, optional-auth like 'deals'; per-deal `applicable` +
  `applicable_reason` (validator reason codes) from PromoCodeService::validate();
  unauth ⇒ per-customer-cap + first-time checks skipped (documented: hint endpoint,
  BookingForm stays the enforcement path) — plus an `active_deals` key on
  `GET /shops/<id>` (isVisible-filtered, shop-scoped ∪ platform-wide, same formatDeal
  shape; `$shop->toArray()` keeps every existing key identical); route line added in
  `_CustomerUrls.php`. **Second sanctioned api edit landed**: package-booking redemption
  recording in `BookingController::actionBookPackageServices` (see API-impact flags) —
  **chip task_1b33241f is RESOLVED/stale: the exact fix it proposed is implemented here;
  dismiss the chip.** `formatDeal()` gained an optional `$extra` param (default `[]` —
  existing callers' payloads byte-identical). W2's stale "not wired this wave" comment in
  BookingForm::preparingPackageWithPromoCode() updated to point at the new wiring.
  `git diff --stat api/` = exactly `_CustomerUrls.php` (+route) / `BookingController.php`
  (+redemption block) / `ShopsController.php` / `BookingForm.php` (W2 + comment update).
  Verify: `php -l` clean on all 7 touched files (api ×4, backend ×3); backend
  `/promo-code/index` curl w/ admin cookies → 200 (shop column + 6 deactivate buttons,
  zero exception markers); `GET /shops/deals` 200; `GET /shops/1/applicable-deals
  ?service_ids=1,2` 200 unauth (never 404/500); `GET /shops/1` 200 with `active_deals`
  present + existing keys intact. Tests: PromoCodeServiceTest 36/36 (104 asserts),
  FinanceLedgerServiceTest 43/43, EntitlementServiceTest 10/10 — zero regressions.
  **New `Yii::t('backend', ...)` keys for W5** (deliberately NOT added to
  common/messages this wave — W5 owns translations): `Platform-wide` · `Until {date}` ·
  `First-time only` · `Deal deactivated.` · `Failed to deactivate the deal.` ·
  `Every shop's promo codes, platform-wide deals, usage against cap, and redemption
  window.` · `Deactivate promo code "{code}"? Customers will no longer be able to
  redeem it.` · `{count, plural, one{# service} other{# services}}` (all other keys on
  the page pre-exist).
- [x] W5 translations + tests + verify — done 2026-07-26.
  **Translations.** Grepped every uncommitted BEA-009 file's actual `Yii::t()` calls (not the
  W3/W4 tracker key lists, which drifted slightly from the real diff): 22 `Yii::t('frontend', …)`
  keys across `frontend/controllers/PromoCodeController.php` + `frontend/views/promo-code/
  {_form,index}.php` (`Applies to`/`Choose services`/`Services` were already pre-existing keys,
  confirmed by grep — not re-added) and 9 `Yii::t('backend', …)` keys across
  `backend/controllers/PromoCodeController.php` + `backend/views/promo-code/index.php` (search
  model has no `Yii::t` calls). The backend sweep also caught one genuine pre-existing gap
  unrelated to this feature's new copy: `'Data has been created successfully'` (backend
  category) was missing entirely — only the `...updated...` sibling existed — even though the
  controller has called it since before this wave; added it as part of the same pass since it's
  used in a touched file. All 31 keys (22 frontend + 9 backend) added to
  `common/messages/{en,ar}/{frontend,backend}.php` as a new `// ── NVG-BEA-009 — Deals &
  Promotions (W5 i18n) ──` block, following the file's established per-wave-block convention
  (matches the BEA-002/003/005/010/011 blocks already in these files) rather than interleaving
  alphabetically. Arabic is real Arabic throughout, not transliteration. The backend ICU plural
  key `'{count, plural, one{# service} other{# services}}'` got full Arabic CLDR plural
  categories — `one{خدمة واحدة} two{خدمتان} few{# خدمات} many{# خدمة} other{# خدمة}` — following
  the exact convention already used for `{n, plural, one{# day} other{# days}}` elsewhere in
  these files (no redundant `#` digit on the `one`/`two` categories, since Arabic grammar makes
  it read oddly); verified against PHP's `MessageFormatter` (`intl` ext, confirmed loaded in the
  container) across n=1,2,3,10,11,50,99,100 — every CLDR category (`one/two/few/many/other`)
  renders correctly. The frontend's non-ICU `'{n} service(s)'` key got a single-form Arabic
  translation (`{n} خدمة`), matching the codebase's existing simplification for other
  non-plural-syntax placeholder strings (e.g. `'{count} numbers frozen'`). i18n-check hook fired
  twice mid-edit (as designed — added en before ar) and went clean once both sides landed;
  parity script confirms 0 missing/untranslated on both categories (`frontend: en=1945 ar=1945`,
  `backend: en=3382 ar=3382`). `php -l` clean on all 4 touched message files.
  **Acceptance-criteria tests.** Re-read the BRD's 4 ACs against the existing 36-test
  `PromoCodeServiceTest` (W1): (a) cap-reached ⇒ discovery-feed invisibility — covered
  (`testIsVisibleFalseWhenTotalCapReached`) — AND validate-reject — covered
  (`testValidateTotalCapReachedWhenUsesEqualsMaxUses`); (b) first-time-customer reject for a
  returning customer — covered (`testValidateFirstTimeOnlyBlocksWhenAPriorActiveBookingExists`);
  (c) service-scope reject — covered (`testValidateServiceScopeBlocksWhenNoServiceOverlaps`). All
  three were unit-level checks on hand-built `PromoCode` instances, though — none of them proved
  the actual BookingForm-shaped flow (`recordRedemption()` writing the mutation that
  `validate()`/`isVisible()` are re-checked against on the very next attempt). Added the one
  missing piece: `testRecordRedemptionAtCapFlipsIsVisibleAndBlocksTheNextValidateCall` — a real
  DB-backed (tx-rolled-back) test that (1) confirms a fresh cap-1 code is visible and validates
  ok for a first customer, (2) calls `recordRedemption()` exactly as `BookingForm::save()` does
  post-persist, (3) reloads the row and asserts `isVisible()` now returns false, and (4) asserts
  a *second* customer's `validate()` call is rejected. One subtlety surfaced and is documented
  inline: `recordRedemption()` auto-deactivates the row at cap (`status → NOT_ACTIVE`), and
  `validate()` checks status before the cap reason, so the surfaced reason on the second call is
  `REASON_INACTIVE`, not `REASON_TOTAL_CAP_REACHED` — matches the exact behavior W2's tracker
  entry already proved manually via curl; now pinned by an automated test instead. `PromoCodeServiceTest`:
  **37/37 green (113 assertions)**. Full `common` unit suite: **438 tests / 1044 assertions, 6
  errors / 15 failures** (438 = the W1 baseline's 437 + this one new test) — the error/failure
  set is byte-identical to every prior wave's documented pre-existing baseline
  (`OtpVerificationRateLimitTest` ×6 errors; `AuroraTest` ×5, `CalendarFormatTest` ×4,
  `SmsLogTest` ×5, `TokenExpirationTest` ×1 — 15 failures), zero regressions.
  **Smoke.** Backend (given session cookies): `GET /promo-code/index` → 200, `lang="ar"`,
  `<title>العروض</title>` (the platform-wide Deals overview page renders Arabic — `Platform-wide`/
  deactivate strings from the W4 tracker's new-key list), zero exception/error markers in the
  response body or `runtime/logs/app.log`. Frontend: logged in as
  the dev shop-owner (`Beautycenter123@123.com` / `/sign-in/login`, `LoginForm[username]` +
  `LoginForm[password]` — NOT `SignInForm`, that field name is stale), then forced Arabic via
  `GET /site/change-language?lang=ar` (the account's `user_profile.locale` was `'en'`, which
  `LocaleBehavior` otherwise treats as authoritative for a logged-in user on every request unless
  a `_locale` cookie is present) — `/promo-code/index` → 200 `lang="ar"`
  `<title>كوبون الخصم</title>` with `القيود`/`الفترة` rendering; `/promo-code/create` → 200
  `lang="ar"` `<title>إنشاء كود خصم</title>` with `حدود الاستخدام`/`فترة التفعيل`/`نشط من`/
  `نشط حتى`/`الحد الأقصى لكل عميل` all rendering; zero exception/error markers. API (unauth,
  no cookies): `GET /shops/deals` → `200 {"success":true,"status":200,"data":[]}`;
  `GET /shops/1/applicable-deals?service_ids=1` → `200 {"success":true,"status":200,"data":[]}`;
  `GET /shops/1` → 200 with `active_deals` present in `data` (empty array — no test deals seeded
  on shop 1 in this dev DB, but the key exists and every pre-existing key on the payload is
  intact). No 500s anywhere; `api/runtime/logs/app.log` clean.
  **Docs.** This tracker entry + plan status header flipped to COMPLETE; BEA-009 completion
  section appended to `ai_specs/05_PLANS/HANDOVER.md` (waves recap, the 3 bonus root-cause fixes,
  api-additive flag recap for the mobile team, out-of-scope reminder).
  **Scope discipline.** No `api/` files touched this wave (translations + tests + docs only, as
  scoped). No production code changed — W5 found zero real bugs requiring a fix; the one
  "missing" thing found (`'Data has been created successfully'` backend key) was a translation
  gap, not a code bug, and is covered by the translations item above.
