# NVG-BEA-005 — Client Attribution Engine & Freeze List — Gap Analysis + Implementation Plan

**Status: COMPLETE — started 2026-07-22, all waves (W1–W6) done 2026-07-23.** Live tracker at the bottom.
Spec: NVG-BEA-005 BRD (user-provided). Audited 2026-07-22 (agent, file:line evidence).

## Answers to the BRD's open questions (owner: Ahmed Qotb)
- **OQ-21 (soft-delete + re-registration):** soft-delete = `status=DELETED` + mobile/email
  salted with `del_` prefix (api ProfileHelper). Re-registration therefore ALWAYS creates a
  new User row — no phone-keyed reactivation. → To make classification survive, we add
  `customer_classification.mobile` (normalized) and resolve by customer_id OR mobile (W1/W2).
- **OQ-22 (specialist walk-in):** today `WalkInBookingService::resolveOrCreateCustomer`
  ALWAYS creates a full User (synthetic email) for unknown phones — no phone-only stub. The
  BRD's phone-only-block enhancement touches the SPECIALIST_APP api contract → **flagged,
  deferred** (needs mobile-app coordination).
- **OQ-23 (schema):** `customer_classification` exists, keyed `(shop_id, customer_id)` w/
  classification, source (walk_in|deep_link|freeze_list|app_first_booking), locked_at,
  override_reason/overridden_by, immutable. Per-shop ✓. Not phone-keyed (fixed via W1 mobile
  column).

## Verdict: skeleton done, engine dead

### DONE
customer_classification + customer_freeze schema · admin override flow (mandatory reason,
audited, re-derives charges) · shop portal Classifications tab (read-only, badge/source/
date/override) + freeze add/remove UI + customers directory column · per-shop min-fee +
commission overrides · min-fee floor + inGrace waiver + navagoo_sourced gate inside
buildMarketingFee (logic correct, inputs never real).

### GAPS (agent-verified)
1. **ENGINE DEAD:** `classifyOnFirstBooking()` has zero call sites; `FinanceLedgerService::
   resolveClassification()` probes NON-EXISTENT static methods → silently returns
   'shop_owned' for every booking → **marketing fee never charged in live flows** (only via
   admin-override re-derive).
2. **Freeze List:** single-entry modal only — no CSV/paste bulk, no cap (2000 /
   freeze_list_cap), no consent checkbox + logged affirmation, no 3-day lockout (always-on).
3. **Grace window: 3 disconnected impls** (hardcoded 30d off created_at in CustomersController;
   CommercialConfig.grace_window_days=3 never read; Shop.grace_window_ends_at is an unrelated
   BEA-002 concept). None feed `inGrace`. No first-portal-login anchor. No reminder emails.
4. **Deep-link attribution unimplemented** (viaDeepLink hardcoded false; /book/{token} has no
   receiver; invitation campaigns don't feed classification).
5. Walk-ins never classify (should = shop_owned at first shop-admin walk-in w/ services+payment).
6. No 15-day dispute mention (disputes out of scope anyway).

### Product flags (recorded, not silently changed)
- **Fee basis deviation:** BRD says 5% × *Net Collected (Excl. VAT)*; live code uses booking
  value excl. VAT (demo-parity). Changing alters real charged amounts + existing tests →
  needs product confirmation before flipping. NOT changed in this effort.
- Phone-only specialist walk-in block (no User row) → api/mobile contract change → deferred.
- True deep-link *registration* attribution needs api signup changes → W4 ships the additive
  proxy (invitation-recipient phone match ⇒ shop-owned/deep_link) + flag for the full flow.

## Waves
- **W1 Schema (additive):** `customer_classification.mobile` VARCHAR(32) NULL + index;
  `shop.first_portal_login_at` INT NULL; `shop.freeze_list_cap` INT NULL (per-shop override);
  `shop.freeze_locked_at` INT NULL (stamped when window closes/upload completes);
  `customer_freeze`: `consented_at` INT NULL, `consented_by` INT NULL, `batch_id` VARCHAR(32)
  NULL; `commercial_config.freeze_list_cap` INT DEFAULT 2000.
- **W2 Engine wiring (critical):** fix `resolveClassification()` to use the real service
  (classify-on-first-classifying-event: app booking ⇒ 3-part rule w/ freeze check by
  customer_id OR mobile + deep-link flag; walk-in ⇒ shop_owned). Call classification from
  WalkInBookingService (create) + fee-derivation path (first app-booking event). Single
  grace source: `inGrace` computed from `shop.first_portal_login_at` +
  `CommercialConfig.grace_window_days` inside deriveBookingCharges when opt omitted; stamp
  first_portal_login_at at shop-owner login. Mobile-keyed classification survival.
- **W3 Freeze List UX:** bulk CSV upload + paste input; cap enforcement (shop.freeze_list_cap
  ?? global 2000); consent checkbox + logged affirmation (consented_at/by); 3-day window from
  first_portal_login_at: countdown banner (Customers), locked state after (upload date +
  count + permanent-close message); align the old 30d banner; daily grace reminder email
  until upload/window end (console command + schedule.php).
- **W4 Attribution extras:** invitation-recipient phone match ⇒ deep_link/shop-owned proxy at
  classification time; admin freeze add (post-window, admin-only — small backend action).
- **W5 Shop UI polish:** Customer Invitations tab + classification columns (classification /
  reason / date); ensure no cumulative fee amounts shown; Classifications tab labels match
  BRD reasons.
- **W6 Translations + unit tests (all acceptance criteria) + smoke + docs.**

### Sequencing
W1 → W2 → (W3 ∥ W4 ∥ W5) → W6. Coordinated with NVG-BEA-003 waves (disjoint files; both
touch FinanceLedgerService additively — BEA-005 W2 only).

## Live tracker
- [x] W0 audit + this plan (OQ-21/22/23 answered above)
- [x] W1 schema (m260722_170000 applied 2026-07-22)
- [x] W2 engine wiring — done 2026-07-22. `FinanceLedgerService::resolveClassification()`
  now delegates to `CustomerClassificationService::classifyOnFirstBooking()` (real
  lookup-or-classify, keyed by (shop_id, customer_id) OR (shop_id, normalized mobile)
  — `CustomerClassificationService::findRow()`) instead of the dead static probes.
  Walk-in vs app discriminator = `Booking::booking_method` (`BOOKING_METHOD_WALK_IN_SHOP`/
  `_WALK_IN_AGENT` → walk_in; `BOOKING_METHOD_MOBILE` → the 3-part rule incl. freeze-list
  check), already correctly read inside `classifyOnFirstBooking()` — it just had zero
  call sites before. Wired at:
    - `WalkInBookingService::create()` — classifies AFTER commit, non-fatal on error
      (first shop-admin walk-in ⇒ permanent shop_owned/walk_in stamp).
    - `FinanceLedgerService::deriveBookingCharges()` — via `resolveClassification()`
      when `opts['classification']` is omitted (all real call sites: BookingController
      cancel, GroupBookingService create/cancel, DemoController; admin re-derive in
      backend/UserController still passes it explicitly and is untouched).
  Single grace source: `FinanceLedgerService::computeInGrace(Shop $shop)` +
  `graceWindowDays()` (CommercialConfig.grace_window_days, CEO default 3) anchored to
  `shop.first_portal_login_at`; `deriveBookingCharges()` now computes it via
  `array_key_exists('inGrace', $opts) ? … : computeInGrace($shop)` — explicit opts
  (tests, admin re-derive) still win untouched. `first_portal_login_at` stamped once
  in `SignInController::actionLogin()` right after `$model->login()` succeeds
  (`updateAttributes()`, no validation side effects). `CustomersController::graceWindow()`
  now reads the SAME `first_portal_login_at` + `graceWindowDays()` instead of its old
  hardcoded 30-day-off-`created_at` banner (`GRACE_WINDOW_DAYS` const removed).
  Mobile survival: `CustomerClassificationService::classifyCustomer()` now takes an
  optional `$mobile`, stamps the normalized value (`CustomerFreeze::normalizeMobile()`
  — the pre-existing freeze-matching helper, reused for consistency) onto new rows, and
  `findRow()` falls back to a mobile match before classifying, so a re-registered
  customer (OQ-21: soft-delete always creates a new User row) keeps their original
  attribution instead of being reclassified. Files touched: `common/components/
  FinanceLedgerService.php`, `common/components/CustomerClassificationService.php`,
  `common/services/WalkInBookingService.php`, `frontend/controllers/SignInController.php`,
  `frontend/controllers/CustomersController.php`. Verify: `php -l` clean on all 5;
  `codecept run unit` — 274 tests, 622 assertions, 6 errors / 15 failures, ALL in the
  known-baseline Otp/Sms/Token/Aurora/CalendarFormat groups (unchanged by this wave);
  FinanceLedgerServiceTest (33) + PaymentProcessingFeeTest (13) + BookingCompletionServiceTest
  (2) = 48/48 green.
- [x] W3 freeze list UX — done 2026-07-22. **Bulk upload**: new
  `CustomersController::actionBulkUpload()` (POST, multipart) accepts a CSV/TXT
  file (`mobiles_file`, .csv/.txt, max 2 MB) and/or a pasted list
  (`mobiles_text`); both routed through the new
  `CustomerFreeze::parseBulkMobiles()` helper — tolerant of a header row (a
  non-numeric first cell normalizes to `''` and is skipped), CSV columns
  (`,`/`;`/tab), CRLF/LF, blank lines, and dedupes within the batch. Candidates
  are deduped against `CustomerFreeze::frozenMobiles()` (existing rows), then
  cap-checked via the new `CustomerFreeze::effectiveCap()`
  (`shop.freeze_list_cap ?? commercial_config.freeze_list_cap ?? 2000`) —
  batch rejected with an exact over-cap count when it would exceed the cap.
  **Consent**: the upload form has a required "I affirm these phone numbers
  are legitimate pre-Navagoo customers of my shop" checkbox, enforced
  server-side (`Yii::$app->request->post('consent')`); every inserted row in
  the batch is stamped `consented_at` (unix ts) / `consented_by` (logged-in
  user id) and shares one `batch_id` (`frz_<uniqid>`), and the affirmation is
  logged via `Yii::info(..., 'audit')` (visible in `system_log`).
  **3-day window + lockout**: new `CustomersController::freezeLockState()`
  reuses the SAME anchor/window as `graceWindow()`
  (`shop.first_portal_login_at` + `FinanceLedgerService::graceWindowDays()`).
  Locked when EITHER `shop.freeze_locked_at` is set (stamped immediately after
  the first successful bulk upload — an early upload closes the list early,
  independent of the window) OR the window has elapsed (self-healed: the
  first request observed after close lazily stamps `freeze_locked_at`, and
  the new daily cron does the same sweep so a shop that never revisits still
  ends up locked). While open, the Freeze list tab shows a "Freeze list closes
  in {n} day(s)" banner + the upload UI; once locked, that UI is replaced with
  a locked-state card (closed-on date, frozen count, permanent-closure
  message pointing to Navagoo admin). `actionFreeze`/`actionUnfreeze` (manual
  single add/remove) now call the same `freezeLockState()` guard server-side
  and are also hidden client-side when locked, so lockout can't be bypassed
  by re-posting directly.
  **Daily reminder**: new `console/controllers/FreezeReminderController::
  actionSend()` (`php yii freeze-reminder/send`) walks shops with an open
  window and `freeze_locked_at IS NULL`; still-open shops get "{days} day(s)
  left to upload your existing-customer list" (HTML+text, `Yii::$app->mailer
  ->compose()`, no new mail-view file — inline body, matching scope), sends
  logged via `Yii::info(..., 'audit')`; shops whose window just elapsed get
  `freeze_locked_at` stamped instead (no email). Idempotency is once-per-day
  via the cron schedule (no new "last sent" column — W1 didn't add one and
  the BRD only needs once/day). Added to `console/config/schedule.php`
  (`->daily()->withoutOverlapping()`, qc + prod). `init()` masks
  `E_DEPRECATED` (same fix as `NotificationsController`/`WagesController` —
  vendor `MultiLanguageBehavior` dynamic-property deprecation on the console
  SAPI).
  Files: `frontend/controllers/CustomersController.php`,
  `frontend/views/customers/index.php`, `common/models/CustomerFreeze.php`
  (additive: `effectiveCap()`, `countForShop()`, `parseBulkMobiles()`),
  `console/controllers/FreezeReminderController.php` (new),
  `console/config/schedule.php`.
  **Gotcha hit + fixed**: an all-digit string used as a PHP array key is
  silently cast to `int` (`array_keys(['9665551112223' => true])` returns
  `int(9665551112223)`, not a string) — this broke the `CustomerFreeze.mobile`
  `string` validation rule on every bulk-inserted row. Fixed by returning
  `array_map('strval', ...)` from `parseBulkMobiles()` and building the
  dedup/cap list as a plain indexed array (`array_filter`/`array_values`,
  never keyed by the mobile string) in `actionBulkUpload()`, plus an explicit
  `(string)` cast at the point the AR attribute is set.
  Verify: `php -l` clean on all 5 touched/added files;
  `php console/yii freeze-reminder/send` ran clean (sent=1 in dev, 0 fatals);
  curl `Host: shop.navagoo.localhost /customers` → 302 (guest redirect, never
  500); full browser walkthrough on the dev shop (login → Freeze list tab →
  paste list with header/junk lines mixed in → consent → upload → 3 correct
  rows inserted, batch_id/consented_at/consented_by verified in DB, audit log
  row confirmed, locked-state card rendered with correct date/count → manual
  single "Freeze" action re-verified working while unlocked). Test rows
  cleaned up from the dev DB afterward (`shop.id=15` reset to unlocked,
  0 freeze rows).
- [x] W4 attribution extras — done 2026-07-22. **Deep-link proxy**:
  `CustomerClassificationService::invitedViaDeepLink()` (new private helper) — for
  app-first bookings (`viaApp === true`), before falling through to
  `navagoo_sourced`, one cheap `EXISTS`-style query checks whether the normalized
  mobile was sent (and `delivery_status = STATUS_SUCCESS`, i.e. actually
  dispatched, not FAILED/PENDING) a `customer_invitation_recipient` row joined to a
  `customer_invitation_campaign` of THIS shop; if so, `classifyOnFirstBooking()`
  passes `viaDeepLink=true` into the existing `resolve()` priority chain ⇒
  shop_owned/deep_link. Documented inline as a proxy (BRD's "registered via shop's
  deep link") pending the real api-signup-time token capture (mobile/api contract
  change — flagged, deferred, see plan "Product flags"). One shop's campaigns only;
  `mobile_number` is already digits-only (same shape as
  `CustomerFreeze::normalizeMobile()`), so no extra normalization needed on the
  recipient side. **Post-window admin freeze** (BRD: "Post-window additions handled
  by Navagoo admin only"): `backend/controllers/UserController::actionAddFreeze()`
  (POST-only) — shop_id + mobile (required) + name (optional) + reason (required,
  stored in `customer_freeze.note` — no separate reason column exists) →
  normalizes mobile, creates a `CustomerFreeze` row with `consented_by`=admin id,
  `consented_at`=now, `batch_id`='admin', audit-logged via `Yii::info(...':audit')`.
  Never mutates an existing `customer_classification` row (immutability preserved —
  freeze rows are forward-looking, gating only the customer's future first
  booking); if one already exists for that (shop, mobile) the success flash says so
  explicitly. UI: "Add to freeze list" button + modal on the People→Classifications
  tab (`backend/views/user/people.php`), same modal idiom as the existing Override
  modal — shop `<select>` (fed by `UserController::actionPeople()`'s new
  `$shopOptions`, computed only on the classifications tab), mobile, optional name,
  mandatory reason textarea, Apply disabled until all three required fields are
  filled. Files touched: `common/components/CustomerClassificationService.php`,
  `backend/controllers/UserController.php`, `backend/views/user/people.php`. New
  `Yii::t('backend', ...)` keys (all in `people.php` + `UserController.php`, NOT
  added to `common/messages/*` per this track's scope — translations are W6):
  'A valid shop and mobile number are required.', 'A reason is required to add a
  number to the freeze list.', 'Invalid mobile number.', 'Failed to add to freeze
  list.', 'Added to freeze list.', 'Added to freeze list. This number is already
  classified ({classification}) at this shop — that classification is unchanged;
  the freeze only affects future first bookings.', 'Add to freeze list',
  'Post-window addition — for shops whose 3-day freeze-upload window has already
  closed.', 'Select a shop…', 'Mobile', 'Name (optional)', 'Reason', 'Why is this
  number being frozen after the window closed?', 'Add', 'Cancel'. Verify: `php -l`
  clean on all 3 touched files; `codecept run unit` — same 274/622/6-errors/
  15-failures baseline (unchanged); targeted filter
  FinanceLedgerServiceTest+PaymentProcessingFeeTest+BookingCompletionServiceTest+
  EntitlementServiceTest = 57/57 green (33+13+2+9). Curl smoke:
  `GET /user/people?tab=classifications` (backend.navagoo.localhost, authed
  cookies) → 200, page renders, "Add to freeze list" button + `freezeModal` +
  shop `<select>` (23 options) present, no fatal/exception/warning in the response
  body.
- [x] W5 shop UI polish — Customer Invitations "Invited Customers" tab extended with
  Classification / Reason / Classified On columns (batch-loaded by normalized mobile,
  one query, no N+1) + "View all classified customers" link to /customers#classifications;
  no fee amounts shown. Classifications tab labels already matched BRD reasons pre-existing
  (frontend/views/customers/index.php), not touched. 2026-07-22.
- [x] W6 translations + tests + verify — done 2026-07-23.
  **Residual translations**: a prior i18n sweep (BEA-003's W6, the shared
  "NVG-BEA-003/BEA-005 (W6 i18n sweep)" block in `common/messages/{en,ar}/frontend.php`)
  had already translated nearly every BEA-005 key. Full grep of all `Yii::t()` calls in
  the 11 uncommitted BEA-005 files vs `common/messages/en/*` found only **2 residual
  frontend keys**, added to BOTH en+ar (real Arabic, placeholders verbatim):
  `'Invalid communication method.'` and
  `'{days} day(s) left to upload your existing-customer list'` (the reminder-email
  subject). Post-edit parity verified programmatically: frontend en=ar=1900,
  backend en=ar=3320, common en=ar=232 — zero keys missing either direction in any
  category. `php -l` clean on both touched message files.
  **Unit tests** (acceptance criteria, all green — 64 new assertions in 31 new tests):
  - `common/tests/unit/components/CustomerClassificationServiceTest.php` (NEW, 8 tests):
    resolve() priority — freeze-listed phone ⇒ shop_owned/freeze_list even on an app
    booking (AC-a); clean app-first ⇒ navagoo_sourced/app_first_booking (AC-b);
    walk-in wins over everything (AC-c); the full walk_in > deep_link > freeze_list >
    app_first chain; no-signal default. Persistence (transaction-rollback pattern per
    PaymentProcessingFeeTest — synthetic ids, no FK constraints on either table):
    second classify call with OPPOSITE input returns the original row untouched,
    still exactly 1 row (immutability, AC-d); findRow() mobile-keyed survival — a
    re-registered customer under a NEW customer_id resolves to the ORIGINAL row by
    normalized mobile and is NOT reclassified (AC-g).
  - `common/tests/unit/components/FinanceLedgerServiceTest.php` (EXTENDED +10 tests,
    33→43): computeInGrace() false without a first_portal_login_at anchor / true
    inside the window / false once elapsed / true just before the boundary (AC-e);
    buildMarketingFee() waives to 0.0 (amount AND vat) when inGrace, charges for real
    after (AC-e); returns null for shop_owned; floors a genuinely-below-minimum 5%
    amount UP to the LIVE minMarketingFee (AC-f — distinct from the pre-existing
    minMarketingFee() resolution test: this exercises the max() inside the builder);
    no flooring when the computed amount clears the floor; PENDING-vs-UNPAID status.
  - `common/tests/unit/models/CustomerFreezeTest.php` (NEW, 13 tests):
    parseBulkMobiles() — header-row skip, in-batch dedupe + first-seen order, CSV
    first-column (`,`/`;`/tab), CRLF/LF, blank lines, <7-digit junk dropped,
    formatting stripped, strings-not-ints regression guard (the W3 array-key-int
    gotcha), empty input; effectiveCap() — positive shop override wins, 0/negative
    override falls through same as null, global-default fallback (2000 asserted only
    when the live CommercialConfig has no usable value, per suite convention).
  **Full suite**: 335 tests / 756 assertions, **6 errors / 15 failures — the exact
  known baseline** (Otp/Sms/Token/Aurora/CalendarFormat groups, untouched by this
  track; pre-W6 count was 274 tests + these same 21). Targeted finance/entitlement/
  plan/subscription filter (PaymentProcessingFee+BookingCompletion+Entitlement+
  ShopSubscription+NavagooSubscriptionPlan+SubscriptionMetrics+SubscriptionBilling)
  = 53/53 green; FinanceLedger 43/43, Classification 8/8, CustomerFreeze 13/13.
  **Smoke**: backend `GET /user/people?tab=classifications` (authed) → 200,
  `freezeModal` + "Add to freeze list" + shop `<select>` present, zero
  fatal/exception/warning markers. Frontend (dev shop-owner login → 302 to
  dashboard): `/customers` → 200, grace banner ("You are in your onboarding grace
  window… 3 days left") + freeze-tab countdown ("Freeze list closes in 3 day(s).")
  + upload UI all rendered, no markers; `/customer-invitations` → 200 no markers —
  renders the pre-existing entitlement gate ("Upgrade required": the dev shop's
  subscription is `cancelled` and the route requires `messaging_campaigns`), NOT a
  BEA-005 issue; the classification columns were already browser-verified in-page
  during W5 and the view+controller `php -l` clean. Console:
  `php console/yii freeze-reminder/send` → clean, `sent=1 locked=0 skipped=0`.
  No production code changed in W6 (no test exposed a real bug).
