# Handover — Tailwind re-skin of the shop portal (no AdminLTE)

**Branch:** `tailwind-poc` · **Last updated:** 2026-08-21

> **Authoritative roadmap now = `DEMO_SYNC_V14_V20_MASTER_PLAN.md`** (9 phases P0–P8, live status table). This HANDOVER remains the shop-portal re-skin detail log.

## 2026-08-21 — Audit Batch B4 (config/finance S2 leftovers) COMPLETE: 7/7 — ALL UNCOMMITTED

Autonomous loop continued: CF-CC-05 (commercial card copied at shop creation; branch
inherits parent's card; PRIVILEGED_FIELDS hardened after the review found the branch-create
class bug + mass-assignment gaps), CF-CC-06 admin (commercial-config deposit cap finally
enforced: override → config cap → legacy settings → 100; config 0/>100 unsavable;
owner-toggle injection of the override blocked), F-FIT-02 (settling a withdrawal flips its
tagged charges paid + mints a paid transfer_settlement invoice + marks linked invoices paid;
reversal twins NET; retry-on-every-settled-save; systemic flash→toast bridge for the whole
tailwind admin), F-FIT-07 (both-documents gate: client button + server + MODEL invariant —
bypass POST live-refused; live test exposed and fixed the settle rail's pre-existing 500:
missing transactions.withdrawal_id column, migration m260821_150000 applied + legacy-row
adoption in the dedupe), NOTIF-04 ("Fires" is a closed dispatched-events catalogue with
per-attribute validation, unique keys, stamped event_basis; phantom customer_invitation
kept out of authoring), F-FIN-16 (Earnings table on the four ledger methods via the
FromRows batch — satellite tip rows split from the primary row after a review-probe showed
double-display; VALUE column kept raw per the demo), SN-04 (Settings ▸ Payments
entitled/locked per plan with the demo's dynamic "Subscribe to {plans}" CTA; skipOnEmpty
bypass + phantom first-save default both live-reproduced and fixed; legacy /payment-settings
redirected; setup wizard gated). Suite 565→**596/596**. Each item: full 4-agent review +
live browser/probe test. Staging log: `Mockup vs. Live audit/test-data.md`.

## 2026-08-21 — Audit Batch 2 (subscriptions S2) COMPLETE: SE-01/06/08/09/11 — ALL UNCOMMITTED

Autonomous loop (owner-armed /loop): each item implemented → full suite → 4-agent review
(php/security/silent-failure/code-review) → confirmed findings fixed → live test. Suite
445→**565/565**. Highlights: SE-01 term_features snapshot (stamps RESOLVED sets — even a
plan TIER edit can't leak mid-term; live-tested via the admin feature matrix); SE-06
payment-timing = toggle∧plan via ONE resolver (walk-in + group modals + POST guards,
branch→parent aware, STARTER empty-state upsell; api untouched — mobile phase reuses the
resolver); SE-08 specialist cap-lock (newest-first, wage freeze mirrored into
user_profile.wage_frozen_at, toggle-bypass guards both portals, zero-cap refused,
status-logs+audit per flip, post-commit enforcement kills a double-charge window); SE-09
deferred bank-rail upgrade (invoice+staging atomic, card charge-first + idempotent retry,
applyPendingUpgrade refuses stale/cancelled staging, voidPendingUpgrade + audit trail,
pending banner + admin Verify); SE-11 admin subscription ops stamp the term (trial-once,
COMP@0.00 — no MRR fabrication, live-paid terms schedule instead of clobber, full audit
events, atomic + dunning resets; live-tested assign→activate→cancel = grace-to-term-end).
~20 review-found defects fixed across the batch (5 CRITICAL-class: false-fail settlements,
group-booking entitlement bypass, card double-charge, stale-staging privilege escalation,
unbilled paid-looking terms). New owner questions logged in the tracker (Starter cash
walk-ins; cap-lock = full account lockout; mid-trial admin assign supersede).

## 2026-08-20/21 — Audit Batch 1 (admin audit-events) COMPLETE: F-ADJ-01+02+04 + 5 dependents — ALL UNCOMMITTED

New working mode set by the owner: item-by-item with a checkpoint after each, **NO git
commits ever unless explicitly asked** (an early commit was reverted via soft reset — the
whole batch sits staged on `d7c0fb2`). Batch/remainder plan lives in the tracker
(`AUDIT_REMEDIATION_2026_08_11.md`) + an owner-facing artifact ("خطة إغلاق التدقيق").

**F-ADJ-01** — AuditLogService wired into every admin mutation (~29 sites / 10 controllers,
incl. the OFFER CRUD family a review found missing) with changes()/emitChange() field-diff
helpers. **F-ADJ-02** — AddToTimelineCommand append-only + notifyDeletion carries user_id +
notifySignup exists-guard (hook + 5 api calls double-fire). **F-ADJ-04** — shop form's
marketing-rate input rebound to platform_commission (the column marketingRatePct bills),
dead marketing_fee_rate_pct input/seed/POST paths removed (column left in DB — no mobile
impact), directory Marketing column shows the EFFECTIVE rate via the engine resolver,
inherited-global display admin-only.

**Two 4-agent review passes** (php/security/silent-failure/code-review): pass 1 found 3
CRITICAL (emit could false-fail committed settlements / starve activation email; phantom
plan.deleted; offers unwired) — all fixed incl. emit() hardened never-throw + 64KB clip +
per-site success-gating. Pass 2: 4/4 APPROVE, 0 CRITICAL/HIGH; fixes: engine-resolver in
shop index, admin-gate on global rate, `m260821_100000` timeline_event filter indexes
(applied). Blank-rate='' proven to persist NULL ⇒ inherit-global (live DB probe).

**Live verification**: qa_admin browser session — trigger toggle → 2 audit rows w/ real
actor + field-diff; two notifyDeletion probes → 2 rows w/ own user_ids (cleaned up).
Probe helpers in `console/runtime/_fadj02_probe.php` + `_auditlog_tail.php` (gitignored).
Suite 445→**546/546 green**. Next: audit Batch 2 — subscriptions S2 (SE-01 first).

## 2026-08-16 (later) — WEB group modal now intersects specialists (matches mobile)

Owner-reported: web "New group booking" showed slots to 7 PM while the mobile
/booking/group-slots stopped at 3 PM for the SAME shop/day/specialists. Root-caused with
live data + repro (shop 7, agents 25/46, 2026-08-17): the endpoint was CORRECT — svc 45
(agent 25) is 120 min and agent 25 has an evening shift (17:30-21:00) but agent 46
(svc 44, 30 min) has none (09:00-17:00), so the true intersection ends 15:00. The WEB
modal was rendering only the "anchor" guest's SOLO /booking/slots grid (agent 25 alone →
09:00-19:00), consulting the other specialists only as a post-click conflict banner —
so it offered 5:30-7:00 that agent 46 can't actually staff. Reproduced exactly: my
endpoint (25∩46) = 09:00-15:00 (matches mobile); agent-25-alone = 09:00-19:00 (matches
web). Owner chose: fix the web to match the mobile (true intersection).

Fix (frontend, reuses the SAME GroupBookingService::mutualFreeSlots as the mobile — no
drift): `BookingController::actionGroupSlots` (POST, session-shop, VerbFilter'd) wraps
mutualFreeSlots; `BookingCalendarController::actionGroupBooking` passes `groupSlotsUrl`;
`_group_booking.php` config += `urls.groupSlots`, and `maybeLoadSlots()` now POSTs EVERY
ready guest's {agent_id, service_ids} (not just the anchor) and renders the intersection.
The lazy per-guest conflict banner stays as a backstop; create still row-locks.

Verified in-browser as the [[dev-test-login]] owner (shop 15): endpoint 200 from a real
session; **intersection proven live** — temporarily shortening agent 39's Monday shift to
12:00 dropped the 38∩39 grid to 11:00 while agent-38-alone stayed 16:00 (restored after);
config carries groupSlotsUrl; modal renders with no console errors; FormData shape
(participants[i][agent_id] + service_ids[]) accepted. Suite 535/535. No CSS rebuild (JS/PHP
only, no Tailwind class changes). GOTCHA confirmed: `agent_slots` table is the SOLO busy
mirror; group children never write it — always compute group availability from
BookingScheduleService (booking-table truth), which both paths now do.

## 2026-08-16 (later) — Group available-slots endpoint (mobile request)

Mobile dev sent `backend_group_booking_requirements.md`: they need one endpoint returning
the INTERSECTION of every party specialist's free slots so the group picks one shared time.
Recon (2 agents) surfaced the key fact: **group children are plain `booking` rows and never
write `agent_slots`**, so the app's current client-side intersection over
`POST /booking/agent-slots` (which reads the `agent_slots` table) UNDER-reports specialists
already committed to other parties. Built server-side on the authoritative dynamic engine:

- **`common\components\GroupBookingService::mutualFreeSlots($shopId,$date,$specialists)`** —
  per specialist, `BookingScheduleService::freeSlots()` at that participant's own duration
  (Σ service_period, floor MIN_DURATION=15; capability via `user_shop_service`), then
  intersect the start times. `freeSlots` reads the `booking` table so existing parties ARE
  seen busy. `OvernightPersist::rollSlotValues` applied per agent (day-shops byte-identical).
  New `durationForServiceIds()` helper. `@frontend` classes are reachable from common (the
  alias is registered in common/config/bootstrap.php — the same path createGroupBooking
  already uses).
- **`POST /booking/group-slots`** (`BookingController::actionGroupSlots`, bearer-required,
  beside agent-slots; route in `_CustomerUrls.php`). Accepts BOTH shapes: rich
  `participants:[{specialist_id|agent_id, service_ids[]}]` (accurate per-duration) and flat
  `specialist_ids[]` (+ optional shared `service_ids`) = the dev's original proposal.
  Response = `/booking/slots` shape `{date, slots:[{value,from,to,label}], book_now}`.
  Read-only; createGroupBooking still re-checks under a row lock.

Verified: +4 tests (suite **535/535**) — intersection == single when both free, a booked
specialist drops that time while the other keeps it, cannot-perform empties the set,
duration sum+floor. Live-smoked on shop 16 (agents 40/41): 27 shared slots, booking agent-40
@14:00 dropped 14:00 from the group set but not from agent-41-alone, cannot-perform → empty,
no-bearer → 401, both request shapes. Delivery note:
`ai_specs/03_API/GROUP_AVAILABLE_SLOTS_TO_MOBILE.md` (contract, business rules, 3 open Qs:
URL alias, per-specialist end times, "any specialist" mode; caching deferred with rationale).

## 2026-08-16 (later) — Solo-booking payment methods: verified live + dedicated mobile note

Owner ask: "the regular booking should have the booking's payment methods — cash, or
partial (deposit), or card — like group booking, and a file for it." Recon showed the
machinery ALREADY exists (NVG-BEA-001 + Issue-C): `payment-options` (salon-controlled
modes + deposit split), `book payment_mode=on_visit|deposit|online` (on_visit confirms
in one call), `/pay` settle (deposit vs full, idempotent), `confirm-on-visit`, breakdown
fields on every response. **No code gap** — re-verified live today (seeded shop-16
settings: 3 modes + 30% → 27/63 of 90 ✓; on_visit → Scheduled+balance=total ✓; deposit →
intent stamped pending ✓; disabled mode → 422 ✓; seeds cleaned). Deliverable =
`ai_specs/03_API/SOLO_BOOKING_PAYMENTS_TO_MOBILE.md` — the consolidated screen-by-screen
contract (chooser card copy, per-mode call sequences, confirmation matrix, errors, QA
checklist), cross-linked to the group + promotions notes. Note: package+cash mixing
inside ONE solo booking deliberately not built (exists only as group children) — stated
in the file.

## 2026-08-16 (later) — AUTH: customer sign-in hardening (pre-OTP token/PII leak + wrong-account resolution)

Spawned from the promotions session's observation, executed on the owner's go. THE bug:
`POST /user/customer-sign-in` returned a WORKING bearer + the FULL profile (cards
last-four, pending bookings, home location) for any posted phone number, pre-OTP.
Fixes in `api/controllers/UserController.php`:
- Sign-in response now `{token: null, profile: null, sms_response}` — safe because
  /user/verify ROTATES access_token on success (the sign-in copy always died at verify),
  so no client flow could depend on it. Emergency valve: env `AUTH_SIGNIN_TOKEN_COMPAT=1`
  restores the legacy body without a deploy (delete after mobile confirms).
- NEW shared `resolveOtpUser()` for the OTP trio (customer-sign-in / verify / resend-otp):
  email clause only when a non-empty email is sent (user.email is NOT NULL — the real
  historic hazard is '' matching, not NULL), customer-type scoping on sign-in, and
  mobile-VARIANT collisions ('966509333989' owner vs '509333989' customer — the live
  case that refused a legitimate customer) resolve by type scope / freshest REGISTER OTP
  (verify serves agents too — no type filter there).
- firebase_token no longer persisted at sign-in (attacker could re-route victim pushes
  pre-OTP); verify keeps persisting it.
Verified: +7 tests (suite **529/529**); live smoke — sign-in leaks nothing, the collision
number now signs in, OTP→verify→fresh token→authed endpoint 200. Delivery note:
`ai_specs/03_API/AUTH_SIGNIN_HARDENING_TO_MOBILE.md` (one QA ask + the compat valve).

**Sweep completed same day (2 commits, `efddbe6` + this):** the SAME fragile inline
lookup migrated onto `resolveOtpUser` everywhere it lived. **Sweep A (`efddbe6`)** —
sign-in family: `actionSingUp` existence check (closed a real dup-account hole — the old
raw-mobile match missed the stored `966…` form → second accounts; FLAG: a shape-variant
re-signup now returns "Already exist"), `actionSignIn` scoped to `$params['user_type']`,
`actionAgentSignIn` scoped to AGENT (FLAG: rare wrong-type collision message "no
permission"→"Invalid Data", both 400, and stops leaking cross-type existence). **Sweep B
(this commit)** — password-reset trio (email-only, deterministic, empty-email no longer
matches ''-rows) + `ProfileController::actionVerify` (email-conditional + normalized
mobile variants). Suite **531/531** (+4). Live-smoked: real customer/agent variant
collision (577700001) → each resolves to the right account; password-reset real/empty/
unknown email → sent/required/404. Delivery note updated with the full sweep table.

## 2026-08-16 — Mobile promotions flow: API parity with the demo-M6 checkout (4 commits + delivery note)

Owner directive: make the api/ tier run the demo customer-app promotion-booking flow and write
the mobile handoff doc. Walked the demo live first (Deals tab → salon → BookFlow → checkout →
Paymob → confirmed booking) and read `src/lib/promotions.ts` + CheckoutStep + store.createBooking
for the exact resolver semantics. All delivered ADDITIVE + OPT-IN (`resolve_promotions=1`):

- **`b293e40` W-P1 schema+validator:** promo_code += `trigger_type` (code|automatic — column
  named trigger_type, TRIGGER is MySQL-reserved + AR::trigger()), `min_order_value` (VAT-incl),
  `max_discount` (VAT-incl percent cap), `time_windows` (JSON, 0=Sun, [from,to) hours),
  `description_ar`. PromoCodeService: **platform-wide fix** (shop_id NULL was (int)-cast to 0 →
  every platform code advertised in /shops/deals but rejected wrong_shop at checkout — both
  validate() and the BookingForm lookup), min-order + time-window checks (FAIL-CLOSED without
  context), `excludeBookingId` (a booking's own redemption no longer counts against its
  per-customer cap on re-validation), `releaseRedemption()` (abandoned STATUS_NEW deletes were
  permanently burning per-customer caps; at-cap-only reactivation guard).
- **`7e4a8f1` W-P2 checkout resolver:** `resolveApplicable()` ranks shop+platform,
  automatic+typed-code promos by computed discount (`discountAmountExVat` — max_discount
  deflated by VAT, clamped to cart); preparing-booking gains `promotions[]` (best first,
  VAT-incl discount_amount, is_best/is_applied, bilingual titles), `applied_promotion_id`,
  `valid_code_reason` + self-contained `{en,ar}` `valid_code_message` (12-string demo rejection
  ladder in common ar+en). Selection precedence promo_id → valid typed code → best; a rejected
  code never blocks the best automatic (demo behaviour).
- **`2b23d57` W-P3 authoritative path:** booking-services releases stale-NEW redemptions before
  deleting; /booking/book re-validates the promo against the FINAL slot via
  `validateForBookingSlot()` (own-footprint neutralized: excludeBookingId + at-cap uses/status
  in-memory undo) → explicit **422 + promo_reason + bilingual promo_message** instead of silent
  repricing.
- **`f7dc071` W-P4 deals surfaces:** DealResource += trigger/title_ar/min/max/time_windows;
  applicable-deals accepts schedule_date+from_hour + returns per-cart `discount_amount` +
  `is_best`, ranked; platform deals now applicable end-to-end.

**Verified:** suite 445→**522/522 green** (+28 tests incl. resolver ranking, window boundaries
[from,to), self-footprint neutralization, release/reactivation guards); live curl smoke on the
api vhost with a real customer token — Tue/Fri window flip, typed-code-wins + BEST badge
persistence, not_found/min_order bilingual ladder ({min} substituted), explicit promo_id pick,
legacy no-flag byte-identical, create-stamps-promo, Friday book → 422 outside_time_window (ar
msg), abandon+rebook keeps uses=1 with the single redemption row moving to the new booking.

**Mobile handoff:** `ai_specs/03_API/PROMOTIONS_BOOKING_DELIVERY_TO_MOBILE.md` — screen-by-screen
(Deals tab / salon / slot / checkout incl. the rules-popover field map / payment / confirmation),
full contracts with real samples, the reason table, 6 contract warnings (always send the slot
with the quote; never total client-side; one promo per booking; salon code beats platform on
collision), deferred list (customer-types beyond first-time, category scope), and the OI-FIN-11
note (marketing fee currently on the promo-NET basis). 3 questions pending mobile's answers
(deep-link, used-promos surface, list ordering).

**Deferred/known:** portal promo editor does not yet expose trigger_type/min/max/windows/
description_ar (API-first delivery; add to `frontend/views/promo-code/_form.php` when the shop
UI catches up — scenarios already whitelist them). Sign-in matching quirk observed (not touched):
customer-sign-in matches `email IS NULL` rows first when no email param is sent.

## 2026-08-11/12 — Parity-audit S2 sweep session 1: 32 findings in 12 commits

Continuation of the audit program (owner: "do everything in the audit"). 24 S2 +
8 S3 closed (`28d10c4`…`a97012a`), suite 488/488 throughout. Highlights: both
status endpoints FSM-unified; collect amount server-derived; walk-in promo/
snapshots/earned-on-completion; retained cancel/no-show money → settlement pool
(reusing the api mint, re-based on the REAL collection); signed running balance;
ONE resolver each for withdrawal-minimum + settlement-hold; per-channel notif
approval; group actions through the FSM + read-time outstanding; package-sale
grace; redemption valued from the owned snapshot; refund anchor carries slot
time; subscriptions off the settlement rail; payout consults charge.status;
card invoice payment mirrors verify. Two items are BLOCKED on the guard-shared
hook (BE-F02 canPerform, SN-02 minuteLabel — BookingScheduleService needs an
owner-approved bypass); CLS-01 needs the api signup to persist the invite token.
Full status: `AUDIT_REMEDIATION_2026_08_11.md` (the tracker).

## 2026-08-11 — Parity-audit S1 program COMPLETE: 18/18 findings, 10 waves, 10 commits

Owner directive: work the "Mockup vs. Live audit" backlogs point by point. Execution
tracker (wave plan, standing decisions, per-item status, S2+ enumeration) =
**`AUDIT_REMEDIATION_2026_08_11.md`** — read it before continuing the program.

All 18 S1 findings closed on `tailwind-poc` (`0ab69ef`→`c858e6e`), suite grew
445→**480 tests, all green**, every wave commit carries its own worked-example test:
W1 walk-in payment split · W2 package-redemption guard (portal cancel now REJECTED;
redemption-time stamp left 🅿OI-CAT-01) · W3 completion raises ledger rows +
pending-promote · W4 legacy marketing rail gated by classification/grace/floor ·
W5 the three unread admin resolvers (carry-threshold → commercial_config, plan-aware
freeLimitFor, offer rateDiscountPct wired) · W6 SubscriptionBillingService extraction
(net renewals, no-card ⇒ dunning, dev-PAID gated non-prod, past_due retry+30d expiry) ·
W7 settlement↔fee bidirectional link (OQ-FIT-A: numeric ids) · W8 pay-now collect-only
mode + response reading · W9 walk-in checkPlacement + OvernightPersist date-roll
(guard-shared hook respected) · W10 payout nets processing + non-booking fees
(after W1, per the audit's hard constraint).

**Remaining:** S2 (27 shop + 38 admin) → S3 → S4 → UI sweeps per the tracker; 4
product rulings pending (REPORT.md §7). Earlier same day: social wiring + CORS fixes
(727b88d, 9a69004) + dontAsk permissions + tracker/task scaffolding.

## 2026-08-11 — Full-repo comprehension sweep → 2 wiring fixes (727b88d, 9a69004)

A 4-agent read of every tier surfaced two confirmed wiring gaps, both fixed same-day:

**`727b88d` fix(social):** the Upload-Post feature was coded across all 5 tiers but
`Yii::$app->uploadPost` / `->socialMedia` were **never registered in any config**
(CLAUDE.md + the client docblock claimed base.php; `git log -S` proves it never
existed) → every provider-touching action threw "Unknown component ID". Also
`social-post-status/poll` was absent from `schedule.php` (provider has no webhooks
→ posts stuck QUEUED forever). Registered both components (env-driven) + scheduled
the poller every-5-min withoutOverlapping in BOTH qc/prod blocks. Proven live:
console boot resolves both, real `listUsers()` → success=true.

**`9a69004` fix(api-security):** `ShopsController` re-declared corsFilter with no
`cors` config → Yii wildcard `Origin ['*']` on all shop read endpoints (the exact
Fix-1.3 bug RateController documents fixing). Dropped the re-declaration so the
parent allow-list filter applies. NEW guard in the RUNNING suite:
`common/tests/unit/controllers/ApiCorsWildcardTest` sweeps all 26 api controllers —
key insight: `api/tests/unit/*` is wired into **no codeception.yml and never
executes** (which is why both its instantiation bug `new $class()` and this
regression went unseen). Curl-verified: evil origin gets no ACAO; allowlisted echoes.

Suite after both: **451/451 green** (was 445). Also: `.claude/settings.local.json`
now `defaultMode: dontAsk` + broad allowlist (user asked to stop permission prompts).

## 2026-08-10 — Mobile answers Q1–Q10 implemented (group booking round 3)

Mobile answered the delivery note's questions (`answers_to_questions_1_to_10.md`);
everything they opted into is live: **Q3** `cash_due_now` (server-computed charge, in
create/view/pay) · **Q5** `status_summary` per-status child counts · **Q6**
`GET /booking/index?collapse_groups=1` (organiser row per party) · **S1** Paymob
webhook now settles a group server-side when the app dies post-payment — money code
extracted VERBATIM into `api\components\GroupPaymentSettlement` (settle +
postSettleSideEffects + organiserChild/normalisePaymentMethod) shared by
`/group-booking/pay` and `WebhookController`; **contract: mobile's Cloud Function must
set the Paymob order merchant_order_id = "GRP-YYMM-XXXXX"** (idempotent by tran_ref
either way) · **S4** 3 bilingual organiser pushes in `NotificationHelper`
(groupBookingCreated / groupBookingConfirmed — fires from BOTH settle paths /
groupParticipantCancelled), routes `navagoo://group/details/<gid>`, data.type
`group-booking`. Q1/S2 declined by mobile (they keep their Firebase CF). Suite still
**445/445** post-refactor; all docs updated (delivery note "answers received" section +
red action box for the CF change; flow doc samples).

## 2026-08-09 — Unit suite 445/445 green + mobile group-spec gaps closed (87d9cef, c87069d)

**Tests (87d9cef):** the 21 pre-existing failures are gone — all were stale/broken TESTS,
no code bugs: money-format tests updated to the shipped ⃁ glyph + ring badges (landed via
`fb0a405`), SmsLogTest rewritten to the SHIPPED OTP design (CSPRNG 4-digit + env-allowlist
1111 bypass + NEW coverage of the M2 prod verify-guard via \$_ENV pinning),
OtpVerificationRateLimitTest fixed (was error'ing on nonexistent `User::phone`; now seeds
sms_log directly), TokenExpirationTest plants the expired token post-insert
(updateAttributes) because Fix-3.1 behaviors deliberately stamp fresh tokens on insert.
Full suite: **OK (445 tests, 1206 assertions)** — assertions UP from 1129.

**Mobile spec (c87069d):** `BACKEND_GROUP_BOOKING_SPECIFICATIONS.md` (mobile team) audited
against code — most items already live; the real gaps were additive response fields, now
shipped: shapeGroup + `amount_paid_now`/`name`/`deposit_percentage`/group `payment_mode`/
`shop{...cancellation_policy}`/`invoice` (organiser-anchored Payment PDF), shapeChild +
`services[{id,name,price,period}]`, BookingResource + `group_booking_id`+`is_group_booking`
(list → group-details routing). Flow doc sample updated (+ fix: payment_mode is lowercase).

## 2026-08-04 — Mobile-audit API work: buckets A+B implemented (4 commits)

Follow-up to `NVG_MOBILE_API_AUDIT_VERIFICATION_2026_08_04.md` (the code-verified
review of the mobile team's `NVG_FINAL_REVIEW.md`). Owner chose **"execute all,
including contract changes"**; all of it built additively so the CURRENT mobile
build keeps working:

- **`47ab9bf` (bucket A, additive)** — `/my-packages` + `eligible_service_ids` /
  `services` / `eligible_specialists` / `price` / `per_session_price`;
  `preparing-booking` + `valid_code` / `promo_code` / `promo_code_id`;
  group-create 409 + `participant_index`; `GET /shops/:id` + `payment` block
  (`allowed_payment_methods` + `deposit_percentage`, on_visit|deposit|online,
  no-settings ⇒ online-only); `AgentResource` + inline `service_ids`
  (user_shop_service). Verified live via curl.
- **`521f7ca` (B4)** — standard error envelope: `sendFailedResponse` now always
  emits a flat `message` alongside the unchanged `errors`; `site/error` emits the
  same envelope (keeps legacy `error`/`code`) **and fixes a real always-500 bug**
  (`instanceof \HttpException` tested a nonexistent global class → every framework
  error returned 500; now `yii\web\HttpException::statusCode`). Verified: unknown
  route now 404 with the envelope; bearer-auth 401s natively carry `message` too.
- **`139ee21` (B3)** — per-branch payment: NO migration needed — a branch already
  IS a `Shop` (`parent_shop_id`/`inherit_parent`) with its own possible
  `ShopPaymentSettings` row. Gap was inheritance only:
  `ShopPaymentSettings::findForShop()` now falls back to the PARENT's settings for
  a branch with none of its own + `inherit_parent=1` (one level). All resolution
  points funnel through `findForShop` (solo payment-options, group booking,
  shop-view block — the last switched from `findOne`).
- **(this commit) B1+B2 — group booking contract expansion** (BRD scope expansion
  recorded in `NVG_BEA_011_GROUP_BOOKINGS_PLAN.md` under W6): per-participant
  `time_slot` "HH:MM" (omit ⇒ shared start unchanged; BR-G06 distinct-specialist
  generalised to per-specialist interval-overlap in BOTH controller and service;
  per-guest placement checked at the guest's own start; booking_date stamped
  per-guest) + per-participant `package_redemption_id` (exactly ONE included
  service; entitlement locked FOR UPDATE, re-verified, session(s) decremented
  atomically; child stamped PACKAGE/SCHEDULED with cash 0 so `/group-booking/pay`
  naturally excludes it from the Paymob total; derived charges swapped for
  `buildPackageRedemptionCharges` pre-commit; `shapeChild` + `package_entitlement_id`).
  i18n: 2 new backend keys (ar+en). GroupBookingServiceTest: 15/15 green post-change.

**Trap fixed en route:** `codecept run unit <Name>` needs the path
(`components/GroupBookingServiceTest.php`) from `common/`; only the `unit` suite is
configured (`common/codeception.yml`) — `tests/functional/` is NOT runnable here.

## 2026-08-02 — Admin navbar rebuilt to demo TopBar parity (committed a8548b7)

User: "the nav bar on the admin not the same like the demo." The admin navbar
(`backend/views/layouts/_tw_admin_navbar.php`) was far barer than the demo admin
TopBar (`Navagoo_MI` TopBar.tsx `variant="admin"`): date-only clock, a single
globe locale link, name+logout with no menu, and no env badge / bell / what's-new /
how-to. Rebuilt to the demo's admin variant, in demo order: **EnvBadge** (YII_ENV →
dev/test/prod pill) · **live ticking clock** (date · h:mm:ss a) · **EN | العربية
segmented toggle** (demo LanguageToggle/Segmented; each option a `/site/set-locale`
link, active = current) · **notifications bell** dropdown (demo admin stub, "You're
all caught up") · **account dropdown** (avatar + name + chevron → "Platform
operations" + Logout) · **What's new** + **How to** buttons opening aurora modals.

Reused the shop navbar's proven PHP patterns (`frontend/views/layouts/_tw_navbar.php`).
Backend had **no** modal shell or modal CSS, so added `backend/views/layouts/_tw_modal.php`
(twin of the frontend partial) + the modal enter/leave block to `backend/web/css/tailwind.src.css`
(aurora-core.js — the `[data-modal-open]` controller — was already published on the admin
layout by AuroraDialogAsset). Admin CSS rebuilt (`npm run build:css:admin`).

**Deliberately NOT ported** (pure demo scaffolding, no real back-office backing): the
god-mode identity switcher (UsersRound), the flask demo-controls drawer, and the
"Demo messages" outbox (Inbox). The demo admin bell is itself a stub, mirrored as such.

i18n: +25 `backend` keys in both `common/messages/{ar,en}/backend.php` (reused existing
Live/Notifications/Close/Logout/"You're all caught up"). Bilingual hook passed.
**Browser-verified end-to-end** (qa_admin, EN + AR/RTL): env badge, ticking clock, the
segmented toggle switching locale + active state, bell dropdown, account dropdown, and
BOTH modals rendering full bilingual content; RTL mirrors correctly; zero console errors.
`php -l` clean on all touched files.

## 2026-08-02 — Shop navbar aligned to demo TopBar (committed 8f495d8)

Follow-up to the admin navbar work ("do the same with the shop"). The shop navbar
(`frontend/views/layouts/_tw_navbar.php`) already had most of the demo chrome (env
badge, live clock, bell, outbox, account, what's-new/how-to, flask), but two gaps vs
the demo TopBar (`variant="shop"`): (1) language was a single **globe** switch icon —
replaced with the demo's **EN | العربية segmented toggle** (each option a
`/site/set-locale` link, active pill = current locale); (2) **element order** didn't
match the demo. Reordered to the demo sequence: EnvBadge · clock · **language** · bell ·
outbox · account · what's-new · how-to · flask (was: env · clock · what's-new · how-to ·
flask · bell · outbox · globe · account). Bell/outbox/account/flask/modals/clock JS
untouched (same ids + data-attributes, just repositioned). No new i18n (literals +
existing keys). Shop CSS rebuilt. **Browser-verified** (Jay/Beauty Center, EN + AR/RTL):
segmented toggle switches locale with correct active state, order matches the demo, the
What's-new modal opens, RTL mirrors correctly, zero console errors; `php -l` clean.

## 2026-07-27 — Auto-translate (EN↔AR) system ported from the demo, both portals

User ask: "نظام الترجمة التلقائي في الديمو — انقله كله وحطه في الأماكن المستخدم فيها".
Demo seam = `src/lib/translate.ts` (MyMemory free API → salon glossary → manual) +
`BilingualField` behavior (blur gentle-fill, never clobber manual, force button,
auto badge). Ported as **`frontend/web/js/aurora-translate.js`** — one copy, both tiers
(TailwindAsset + AuroraDialogAsset publish). Wiring: explicit `data-translate-pair`
attrs + automatic detection of every `X[attr_en]`↔`X[attr_ar]`/`X[attr]` (and id
`<base>_en`) pair on any page — covers all MyMultiLanguageActiveField forms in both
portals with zero per-view edits. Explicit wiring added to: faq/support modals,
push-notification composer, notification-trigger (name + channel templates).
tailwind.admin.config.js now scans the shared frontend JS so the admin bundle keeps the
injected classes. Browser-verified end-to-end on /shop-service/create (glossary hit,
live-API hit AR→EN, manual-override protection, badges/tint/buttons — screenshot in
session). Demo-bilingual-but-portal-single surfaces (NOT wired; schema+API change,
flag-first): agents/Team name-title-bio, promo-code label, admin plan/offer names.
Note: MyMemory is a client-side external call exactly like the demo; the single swap
point for a keyed provider is `window.ngTranslate.translate`.

## 2026-07-27 — Admin "Support & content" rebuilt to full demo parity (user report: "مش زي الديمو")

`/faq/support` was a read-only overview (accordion of active FAQs + a `page` CRUD table +
a contact form with Phone/YouTube and no TikTok). Rebuilt as the demo twin
(`portals/admin/SupportContent.tsx`, seen rendered at `#/admin/content` first):

- **FAQs tab** — For customers / For shops sub-tabs (existing `faq.audience` 0/1 from
  m260717_120000), single-open accordion (EN + AR answers on expand), per-row ↑/↓ reorder
  (per-audience `sort` swap w/ collision renumber), edit + delete (ngConfirm), "Visible in
  help centre" toggle (status flip, in-place DOM update), Hidden badge + opacity-55, and a
  bilingual Add/Edit modal (all-4-fields-required amber hint banner = demo requiredHint).
- **Policies tab** — new migration `m260727_120000_seed_support_policy_pages` seeds four
  `page` rows (`policy-{privacy,terms}-{customer,business}`) with the demo's verbatim
  AR/EN copy (AR = base cols; EN = `translations_with_text`). Card grid (FileText tile,
  Customer=blue / Business=brand badge, first body line) + bilingual Title/Body modal →
  `support-save-policy` (slug-whitelisted, NOT a generic page editor). Distinct from the
  file-based `policy_document` table (admin Settings tab).
- **Contact tab** — exactly the demo's 7 channels: Support email, WhatsApp, Instagram,
  Facebook, X (Twitter)→legacy `twitter` col, LinkedIn, TikTok (col existed from
  m260717_120000). Phone/YouTube dropped from the UI only.
- **Endpoints** (FaqController, POST+CSRF, JSON): `support-save-faq`, `support-move-faq`,
  `support-toggle-faq`, `support-delete-faq`, `support-save-policy`. Reads/writes are raw
  queries because MultiLanguageBehavior swaps base attrs per app language outside its
  `admin_routes` — "give me AR and EN at once" isn't reliable through the AR model.
- **i18n**: ~35 new keys in `common/messages/{ar,en}/backend.php`, Arabic verbatim from the
  demo `ar.ts` admin.support block. Admin CSS rebuilt (`npm run build:css:admin`).
- **Verified**: php -l all files green; migration applied; inline JS `node --check` clean;
  unauthed `/faq/support` → 302 login (no 500); `/faq/support` added to
  `tests/smoke/render-smoke-admin.sh`. **Full in-browser interaction pass is BLOCKED on
  the current admin password** (stale in memory; hash-swap not permitted) — run
  `SMOKE_PASS=<current> tests/smoke/render-smoke-admin.sh` + click-through when available.
- **Open API flag** (unchanged behavior, needs sign-off): `api/faq` returns ALL FAQ rows;
  once shop-audience FAQs are created they'd surface in the mobile customer app — the
  endpoint should filter `audience=customer` (shared-contract change, flag-first rule).

## 2026-07-26 — NVG-BEA-009 In-App Deals & Promotions: COMPLETE, W1–W5 in one day (uncommitted)

Closes out `ai_specs/05_PLANS/NVG_BEA_009_DEALS_PROMOTIONS_PLAN.md` (now **COMPLETE W1–W5**,
started and finished the same day). Full gap analysis, wave detail, and evidence live in that
plan's own tracker — this entry is the summary.

**Waves.**
- **W1 (schema + validator core):** additive migration
  `m260726_150000_add_deals_promotions_constraint_fields` on `promo_code`
  (`per_customer_cap`, `service_scope` JSON, `first_time_customer_only`, `active_from`,
  `active_until`) + `user_promo_code.booking_id`. New `common/components/PromoCodeService`
  (`validate()`, `isVisible()`, `recordRedemption()`) revives the previously-dead
  `uses`/`remaining_uses` counters and the unused `user_promo_code` join table. No `api/`,
  `frontend/`, `backend/`, or `common/messages/` touched — additive only.
- **W2 (flagged api enforcement wiring):** `api/models/BookingForm.php`
  (`preparingWithPromoCode`/`preparingPackageWithPromoCode`) delegates every constraint check
  to `PromoCodeService::validate()`; `BookingForm::save()` calls `recordRedemption()` post-persist
  for the services-booking flow. `api/controllers/ShopsController.php::actionDeals()` now
  filters via `PromoCodeService::isVisible()` instead of the old ad-hoc legacy-expiry check.
  Request/response shapes unchanged in both files.
- **W3 (shop portal form):** `frontend/controllers/PromoCodeController.php` +
  `frontend/views/promo-code/{_form,index}.php` — constraint fields (per-customer cap,
  first-time toggle, service-scope multi-select shop-scoped via an IDOR-guard whitelist,
  active-window datetimes) + list columns (usage vs cap, constraints, window, cap-reached
  status badge).
- **W4 (admin overview + additive api surfaces):** `backend/promo-code/index` platform-wide
  Deals overview (all shops, not just platform-wide codes — see bonus fix #3 below) with
  one-click audited Deactivate. API additive: `GET /shops/<id>/applicable-deals?service_ids=`
  (new endpoint) + `active_deals` key on `GET /shops/<id>` (existing keys byte-identical).
  Second sanctioned api edit: package-booking redemption recording in
  `BookingController::actionBookPackageServices` (closes the W2-flagged gap — the
  chip for that gap, `task_1b33241f`, is resolved/stale and was dismissed).
- **W5 (this entry) — translations + tests + smoke + docs.** See the plan doc's tracker for
  full detail; summary below.

**Three bonus root-cause fixes found + fixed along the way** (all documented in-place in the
plan tracker and code comments, not separate follow-up tickets):
1. **`UserPromoCode` `BlameableBehavior` bug (W1).** The generated base model wired a
   `BlameableBehavior` for `created_by`/`updated_by` columns the `user_promo_code` table never
   had — any `save()` threw `UnknownPropertyException`. Almost certainly why the table sat
   completely unused since 2023 despite being part of the original schema. Fixed by overriding
   `behaviors()` in `common/models/UserPromoCode.php` to match the real columns.
2. **Package-booking redemption recording (W4).** `BookingController::actionBookPackageServices`
   persists its `Booking` directly rather than via `BookingForm::save()`, so W2's redemption
   recording never fired for package bookings — flagged as a known gap in W2, closed in W4.
3. **Admin search `shop_id` pin (W4).** `backend/models/search/PromoCodeSearch::search()` had a
   leftover `shop_id = Yii::$app->user->identity->shop->id` expression from a shop-portal-style
   search — an admin identity has no `shop` relation, so it silently evaluated to
   `shop_id = NULL` and the admin Deals list only ever showed platform-wide (null-shop) codes.
   Removed the pin; the admin overview now lists every shop's codes with a shop filter.

**API-additive flag for the mobile team** (same posture as BEA-010/011 — build-when-ready, no
action required now): the customer-facing deals surface gained three additive pieces this wave,
all backward compatible (no existing key removed or reshaped):
- `GET /shops/deals` — existing endpoint, visibility filter tightened to also honor
  cap-reached / date-window rules (fewer, more correct results — same response shape).
- `GET /shops/<id>/applicable-deals?service_ids=1,2` — **new** endpoint, optional-auth, returns
  per-deal `applicable`/`applicable_reason` for auto-apply support.
- `active_deals` key on `GET /shops/<id>` — **new** key, `isVisible()`-filtered deals for that
  shop (shop-scoped ∪ platform-wide).
- `BookingForm` enforcement note: promo-code validation on `POST` booking-creation endpoints is
  now stricter (per-customer cap, first-time-only, service-scope, date window all enforced,
  where previously only status/shop/legacy-expiry were checked) — a booking that used to
  succeed with a promo code that violates one of these NEW constraints will now be rejected via
  the existing `valid_code = NOT_VALID` error path. No new response fields; existing error
  contract unchanged.
- Documented in `ai_specs/03_API/API_CONTRACTS.md` §3.4.

**W5 detail (translations + tests + smoke).**
- *Translations:* 22 `frontend`-category + 9 `backend`-category keys (31 total, including one
  pre-existing unrelated gap — `'Data has been created successfully'` in the `backend`
  category — found and closed in the same sweep) added to
  `common/messages/{en,ar}/{frontend,backend}.php` with real Arabic, including full CLDR plural
  categories for the backend's `{count, plural, one{# service} other{# services}}` key. Parity
  clean (`frontend: en=1945 ar=1945`, `backend: en=3382 ar=3382`); `php -l` clean.
- *Tests:* added one integration-style test
  (`testRecordRedemptionAtCapFlipsIsVisibleAndBlocksTheNextValidateCall`) closing the one gap
  in the BRD's 4 acceptance criteria that W1's 36-test suite hadn't directly pinned — the
  end-to-end "recordRedemption reaching cap flips discovery-feed visibility and blocks the next
  redemption attempt" path. `PromoCodeServiceTest`: 37/37 green. Full `common` unit suite: 438
  tests / 1044 assertions, 6 errors / 15 failures — identical pre-existing baseline set
  (`OtpVerificationRateLimitTest`, `AuroraTest`, `CalendarFormatTest`, `SmsLogTest`,
  `TokenExpirationTest`), zero regressions.
- *Smoke:* backend `/promo-code/index` 200 Arabic; frontend `/promo-code/index` +
  `/promo-code/create` 200 Arabic (shop-owner login); api unauth `/shops/deals`,
  `/shops/1/applicable-deals?service_ids=1`, `/shops/1` (checked for `active_deals`) all 200,
  no 500s. No error/exception markers in any response body or runtime log.

**Out of scope (per BRD, unchanged):** Navagoo-funded promos, referral codes, deal push
notifications. Not touched this feature.

**Scope discipline:** no `api/` files touched in W5 (W2/W4 are the only two sanctioned api
edits across the whole feature, both already landed and flagged above). No production code
changed in W5 — it found zero real bugs requiring a fix.

## 2026-07-22 — NVG-BEA-003 Subscription Billing & Plan Management: W6 (final wave) — translations + tests + smoke (uncommitted)

Closes out `ai_specs/05_PLANS/NVG_BEA_003_SUBSCRIPTION_BILLING_PLAN.md` (now **COMPLETE W1–W6**,
all in one day). Waves W1–W5 (schema, `PaymobSubscriptionHelper` + saved-card rail,
`SubscriptionBillingController` recurring-billing console engine, `EntitlementFilter` +
`_locked` gating + no-plan banner, admin dashboard polish) landed earlier the same day — see
the plan doc's live tracker for their full detail. This entry covers W6 only.

**Translations.** Grepped every uncommitted BEA-003 file's actual diff (not the plan doc's
key lists) for new `Yii::t('frontend'|'backend', …)` calls, including multi-line calls
(email bodies in `SubscriptionBillingController`/`FreezeReminderController` — a naive
single-line grep undercounts these). Per instruction, the same sweep also covered the
co-resident NVG-BEA-005 (attribution/freeze-list) files sitting in the same working tree
(`CustomersController`, `CustomerInvitationsController`, `CustomerClassificationService`,
`CustomerFreeze`, backend `UserController`/`views/user/people.php`,
`FreezeReminderController`, `WalkInBookingService`) — one sweep, both features, since
`common/messages/*` had a single writer this run. **+53 frontend keys / +22 backend keys**,
added to all 4 message files (en=identity, ar=natural financial-register Arabic,
placeholders untouched). `php -l` clean; key-count parity verified programmatically
(frontend en=ar=1898, backend en=ar=3320, both exactly the pre-sweep baseline +53/+22).

**Unit tests — 39 new/extended, all green (107 assertions):**
- `common/tests/unit/models/NavagooSubscriptionPlanTest.php` — `priceForPeriod()` /
  `perMonthPrice()` / `savePctForPeriod()` / `isValidPeriod()` across all 3 billing periods.
- `common/tests/unit/models/ShopSubscriptionTest.php` — `stampTerm()` term-advance math for
  all 3 periods, `addMonthsClamped()` end-of-month/leap-year clamping, status predicates.
- `common/tests/unit/components/EntitlementServiceTest.php` (+1 test) — the
  `subscriptionAccessState()` cancelled-subscription time-boundary branches (`grace` while
  still inside the paid term, `locked` once `current_term_end` elapses) that the pre-existing
  test didn't reach.
- `common/tests/unit/console/SubscriptionBillingControllerTest.php` (new `unit/console/`
  dir) — the private `applyDunning()` dunning state machine via `ReflectionMethod` against a
  real controller instance (`Yii::$app` is a bootable console app per
  `common/tests/_bootstrap.php`, so this needs no extra scaffolding): 1st/2nd failure stay
  `past_due`, 3rd within the 7-day window → `flagged`, a stale window resets to a fresh
  attempt-1 instead of false-flagging, `past_due_since` stamps once.
- `common/tests/unit/models/UserCardTest.php` — `setDefault()` guard clauses +
  `defaultForUser()`'s two DB-safe paths (falsy id short-circuits; absent id matches 0 rows).
  The actual `updateAll()`+`save()` write path isn't re-exercised in-suite (no Db
  module/rollback in `unit.suite.yml`) — already verified live during W2.
- **Skipped on purpose**: the trial_consumed one-time-trial ternary
  (`NavagooPlansController.php:278`) — inline in a large stateful controller action, not
  extracted into a unit, and not worth a production-code extraction with no bug behind it.
- Run: `docker exec projects-webserver bash -c "cd /var/www/html/Navagoo && vendor/bin/codecept run unit -c common"`
  (the `-c common` is required — no top-level `codeception.yml`). New tests 39/39 green.
  **Full suite: 304 tests / 690 assertions / 6 errors + 15 failures — byte-identical to the
  documented pre-existing baseline** (`OtpVerificationRateLimitTest` ×6 errors;
  `AuroraTest`/`CalendarFormatTest`/`SmsLogTest`/`TokenExpirationTest` ×15 failures combined).
  Zero regressions from either the BEA-003/BEA-005 production diff or the new tests.

**Smoke matrix.** Backend (session cookies): `/shop/plans` → 200, `/shop/subscriptions` →
200, 0 error markers. Frontend: logged in live as the dev shop-owner via `/sign-in/login`
(gotcha: the login form field is `LoginForm[username]`/`LoginForm[password]`, NOT
`SignInForm[...]` despite the controller name — got a 302 + `_identity_frontend` cookie once
corrected) → `/navagoo-plans/index` 200, `/settings/index` 200, `/` 200, all clean. Console:
`php console/yii subscription-billing/run --dry=1` → clean 3-phase dry run, 0 due (matches W3).

**Deferred / needs a human decision (unchanged from the plan doc, not re-litigated this
wave):** real Paymob credentials must be provisioned in prod before the saved-card rail goes
live (dev/test always falls back to the simulated instant-PAID rail); the "same card for
general vs subscription billing" open question (Muhannad) is still open; proration is
explicitly out of scope per the BRD.

## 2026-07-16 — Demo Sync v0.26→v0.28 Phase B: Saudi Riyal symbol ⃁ (uncommitted)

Replaced all "SAR" text with the Saudi Riyal Unicode glyph ⃁ (U+20C1) across the entire portal (~74 files). See `DEMO_SYNC_V26_TO_V28_PLAN.md` Phase B for full details.

**Key deliverables:**
- Self-hosted Saudi Riyal font (emran-alhaddad, OFL) in both frontend + backend web roots
- `common\helpers\MoneyHelper` — central money formatting (HTML + plain text variants, locale-aware prefix/suffix)
- `ngMoney(v)` JS global in aurora-core.js for client-side formatting
- i18n message files updated (all 4: ar/en × backend/frontend)
- ~45 frontend + ~29 backend view files updated to use MoneyHelper
- JS files (shop-service-form, booking-new) delegate to ngMoney()
- API tier untouched (ISO 4217 `'currency' => 'SAR'` is a data field, not display)
- Bug fix: Navagoo Plans page had `Html::encode()` escaping the riyal HTML span — removed; JS `.textContent` → `.innerHTML` for money-displaying elements in the confirm modal

**Browser-verified:** Dashboard (EN+AR), Services, Finance/Earnings, Navagoo Plans — all render ⃁ correctly with proper prefix/suffix per locale.

## 2026-07-14 — Feedback-inbox sweep: 6 user-reported issues fixed (uncommitted)

Worked through runtime/feedback/inbox.md (the FAB widget's comments). All verified in-browser:
- **Analytics tooltip clipped** (07-13): KPI cards had `overflow-hidden` clipping the CSS InfoDot
  tooltip → removed from the 5 gradient cards (bg is on the div, rounding needs no overflow). BONUS:
  found 3 double-encoded strings in the view (`â¸`→`▸`, `â¥`→`≥`, `â`→`–`) that also broke the
  Yii::t lookups (message files had the CORRECT keys) — byte-fixed with perl, Arabic now resolves.
- **Analytics header font** (07-13): subtitle had a bold `font-semibold text-ink-soft` shop name +
  `•` — demo PageHeader is ONE plain muted line `{shop} · performance & financials`. Matched.
- **Analytics settlement bar** (07-13): verified current markup already matches demo (px-4 py-3,
  text-[10px] label, text-lg balance, rounded-2xl) — comment predated the 07-13 analytics pass.
- **Earnings in-store split zeros** (07-09): the strip summed `amount_collected` (the ONLINE column!)
  as "In-store collected" with a hardcoded 0/0 cash|card split (stale "no schema" comment) — the
  schema HAS `in_store_collected` + `in_store_method` (written by actionCollect). Now sums the right
  column with a real method split. Verified: 701.00 = 700 cash + 1 card (was "840.50 · 0 · 0").
- **Navagoo-plans past-due w/o invoices** (07-09): the bank-transfer subscribe writes the owed term
  to the CHARGE ledger but the Billing card only listed `invoice` rows → banner said "settle first"
  over an empty list. `actionIndex` now also fetches unpaid `TYPE_SUBSCRIPTION` charges and the view
  lists them as amber CHG-… rows in Outstanding.
- **Notifications page empty** (07-09): TWO stacked causes. (1) The 3 shop-audience triggers had no
  authored in-app templates + `approval_status=0` — the engine is deliberately inert then; authored
  bilingual `[token]` templates + approved (what the admin UI would do). (2) The WALK-IN create path
  (`WalkInBookingService::create`) never dispatched ANY events (the group path's comment even claims
  "the SAME two events the solo path fires" — it didn't); added the `booking_confirmed` +
  `new_booking_received` dispatch after commit, exception-guarded. **E2E-verified live**: created a
  real walk-in → `new_booking_received` row with `to_id=<owner>` → /notifications lists it + navbar
  bell badge=1. (Templates re-written via utf8mb4 after a CLI-charset mojibake; test bookings
  985/986 on 15–16 Jul left in the demo shop.)
- **Still open (business decisions, need direction)**: (a) "مكافآت المختصين" placement (07-09) —
  demo has no separate Specialists-Tips nav (tips live in Team payroll); (b) packages-vs-bundles IA
  (07-09) — demo keeps BOTH a Packages tab in the catalogue and a Package hub; ours' Services tab
  shows Packages(0); (c) bank-transfer subscribe flow wants receipt-attachment + admin approval —
  real feature build (upload + admin queue), not a fidelity fix.

## 2026-07-14 — Services INDEX page fidelity pass (uncommitted; triggered by user FAB feedback)

User's in-app comment (runtime/feedback/inbox.md, `/shop-service`, pinned `rect`): the show/hide
toggle icon isn't like the demo — then "في اكتر من مشكلة". Full `demo-fidelity` audit of
`index.php` vs `Services.tsx` (page shell 120-186, ServicesGrid 306-380, LifecycleActions 240-275):
- **Active toggle icon**: was lucide `toggle-right/left` (the pinned `rect`) → demo uses ONE `power`
  icon in both states, only its colour flips (accent-600 active / st-cancelled inactive). Fixed in the
  shared `$lifecycle` helper (services+routines+addons+bundles). Per-state demo tooltips added
  ("Visible in customer app"/"Hide from customer app"/"Activate"/"Deactivate", en+ar) + cursor-pointer.
- **Page header**: "Catalogue" → demo "Services & packages"; subtitle now "{shop} · catalogue".
- **Tabs**: dropped the icons inside the Segmented pills (demo = plain "Services (5)" labels).
- **Card money**: "150.00 SAR" → demo `money()` prefix "SAR 150.00" (incl. the strike-through price).
- **Card VAT line**: "Incl. VAT 19.57" → demo split "SAR 130.43 + VAT 15% SAR 19.57" @ text-[11px].
- **Duration**: "30 Minutes" → "30 min" (lowercase, backend 'min' key).
- **Card footer**: "Agent Count: 1" → joined specialist NAMES from the eager-loaded relation, or
  "Any specialist" when unlinked (demo `anySpecialist`).
- **Removed invented hover-lift** on cards (demo Card has no hoverLift on the catalogue grid).
- **Favicon** (separate user ask): layouts had NO icon link (tailwind.php) / a broken relative
  `img/c.png` (base.php) → all three tab-level layouts now link `/img/logomark-purple.png`
  (same asset+rel the demo's index.html uses). Kept as portal-extra: Service-Categories cover band,
  pagination. i18n +7 keys (shop en/ar). Lint clean, CSS rebuilt, browser-verified.

## 2026-07-14 — Add-service modal fidelity pass 2 via the NEW `demo-fidelity` skill (uncommitted)

The `.claude/skills/demo-fidelity` skill (born 2026-07-13 after the user caught repeated
"port-from-memory" bugs) was applied to the REMAINING un-audited Add-service blocks
(Variants · Extras/Routines pickers · identity block). Exact-JSX audit vs `Services.tsx`
`VariantPicker`/`MultiSelectPicker`/extras Section found + fixed:
- **Variants**: add-button label is literally "Variants" (t shop.services.variants), NOT "Add a
  variant"; container `space-y-2` (was 2.5); remove btn `size-7` (was 8); hint `mt-1 block text-xs`;
  in-modal the block carries its own SectionLabel.
- **Extras**: the "+{n} min added" line renders ABOVE the 3 pickers (`mb-2 text-xs font-semibold
  text-accent-700`) with the demo's verbatim short text (was below, with an invented longer string);
  grid is `gap-4` (my earlier sed had collapsed it to gap-3 along with the pricing grid — pricing
  IS gap-3, extras is gap-4).
- **Freebie pickers rebuilt as demo `MultiSelectPicker`**: selected CHIPS row (brand-50 pills with
  removable X) → search whose placeholder gains " · {n} selected" → ONE bordered `max-h-60`
  scrollable box of button-like rows with a custom `size-5` check square (checked = brand-600 +
  white check, row bg-brand-50) + "No matches" empty state. Native checkboxes stay in the DOM
  (`peer sr-only`) so `FreebieLink[...][]` still POSTs unchanged; chips/counter are ~50 lines of
  vanilla JS in the view. Browser-verified: check → chip + "· 1 selected" + rollup line; chip-X →
  unchecks; search "zzz" → "No matches".
- i18n: +"+{n} min added" / +"{n} selected" (en/ar backend). Agents column (portal-extra, no demo
  equivalent) kept as card grid + search. `shop-service-extras.js` untouched (same `#extras-rollup`
  contract; only its i18n string changed at the view layer).

## 2026-07-13 — Shop Analytics header/settlement-bar/KPI-band demo-parity pass (uncommitted)

Reviewed vs React source (`Navagoo_MI` @ v0.26.0: `portals/shop/Analytics.tsx`,
`components/ui/Field.tsx` Segmented, `Kpi.tsx` KpiCard, `Card.tsx`). `frontend/views/shop-analytics/index.php`:
- **Period selector was the wrong component entirely** — a brand-filled rounded-full pill track; demo uses
  `Segmented size="sm"`: quiet `bg-slate-100 rounded-xl` track, `rounded-lg` pills, active = `bg-white
  text-brand-700 shadow-xs` (no fill), inactive `text-ink-muted hover:text-ink`. Rebuilt 1:1.
- Settlement-balance bar: demo `Card` override is `px-4 py-3` (was `px-5 py-3`) and its label is
  `text-[10px]` (was `text-[11px]`) — both corrected.
- KPI band (`variant="gradientFill"`, default density per `Kpi.tsx`): icon glyph is `size-4` inside the
  `size-10` roundel (was `size-5`); value text is `text-[28px]` (was `text-[26px]`). Both corrected across
  all 5 tiles (Revenue/Net earnings/Bookings/Repeat rate/Utilization).
- CSS rebuilt, PHP lint clean, browser-verified against the demo pixel-for-pixel on the period control.

**Follow-up same day — chart-size pass.** User flagged charts were still visibly undersized. Measured actual
rendered ring diameters via Chart.js's `getDatasetMeta()` (arc `outerRadius`, in CSS px — no `/devicePixelRatio`
needed) against the demo's Recharts `<Pie>` bbox (via `[class*="pie-sector"]` union, excludes label-line noise):
- **Revenue-by-category donut**: our `size-52` (208px) box + `radius:'60%'`/`padding:24` rendered a ring only
  **~94px** across (45% fill) vs the demo's fillHeight ring at **~186px**. Root cause: the container was sized
  like a small "compact" donut when the demo treats this one as the LARGEST chart on the page. Fixed: box →
  `size-64` (256px, matches `ana-status` below it), `radius:'60%'→'70%'`, `padding:24→32` → ring now **~132px**
  (52% fill, matches `ana-status`'s ratio). (Tried `size-72` first — ring hit ~157px but squeezed the category
  legend list into truncating "Spa and Wellness"; `size-64` was the balance point.)
- **Customer-mix's 3 mini-donuts** (`ana-mix`/`ana-source`/`ana-origin`, New-vs-returning / Navagoo-vs-shop /
  App-vs-walk-in): `size-40` (160px) + `radius:'58%'`/`padding:24` → ring only **~65px** (41% fill) vs demo's
  **~176px** (69% fill, confirmed the demo does NOT use DonutChart's `compact` variant here despite 3-across).
  Fixed: box → `size-64` (256px), `radius:'58%'→'78%'`, `padding:24→16` → ring now **~175px**, matching the demo.
- **Not yet re-measured** (lower priority, not visibly broken in the screenshot pass): Booking-status / On-time /
  Peak-hours / Busiest-days row — `ana-status` itself (`size-64`, `radius:'68%'`, `padding:32`) already renders a
  reasonable ~128px ring; worth a follow-up pass if the user flags that row too.
- CSS rebuilt, PHP lint clean, browser-verified — donut sizes now closely match the demo's fill ratios.

## 2026-07-13 — Shop-service Add-service modal + form ordering/style pass (uncommitted)

Reviewed `/shop-service` Add-service drawer vs demo (`Navagoo_MI` v0.26.0: `Services.tsx` Add-service Modal +
`components/ui/Modal.tsx` + `BilingualField.tsx` + `Button.tsx`). Wrapped the existing full-page form as an
`ngIframeModal` (max-w-4xl `size="xl"`, sticky Cancel + Add footer, amber `requiredHint` banner on
missing name/base-price). Description turned into a demo `BilingualField` (EN + AR side-by-side + "fill
one — the other auto-translates" hint). Cancel → `postMessage('close')`, success → `postMessage('saved')`
so the parent aurora-core closes+toasts+refreshes. Controller `actionCreate/actionUpdate` switch to
`iframe_aurora` layout on `?iframe=1`. Full-page mode preserved as fallback (no iframe query).

**Form restyle pass** (per user "رتب فعلاً و اظبط الاستايل"): every section wrapper is now `$sectionOpen()`
/ `$sectionClose()` — full-page mode still renders the fat icon-card headers; **modal mode** renders the
demo's compact vertical brand-bar `|` mark + small bold title (no card, no shadow, no ring — flat, one
continuous body), matching Services.tsx exactly. Basic Information swapped to `flex flex-col gap-4` +
`order-*` classes so the modal shows: Name → Description → Image → Gallery (demo order); the "cannot
upload gallery for new records" placeholder is hidden in modal-new (adds no signal). Pricing/Details/
Variants/Routines/Agents keep their layouts, only their outer wrappers switched.

+i18n (en/ar backend): "To save, add:" / "a name" / "a base price" / "fill one — the other auto-translates".
PHP lint clean, CSS rebuilt, browser-verified end-to-end (modal opens, sections render in demo order with
compact marks, Cancel closes cleanly). All non-modal callers unchanged.

## 2026-07-13 — Booking-calendar DAY view demo-parity pass (uncommitted)

Reviewed `/booking-calendar/index?view=day` vs the demo (`DayCalendar.tsx`/`SpecialistColumn.tsx`/
`AppointmentBlock.tsx`) in BOTH codebases. The day view was already ~99% matching (specialist columns +
tints, time gutter, status-count chips, Block time, Fullscreen, zoom slider, Off/UNAVAILABLE shading,
per-block colored border+tint). Two real differences, both fixed route-locally (shared
`BookingScheduleService`/`BookingCalendarPresenter` are hook-guarded — never edited):
- **Date header**: shared `dayLayout()` returns ICU `'full'` ("Monday, July 13, 2026", US month-first);
  demo `dateLong` is DAY-first. `BookingCalendarViewModel::day()` now overrides `$day['label']` with
  `asDate($ts, 'EEEE, d MMMM y')` → "Monday, 13 July 2026" / "الاثنين، 13 يوليو 2026". (Also fixed the same
  in `emptyDay()`.)
- **Block customer name**: showed the FULL name ("Layla Ahmed"); demo `AppointmentBlock` uses
  `customer.name.split(' ')[0]` (first name only). `_booking_block.php` now sets `$primary =
  CalendarFormat::firstName($customer)` (full name stays in the block's title/tooltip). Browser-verified:
  blocks now read "Layla" / "Noura" / "Refactor".
- **Column header tint** (user: "the agent's background should start from the name"): the header used
  `color-mix($color 16%, var(--color-page))` but `--color-page` is NOT emitted by our Tailwind-v3 config
  (it only makes the `page` UTILITY, not a `--color-*` var) → the color-mix silently failed → the header
  rendered UN-tinted while the body (which uses `…, white`) was tinted, so the wash looked like it started
  BELOW the name. Fixed in `_specialist_column.php`: pinned the page hex `#f4f7f7` (= config `page`). Now
  the specialist tint starts at the header/name and flows into the column, matching the demo.
- **Navbar "What's new" + "How to"** (shared `_tw_navbar.php`, demo `TopBar` WhatsNewModal / DemoGuideModal
  parity): added two buttons — Sparkles "What's new" + HelpCircle "How to" — with the demo's
  `ring-1 ring-inset ring-white/25` pill styling, opening two page-level aurora modals (via `_tw_modal` +
  `data-modal-open`). "How to" = the demo DemoGuideModal layout (emoji · title · text on slate cards) but
  REAL shop-portal content (bookings / services / team / finance / notifications / Navagoo Plans, + the
  demo flask row when `$demoGate`). "What's new" = demo FeatureRow layout (date · text, newest-first) with
  a genuine changelog of recent portal features. +24 bilingual i18n keys. Browser-verified both modals.
- **Navbar EnvBadge + Outbox** (demo `TopBar` EnvBadge + "Demo messages"): (1) an environment pill next
  to the date mapping `YII_ENV` → dev "Dev" (amber) / test "Test" / prod "Live" (teal), demo styling.
  (2) an Inbox icon (non-guest) with an unread-style count opening a page-level "Sent messages" modal —
  the shop's OUTGOING customer notifications via a new `NotificationsViewModel::outbox($userId)` (mirrors
  `bell()` but keyed on `from_id`), each row = In-app badge + title + message + relative time. +8 bilingual
  i18n keys. Browser-verified: badge shows "تطوير/Dev", outbox shows 8 real sent messages.
- **Navbar clock** (shared `_tw_navbar.php`, demo `TopBar` parity): the non-demo branch showed only an
  abbreviated date ("Mon 13 Jul", no time). Demo TopBar shows `date · formatTime(…, withSeconds)` =
  "20 May 2026 · 5:35:59 AM". Rebuilt: date `asDate(time(),'d MMM yyyy')` + `·` + a `[data-live-clock]`
  span seeded by `asTime(time(),'h:mm:ss a')` and ticked every 1s in JS (12h, seconds, AM/PM, latin).
  Only fires when the DemoClock isn't active (the demo branch keeps its own simulated ticker).
- **Not a code gap:** the specialist role subtitle (demo "Senior Stylist" etc.) renders from
  `UserProfile->bio` — our seed agents just have no bio, so it's blank (data, not code).

## 2026-07-13 — Shop Analytics demo-parity pass (uncommitted)

Full review of `/shop-analytics/index` vs the demo (`Navagoo_MI` @ v0.26.0: `Analytics.tsx`, `Kpi.tsx`,
`RadialProgress.tsx`, `Heatmap.tsx`, `chartTheme.ts`). Backend already computed most of the right data
(revenue by category, customer mix, on-time, peak hours) — the gaps were presentation + one real backend
aggregation (busiest-days needed a weekday×hour matrix, not just a weekday total). Changes, easiest→hardest
per user's ask:
- **Header**: subtitle no longer duplicates the period name (tabs already show it); added "Updated {time} ·
  auto every 5 min" + a Refresh button (`data-ana-refresh` → `location.reload()`; `setInterval` reloads
  every 5 min, mirroring demo `RefreshControls` without a client-side store to diff against).
- **Settlement balance bar** (new): reuses `FinanceLedgerService::shopBalanceView($shopId)` — the SAME
  single source of truth as Earnings/Finance — for `runningBalance` / `withdrawableNow` / `minWithdrawal` /
  `meetsWithdrawalMin`. No new money logic invented.
- **KPI band**: rebuilt to demo `KpiCard variant="gradientFill"` — whole card is a colour gradient (not a
  white card + top strip), icon roundel `bg-white/16`, delta always white text (icon direction only, not
  red/green — matches demo `isFill` branch exactly).
- **Revenue by category**: legend rows now show `· NN%` of the categorized total; donut center label is a
  plain number (`Aurora::money($v, false)` — no "SAR" prefix, no "Total" caption), matching demo
  `centerLabel={money(a.revenue, false)}`.
- **Customer mix**: consolidated from 3 separate cards (Customer mix / Booking origin / Customer source)
  into demo's ONE card with 3 mini-donuts side by side ("New vs returning" / "Navagoo vs shop" / "App vs
  walk-in"), same shared subtitle. Reordered to sit right after Top services/specialists (before the
  operations row), matching demo's `DEFAULT_LAYOUT` band order.
- **On-time**: replaced the two flat "100%" stat tiles with demo's concentric SVG radial rings (outer=Start,
  inner=Finish; `VIEW=132/CENTER=66/STROKE=13/GAP=5`, ported 1:1) + a legend with RAG-status dot (`ragOf()`/
  `RAG_HEX` ported from `lib/kpiTargets.ts`: good `#28A745`/warn `#FFC107`/crit `#DC3545`) and target hint
  ("target ≥ 90%" / "≥ 85%", demo `TARGETS.onTimeStart`/`onTimeFinish`).
- **Busiest days — real heatmap** (the hardest item): backend gained `ShopAnalyticsController::busiestDaysMatrix()`
  — a genuine weekday(0-6)×hour(8-23) 2D aggregation (old `busiestDays()` only totaled per-weekday, ignoring
  hour). View replaced the single-weekday bar chart with a CSS-grid heatmap (7 rows × 16 cols, opacity ramps
  `0.15 + 0.85×(v/max)` — same formula as demo `Heatmap.tsx`), no new chart-lib dependency.
- **Full colour-token audit** (user follow-up "و الألوان كمان") — pulled the complete `chartTheme.ts` +
  cross-checked against `BookingCalendarController::STATUS_COLOR` (already demo-exact from an earlier
  pass) and fixed EVERY hardcoded hex on this page to the real token:
  - Peak-hours bars ↔ Busiest-days heatmap were using each other's colour (`C.bookings` teal `#57b49b` vs
    `C.grossMargin` purple `#594279`) — swapped back.
  - Trend chart: Revenue/Bookings series were swapped AND Bookings was a bar, not a line — now
    Area(Revenue)=`#2ebf91` (`C.revenue`), Line(Bookings)=`#594279` (`C.grossMargin`), matching
    `<Area dataKey="revenue">`/`<Line dataKey="bookings">` in `Analytics.tsx`.
  - Booking-status donut (`$statusBreakdown` in the controller, incl. the empty-viewmodel copy): was
    inventing its own hues (`#2ebf91`/`#594279`/`#e8a833`/`#6f7682`) — now the SAME demo hexes as
    `BookingCalendarController::STATUS_COLOR` (`#6a3ab8`/`#f5b71b`/`#57b49b`/`#4c5663`/`#dc5757`), so
    "Scheduled/In progress/Completed/No-show" read identically across the whole portal.
  - Revenue-by-category donut: palette replaced with the demo's actual 6-hue `categoryPalette` cycle
    (`[marketing, processing, notifications, subscription, grossMargin, gmv]` — including its own
    internal hue repeats), not an invented 7-colour set.
  - Customer-mix's 3 mini-donuts (Chart.js `backgroundColor` arrays AND the `<li>` legend swatches, both
    needed the same fix since they're independently hardcoded): New/Returning → `C.bookings`/`C.grossMargin`;
    Navagoo/Shop → `C.lines.marketing`/`C.lines.processing`; App/Walk-in → `C.lines.subscription`/
    `C.lines.notifications` — all were using unrelated hues before (`#4aa6b5`/`#8360c3`/`#e8a833`, none of
    which exist in the demo's actual palette).
  - Top-services / Top-specialists progress bars: swapped to `C.bookings` (teal) / `C.grossMargin`
    (purple) respectively (were arbitrary `#8360c3`/`#2ebf91`).
  - Global Chart.js defaults: axis/label colour `#94a3b8` (was `#6f7682`) and gridline colour `#e2e8f0`
    (was an ad-hoc `rgba(15,23,42,0.06)`) — `chartTheme.ts` `C.axis`/`C.grid`.
  - Verified end-to-end in-browser after the fixes: all donuts/bars now show the exact same hues in the
    same slot as the demo screenshots. Console clean, no JS errors.
- **Chart CONFIG audit** (user follow-up "the charts not the same") — went past colour into actual chart
  behaviour, found via a real side-by-side zoom (not just eyeballing):
  - **Trend chart**: legend was `position:'top'` (demo = `bottom`) with Bookings listed before Revenue
    (Chart.js legend follows dataset `order`, which was backwards) — moved to bottom, swapped `order`
    values so Revenue is first, matching `<Legend/>`'s JSX-order default in `Analytics.tsx`.
  - **Y-axis tick format**: was `fmtMoney` ("SAR 1,800") — demo's `tickFormatter={fmtSar}` is
    `money(v,false)`, a PLAIN number with 2 decimals ("1,800.00", no "SAR" — that only belongs in the
    tooltip). Added a dedicated `fmtAxisMoney` for the axis only.
  - **X-axis day/month labels**: controller's `buildTrend()` used `format('d M')` → "01 Jul" (zero-padded)
    and `format('M Y')` → "Jul 2026" (with year) for month buckets. Demo's `trendLabel()`
    (`pnlView.ts`) does `Number(day)` (strips the leading zero → "1 Jul") and month-only, no year
    ("Jul"). Fixed both formats to match exactly.
  - **Gridlines**: Chart.js grids were solid; demo's Recharts `<CartesianGrid strokeDasharray="3 3"/>` is
    dashed. Added `borderDash:[3,3]` to the trend + peak-hours Y-axis grids.
  - **Peak-hours X-axis**: labels were rotating diagonally (Chart.js default `maxRotation` kicks in when
    12 labels don't fit) — demo's Recharts XAxis has no rotation. Added `maxRotation:0, minRotation:0`.
  - **Booking-status legend order + target hints**: was `[completed, scheduled, in_progress, no_show,
    cancelled]` — demo's `BOOKING_STATUS_ORDER` (`lib/analytics.ts`) is
    `[scheduled, in_progress, completed, no_show, cancelled]`, fixed in both the real and empty-viewmodel
    arrays. Also added the RAG-coloured target-hint dot next to No-show ("target ≤ 5%") and Cancelled
    ("target ≤ 8%") that the demo shows — `TARGETS.noShowRate`/`cancellationRate` (dir:'down'), via a new
    `$ragOfDown` helper (mirrors the existing up-direction `$ragOf` used for on-time).
  - **Customer-mix legends**: demo's `DonutChart showLabels` draws "N · NN%" ON the arcs (a plugin we
    don't have); we surface the same info — count AND % — in the legend `<li>` rows for all 3 mini-donuts
    (was count-only).
  - Re-verified in-browser: trend legend/axis/gridlines/dates all pixel-match the demo zoom; peak-hours
    labels sit horizontal; booking-status order + target dots confirmed via DOM inspection (`#DC3545`
    red, correctly reflecting this shop's real 11%/22% rates vs the 5%/8% targets).
- i18n: +13 new keys (en/ar frontend) for the header refresh row, settlement bar, customer-mix subtitle/
  column titles, heatmap subtitle, on-time ring legend. PHP lint clean, CSS rebuilt, browser-verified
  end-to-end (incl. a settlement-bar edge case: this test shop has `minimum_withdrawal_amount=0` so
  "available to withdraw" shows even at SAR 0 — real per-shop config, not a bug).
- **Not done (explicitly out of scope, confirmed with user)**: the demo's dev-only drag/resize
  `EditableGrid` ("Edit layout" button, `import.meta.env.DEV`-gated) — a build-time dev tool, not part of
  the real demo's production experience.
- **Donut "shape" pass** (user follow-up: "ديجرام" → clarified as the donut's colored-arc look) — two
  sub-issues, found by zoom-comparing the actual ring geometry against the demo, not just the palette:
  - **Slice gaps**: demo `DonutChart.tsx` uses Recharts `<Pie paddingAngle={2}>` — a real angular gap
    between slices. We only had a 2px white `borderWidth`, which reads as solid/touching at a glance.
    Replaced with Chart.js's `spacing: 3` (the direct equivalent — an actual gap, not a border) and
    bumped `cutout` 68%→70% to match the demo's `innerRadius="58%"/outerRadius="82%"` ratio (≈70.7%).
    Applied to all 5 doughnuts.
  - **On-chart value+% labels with leader lines** (the bigger ask): demo's `renderSliceLabel()` hand-rolls
    a "value · %" label OUTSIDE each slice ≥8% of the circle, connected by a thin leader line — not a
    charting-library feature, a custom SVG draw. Ported the same idea as a small Chart.js `afterDraw`
    plugin (`sliceLabelsPlugin`, no new dependency): reads each arc's `startAngle/endAngle/outerRadius`,
    draws a `#cbd5e1` leader line + "value · N%" text at the midpoint angle, skipping slices <8% (same
    threshold as the demo).
  - **Fighting canvas clipping** (the real difficulty): Recharts renders to SVG, which doesn't hard-clip
    content past its viewBox in a flex layout — our `<canvas>` DOES hard-clip at its own pixel edge, and
    our donuts sit in small FIXED-size boxes (`size-52`/`size-40`), unlike the demo's flexible, much wider
    card. Centering the label on the leader endpoint (first attempt) wasted half the available margin and
    clipped the other half. Fixed with: (1) anchor the label AWAY from the ring instead of centering —
    `cos(mid)`-based left/right alignment so text grows into the margin the leader points toward; (2) a
    dedicated `radius` per chart (independent of `cutout`) to shrink the ring and free real margin for
    text — tuned per container size (Booking-status `size-64`/`radius:68%`; Revenue-by-category kept
    `size-52` with `radius:60%,cutout:66%` since it sits beside a legend and can't grow without truncating
    it; the 3 Customer-mix mini-donuts `size-40`/`radius:58%` with a `small` font variant); (3) shorter
    leader (+8px, not the demo's +14) and on-chart money labels rounded to whole numbers (full 2-decimal
    precision stays in the legend/tooltip — a canvas label has far less horizontal budget than the demo's
    SVG, and cents aren't the point of a floating chart label); (4) shrank the Revenue-by-category center
    total from `text-lg` to `text-sm` so it fits its (now smaller) hole without colliding with the "N·NN%"
    label sitting just outside it.
  - Verified in-browser on all 5 donuts (Booking status 5 slices, Revenue-by-category 2, Customer-mix ×3):
    every label + leader line renders fully inside its canvas, no clipping, no overlap with the center
    total, and the side legend (category donut) no longer truncates. PHP lint clean.
- **InfoDot tooltips + Refresh button chrome** (user follow-up, with the exact demo markup pasted) —
  read `Tooltip.tsx`/`RefreshControls.tsx` source directly instead of guessing:
  - Added a pure-CSS/Tailwind `$infoDot()` helper matching the demo's `InfoDot` 1:1 (`size-[15px] rounded-full
    border-slate-300 bg-slate-100 text-ink-muted`, `i` glyph) — no Radix/JS dependency; hover AND
    keyboard-focus reveal via `group-hover/tip:block group-focus-visible/tip:block` on a `bg-navy-900`
    tooltip bubble (navy-900 = `#1f1629`, already a theme token), matching the demo's dark tooltip style.
  - Wired it in **all 15 places** the demo actually shows one (verified against `Analytics.tsx` +
    `i18n/en.ts` `shop.analytics.*` block, not guessed): Settlement balance, all 5 KPI tiles (Revenue/Net
    earnings/Bookings/Repeat rate/Utilization), Revenue & bookings, Revenue by category, Top services, Top
    specialists, Customer mix, Booking status, On-time, Peak hours, Busiest days. Full tip copy carried
    over verbatim from the demo's i18n strings + inline JSX tips; +15 i18n keys (en/ar frontend).
  - **Bug caught in-browser**: the tooltip bubble inherited `uppercase` + `tracking-[0.1em]` from its
    ancestor label (e.g. "SETTLEMENT BALANCE") since CSS `text-transform`/`letter-spacing` are inherited —
    the tip text was rendering in all-caps with wide tracking. Fixed with explicit `normal-case
    tracking-normal` on the bubble to override the inheritance.
  - **Refresh button**: was using an invented style (`shadow-card ring-slate-900/5`, black-based subtle
    ring) — this is what read as "background بيضه شفافه و بوردر برديس" (translucent-looking, mismatched
    border) vs the demo. Rebuilt to the demo's actual `Button variant="secondary" size="sm"` spec:
    `ring-1 ring-inset ring-slate-200` (not a shadow), `h-9 px-4 text-[13px] rounded-full`, `hover:bg-brand-50
    hover:ring-brand-200`, `active:scale-[0.98]` — same treatment already used for the booking-calendar
    page's New-group/New-walk-in buttons in an earlier pass, so now consistent portal-wide.
  - Verified in-browser: hovering/focusing every "i" dot pops the correctly-cased dark tooltip in the
    right place; Refresh button now visually matches the period-segmented-control's chrome. CSS rebuilt,
    PHP lint clean, i18n hook passed (ar has real Arabic, no drift).

## 2026-07-12 — Booking-calendar LIST view demo-parity pass (uncommitted)

Page-by-page review of `/booking-calendar/index?view=list` vs the demo — verified against BOTH the
live render AND the React source (`Navagoo_MI` @ v0.26.0: `Bookings.tsx`, `components/ui/Badge.tsx`,
`lib/theme.ts`). Changes:
- `CalendarFormat`: +`moneyLead()` ("SAR 300.00", currency-leading, translatable) & +`listDate()`
  ("25 May 2026", day-first). Shared `money()`/`shortDate()` untouched (tests/other views rely on them).
- `BookingListService`: list rows now use `moneyLead`/`listDate`; solo row emits `code` = `formatted_id`
  (demo NB-… id); collection labels → "Collect due" (was "Pending") / "No balance" (was "Not required").
- `_list.php`: **removed the Service column** (demo `Bookings.tsx` has 8 cols, no Service — service
  lives in the detail); status pill **dot removed** (demo = outline pill, no dot); unified all three
  badges to the demo `badgeTint` formula (bg 14% white · text 82% #1a1024 · ring 30%); demo COLOR_TOKENS
  hexes: collect-due `#ea580c`, collected `#2ebf91`, no-balance `#64748b`, settled `#0284c7`, settlement-
  pending `#64748b`; booking# → muted mono `formatted_id`; mixed pill `px-2`; solo customer `max-w-[140px]`;
  colspans 10→9. **Kept** the demo's responsive column hiding (Specialist `hidden xl:table-cell`,
  Settlement `hidden 2xl:table-cell`) — confirmed present in the React `columns` def.
- `booking-calendar.js`: expand chevron now flips 180° AND turns `text-brand-600` (demo parity), reset on
  collapse; chevron got `duration-200`.
- **Header/toolbar pass** (vs `PageHeader`/`Segmented`/`Button` source): `index.php` New-group/New-walk-in
  buttons rebuilt to the demo `Button` secondary·md / primary·md specs (gap-2, `ring-1 ring-inset` not
  border, brand drop-shadow + hover/active states, cubic-bezier transition). `_list.php`: search + status
  filter **moved OUT of the card** into a `flex flex-wrap justify-between` row ABOVE it (demo puts them on
  the page bg, `<Card>` = table only); search narrowed to `max-w-xs`; status filter → demo `Segmented`
  size-sm (`text-xs`). List/Day/Month toggle already matched.
- i18n: +"Collect due" (en/ar frontend). CSS rebuilt. PHP+JS lint clean. Browser-verified: pills match
  the demo hue-for-hue, row expand + chevron motion, responsive hiding.
- **`_detail.php` header pass** (vs demo `BookingDrawer`): **removed** the STATUS/COLLECTION/SETTLEMENT
  pill strip (demo carries those in the row + timeline, not a header strip); **rebuilt the header** as the
  demo drawer strip — customer avatar (md) + name + `id-mono` code + ClassificationBadge (Navagoo #7c3aed /
  Shop #2ebf91) + visit-count badge ("New customer" / "Returning · N visits", cyan #1d6b76) on the
  brand→accent gradient. `BookingDetailViewModel` now resolves `isNavagoo`/`classificationLabel` from
  `customer_classification` (shop+customer scoped) and `visits` = non-cancelled bookings for that
  customer at that shop (both read-only, portal-scoped). +i18n "New customer" / "Returning · {n} visits".
- **`_detail.php` background + Start-Service fix** (vs demo `BookingDrawer`): the WHOLE detail now sits on
  the demo's `bg-gradient-to-br from-brand-50 via-accent-50/30 to-accent-50/70` wash (was only on the
  header) — `-m-4` cancels the shared `iframe_aurora` body `p-4` so the gradient reaches the edges; the
  header lost its ring/rounded/bg card and is now a plain flex strip on the wash (white detail cards float
  on top, matching the demo). Fixed the "Start service" button: it used `style="background:var(--color-accent-400)"`
  but that CSS var is undefined in our build (Tailwind-config palette, not @theme vars) → transparent fill,
  white text = invisible; now `bg-accent-400` (#2ebf91). It was FSM-gated all along, just unstyled.
- **List row: detail fills edge-to-edge + expanded-row wash** (vs demo `Table.tsx`): dropped the
  `rounded-xl ring-1 ring-slate-200 bg-white` frame + `px-4 py-2` around the detail iframe (was insetting
  the gradient) — the iframe is now `p-0` full-bleed with a `border-t border-brand-100` seam. And
  `booking-calendar.js` now toggles the demo's expanded-row treatment on the parent `<tr>` when it opens —
  `bg-gradient-to-r from-brand-50/80 to-accent-50/60 border-transparent` — so the open row + detail read as
  one continuous brand→accent block (was a white row over a gradient drawer). Removed on collapse/filter.
- **`_detail.php` header sizing** (measured demo vs ours): header avatar `size-11`→`size-9` (44→36px, demo
  Avatar md) and customer name `text-base`→`text-sm` (16→14px). Rest already matched the demo (DataRow
  py-2/14px, timeline node size-7, service img size-8, card px-4, 3-col grid gap-6).
- **`_detail.php` notification bell** (demo `BookingNotifBell`): replaced the inline "Notifications sent"
  section (bottom of the timeline column) with a **bell button next to the "Booking details" heading** —
  count badge (bg-brand-500) when `$sentNotifs` is non-empty, click opens a `w-80` popover listing each
  sent notification (In-app/Unread badge + relative time + title) or "None sent yet." Toggle JS
  (registerJs) closes on outside-click + stopPropagation so the row toggle doesn't fire. +i18n "None sent
  yet." / "Unread". Browser-verified (empty state).
- **Avatars + service thumbnails (real images where they exist):**
  - List **solo specialist** was passing `null` as the avatar src → always initials; now sources
    `agentAvatarUrl` (+ group children + the group specialist stack). Verified: Rola/Rawan show photos.
  - Shared `BookingCalendarPresenter::agentAvatarUrl()` returns the `/img/anonymous.png` silhouette (NOT
    null) for photoless agents — can't edit the shared presenter (guard-shared hook / legacy views), so a
    route-local `BookingListService::agentPhoto()` helper + a viewmodel null-check drop the placeholder →
    photoless agents render tinted initials (Test Agent → "TA"), matching the demo + customer behaviour.
  - **Service thumbnail:** viewmodel `$services` now carries `imageUrl` (gated on `ShopService.image_path`,
    via `getImage()`); `_detail.php` renders `<img object-cover>` when present, scissors icon otherwise
    (imageless services show scissors in the demo too). Customer avatar already gated correctly.
- **`_detail.php` Reschedule overlay** (vs demo `RescheduleModal`/`SlotPicker`/`Field`): field label →
  demo `SectionLabel` (sentence-case `text-sm font-bold text-ink` + brand bar, was `text-[11px] uppercase
  text-ink-muted`); **Book now** moved to its own right-aligned row above the card, solid `bg-brand-600`
  with a `· <time>` suffix when a now-slot exists (was a light pill, no time); date pill now shows the
  FULL `dateLong` ("Friday, 14 August 2026", was "Mon, Jul 13") + a chevron-down and opens a native date
  picker (`<input type=date>` behind the pill) on tap — arrows still step days; earliest slot marked
  IN-BOX (block "Earliest" label + border/fill, was a clipped absolute badge); selected slot uses
  border+fill not a ring (also clip-safe). Browser-verified.
- **Booking sub-screens promoted to PAGE-LEVEL modals** (demo parity — Reschedule/Cancel/Collect open
  above the whole list, list = dimmed backdrop, not the detail drawer). The detail is an inline iframe, so
  its `fixed inset-0` overlays were trapped in the drawer. Fix (reuses ALL existing overlay markup+JS):
  - `aurora-core.js`: `ngIframeModal` gained a **bare mode** (`opts.bare` → chrome-less full-screen
    transparent iframe, no panel/backdrop) + the message listener bubbles a child's
    `{action:'iframe-modal', url, bare}` to the top window and opens it there.
  - `iframe_aurora.php`: `?bare=1` → transparent body (no `bg-white p-4`) so the overlay's own `bg-ink/50`
    backdrop shows the list through the transparent iframe.
  - `BookingCalendarController`: `actionRescheduleModal/CancelModal/CollectModal($id)` render `_detail`
    with `only='reschedule|cancel|collect'` (shared `findDetailBooking()` + `renderOnlyOverlay()`).
  - `_detail.php`: `$only` mode renders ONLY that overlay (visible, `display:flex`), skips the `<article>`
    + other overlays; overlay JS auto-`open(ONLY)`s + a capture-phase `[data-overlay-close]`/Escape handler
    posts `status:'close'` to the top. The timeline buttons switched `data-overlay-open` →
    `data-page-modal` + post `{action:'iframe-modal', url:'…/*-modal', bare:true}` to the parent.
  - Browser-verified: Reschedule + Cancel open centered over the dimmed LIST (matches the demo); Cancel
    closes; Collect endpoint renders (only-mode). **Known gap:** from the calendar/group-child context the
    detail is ITSELF an ngIframeModal, so the one-at-a-time guard blocks stacking — sub-screens there still
    need the old in-drawer path or a stacking allowance (out of scope; the list view is the target).
  - **Reschedule "does nothing" fix:** the submit DID work (POST /booking/reschedule → 200) but the
    server rejected the test booking with `{success:false, message:"…max number of reschedules…"}` and the
    error was SWALLOWED — `iframe_aurora` carries no `#ng-toast-root`, so the inner `toast()` was a no-op
    (pre-existing latent bug, now visible at page level). Fix: `_detail.php` `toast()` bubbles the message
    to the top page (`{status:'error', message}`) when no local toaster; `aurora-core` message handler now
    honours `status:'error'` → `ngToast(msg,'error')` (no close/refresh). Happy path unchanged: success →
    `saved()` → aurora-core closes the bare modal + reloads (same path as New walk-in). NOTE: the "Refactor
    Test" seed booking is now maxed-out on reschedules from testing — verify the happy path on a FRESH
    scheduled booking. (Browser re-verify was blocked by the device-validation session-kill redirect to
    /shop-service.)
  - **Cancel/Collect parity (vs demo modals):** verified both open page-level with the list backdrop and
    use the same submit→`saved()`/`toast()` mechanism (so the error-toast fix covers them too). Aligned
    their form labels to the demo `SectionLabel` (bold + brand bar): Cancel's "Cancelled by" / "Reason
    (optional)" and Collect's "Payment type" were plain `font-medium`/uppercase → now `text-sm font-bold`
    + `h-3.5 w-[3px] bg-brand-500` bar, matching Reschedule's "New appointment date & time". Browser-
    verified Cancel modal against the demo (structure + labels match). Remaining diffs are cosmetic
    wording only ("Full refund" vs demo "Full", "Reversed (full)" vs "Reversed in full").
- **Reschedule/Cancel/Collect now open PAGE-LEVEL** (demo `Modal`, not trapped in the short list-drawer
  iframe): `_detail.php` `open()`/`closeAll()` post `{source:'navagoo-iframe', overlay:'open'|'close'}` to
  the parent; `booking-calendar.js` promotes the matching inline `[data-cal-detail-frame]` to
  `position:fixed; inset:0; 100vw×100vh; z-70` (+ locks `<html>` scroll) so the overlay's `fixed inset-0`
  backdrop+modal cover the whole viewport, and reverts on close. Critical: `sizeDetailFrame()` now bails
  when the frame carries `data-promoted`, else the content-height auto-sizer/ResizeObserver instantly
  shrank the frame back (was clipping the modal). No-op when the detail is already a full-page modal
  (calendar ngIframeModal isn't a `data-cal-detail-frame`). Browser-verified: open→full-page centered
  modal + dimmed page, close→reverts to the 417px inline drawer.

## ▶ RESUME HERE (after a context clear) — state as of 2026-07-12

**Done + green (branch `tailwind-poc`, all committed, full suite 256 tests + 39 pages render, 0 regressions):**
- **P0** money truth — leak fix (balance-neutral) + **broken-withdrawal-flow fix** + shopBalanceView unify + branch health.
- **P1 Wages (M3) — 100%** (engine·schema·service·cron·Team-Payroll UI·tips·Detailed-Charges CSV·first_workday), off-rail-proven. Plan: `WAGES_M3_IMPLEMENTATION_PLAN.md`.
- **P4** Admin BI/P&L/Costs/Events (earlier waves).
- **P2 Subscriptions — safe core**: Waves 1 `EntitlementService`, 2 `OfferPricingService`, 3a resolver (all pinned/tested). Plan: `P2_SUBSCRIPTIONS_IMPLEMENTATION_PLAN.md`.

**⛔ Blocked on MI decisions (money — do NOT build blind):** P2 Waves 4–7 (billing lifecycle · dunning · Paymob-MIT · proration) need the **5 decisions in `P2_SUBSCRIPTIONS_IMPLEMENTATION_PLAN.md §2`** (prices, dunning cadence, Paymob-MIT, Arabic, bands). P2 Wave 3b (nav/booking gating) needs subscription-data cleanup + a hide-vs-lock call.

**✅ Safe next step (no decisions, source-authoritative — pick one, plan→waves→verify):**
1. **P5 Shop Analytics** — 11-widget dashboard (demo v0.17). Needs a `BiQueryService`.
2. **P6 Home re-port** — shop Home v0.19 (schedule-hero timeline, attention feed, week band) — the 42% gap in `SHOP_PORTAL_DESIGN_PARITY_AUDIT`.
3. Newer demo clusters from the v0.19→v0.26 pull: **Packages** (v0.22), **Promotions overhaul**, **M6 admin breadth** (v0.25).

**Not started:** P3 RBAC, P5, P6, P7 (Msegat/T2/Paymob delivery), P8 sweep.
**Discipline that held all session:** never land finance unverified (cent-pin + off-rail proof + mobile curl-smoke). Two committers — never both edit `FinanceLedgerService`.

## 🗓️ Session 2026-07-12 (Claude, while MI away) — finance hardening + branch health

Continued the finance/money work + a full post-merge health sweep of the two-committer branch:
- **Money-leak fix** (earlier this run, `edb75d8`): unified `consumedBookingIds` (transfers ∪ invoice-applied) + balance-neutral `invoiceNetting`, verified on live DB + 7 unit tests. See [[finance-money-leak-phase0]].
- **`8e6b8f9`** — routed `shopBalanceView` withdrawable through the same unified `consumedBookingIds` (completes B1 across all 3 balance paths; strictly reductive, verified no-op on current data).
- **`67067ac`** — **FIXED a broken money flow**: Ahmad's `db318a6` B2 added `total_notif_fees` to `transfer_request`, but `WithdrawalBundlingService` writes it to the **`withdrawal`** table (`Withdrawal::tableName()`), which lacked the column → every `createWithdrawal()` threw `UnknownPropertyException` (shop withdrawal-request = HTTP 500, verified). Added the column to `withdrawal` (new migration) + fixed `m260709_175300`'s `->after('processing_fees')` (non-existent col → the whole migration batch, incl. `audit_log`, was stuck; this was `migration_error.log`).
- **`01b7725`** — removed 60 committed dev-scratch/junk files + `.gitignore` guards.
- **`a8e3e4a`** — added the live per-phase execution-status table to the master plan.
- **Health sweep (all green):** migrations 0 pending; `demo_request` + `audit_log` tables exist; every attr `WithdrawalBundlingService` writes now exists on `withdrawal`; admin render-smoke 9/9; admin-finance + `/withdrawal/index` 4/4; `FinanceLedgerServiceTest` 33/33.
- **Flagged (task_8273c3b2):** `db318a6` deleted `frontend/views/booking/*` but left `BookingController` view-actions rendering them → 500 on direct URL (live v2 `/booking-calendar` unaffected).
- **Browser-verified 2026-07-22** (was pending as of this session): `/`, `/earnings`, `/navagoo-plans` all render cleanly logged in as the standing dev test shop-owner — Finance shows real ⃁50.00 minimum-withdrawal + revenue/fees/net/withdrawable tiles, Navagoo Plans shows the 3-tier pricing with the Founding Partner discount applied. Zero console errors on either page.

## What this is
Migrating the Navagoo **shop owner** portal (`frontend/`) off AdminLTE/Bootstrap onto
a **Tailwind** chrome, porting the design from the mockup repo `Navagoo_MI` (React/Vite,
direction = *Aurora*). Server-rendered Yii views — no SPA.

## Architecture
| Piece | File |
|---|---|
| Tailwind layout (aurora chrome) | `frontend/views/layouts/tailwind.php` |
| Sidebar partial (full menu) | `frontend/views/layouts/_tw_sidebar.php` |
| Navbar partial | `frontend/views/layouts/_tw_navbar.php` |
| Prod theme (PostCSS build target) | `tailwind.config.js` |
| ~~Guest-viewable POC~~ | **REMOVED 2026-06-29** — the `Poc*` controllers + `frontend/views/poc*/` stubs + `web/tailwind-poc.html` were deleted (fake-data scaffolding, superseded by the real [[dev-test-login]] verification). Verify auth-gated pages by logging in, not via POC. |

- **Per-action flip:** each converted controller sets `$this->layout = 'tailwind';` at the
  top of `actionIndex` **only** — sibling actions (create/update/view) stay on AdminLTE
  until ported. Nothing is controller-wide.
- **Tokens:** dev loads the **Tailwind Play CDN** with an inline theme that mirrors
  `tailwind.config.js`. Sidebar collapses via native `<details>` (no JS). Sidebar active
  state is resolved from the live controller/action (falls back to `activeNav` for the POC).
- **Core assets:** the layout registers `yii\web\YiiAsset` + `yii\widgets\PjaxAsset` and
  emits `Html::csrfMetaTags()` so `data-method=post`/`data-confirm` links, ActiveForm
  validation, and Pjax all work — **without** pulling Bootstrap back in.

## Surfaces converted ✅ (entire shop sidebar menu)
dashboard (site) · Booking Details (booking) · Agents Bookings · Promo Codes · Package ·
Services (shop-service) · Shop Earnings (earnings) · Transfer Requests + Specialist Tips
(agents-wallet) · Payment Methods (payment-settings) · Agents · Branches · Customer
Invitations · Reviews (rate) · Notifications · Shop Settings · Manage Social Media ·
Help center (technical-support — frontend controller+view were **created**, didn't exist).

## RTL
Fixed: content panel uses logical `rounded-ss-2xl` (top-right corner in RTL) and
`.bg-head-shop` reverses to 270° so the dark `#3d2960` corner meets the right-hand sidebar
seamlessly — mirrors the LTR dashboard. Verified via computed styles + screenshots.

## Parity waves (2026-06-28, continued)

### Wave 2 — Dashboard polish
- Header changed: `'Hi, {name}'` → `'Welcome back, {name}'` (bilingual)
- Primary CTA changed to "New walk-in booking" → `/booking/create?method=walk_in`
- Donut changed from multi-color segments to single accent (`#7c3aed`) full ring (no data-loss — cost breakdown legend kept)

### Wave 3 — Group Bookings View (ALREADY DONE)
`agents-bookings/index.php` already has group PARTY rows with expandable `_group_drawer`, AvatarStack (`$avatarCluster`), "Party of N" subtitle, Mixed status chip, GroupRescheduleModal, GroupCollectModal. No work needed.

### Wave 4 — SpecialistModal display fields
Migration: `m260628_140000_add_specialist_display_fields` — adds `calendar_color VARCHAR(7)` and `languages TEXT` (JSON) to `user_profile`.
- `UserProfile` model: new `@property` docs + safe rules + attributeLabels
- `agents/_form.php`: Calendar Colour 8-colour swatch + hidden input; Languages multi-pill checkbox (ar/en/fr/ur/tl/hi); JS for swatch selection + pill toggle
- `AgentsController::UpdateUserRelatedTbls`: saves `calendar_color` + JSON-encodes `languages[]` array before persisting

### Wave 5 — Services CatalogueModal (SKIPPED — already good enough)
`shop-service/_form.php` already has MyMultiLanguageActiveField for name, filekit image upload, kartik Select2 assigned agents. Description + VariantPicker + ImageCropper require significant new infrastructure (new DB columns + crop.js); deferred.

### Wave 6 — Settings General tab
`settings/_general.php`: added Email field → posts `Shop[email]` (column exists on Shop).
Bank Account card (read-only): shows name + IBAN from `BankAccount::find()->where(['shop_id'=>$shop->id])`.
`SettingsController`: saves `email` alongside `commercial_name`/`mobile`; loads `$bankAccount` and passes to view.

### Wave 7 — Bookings list
`agents-bookings/index.php`: added Notifications bell column header + per-row bell icon link (→ `/notifications?booking_id=X`); colspan updated 8→9 throughout; group party rows get a `—` placeholder in the bell cell.

### Waves 8–13 — final parity push (2026-06-28, 6 parallel streams)
Ran as one background Workflow (6 worktree-free agents) + a translations/lint follow-up. All migrations applied, all files `php -l` clean, schema verified live (columns/table physically present).

**Wave 8 — Settings: 12h/24h + walk-in payment toggles.**
Migration `m260628_200000_add_shop_walkin_time_fields` → `shop.time_format TINYINT(1)` + `shop_payment_settings.walkin_{online,deposit,on_visit}_enabled TINYINT(1)`.
`settings/_scheduling.php`: 24h/12h segmented control (hidden `Shop[time_format]` + JS). `settings/_payments.php`: a compact "Walk-in" sub-toggle under each of the 3 payment methods. `ShopPaymentSettings`/`Shop` models: new `@property` + safe rules. `SettingsController`: `time_format` added to the scheduling save column list; walk-in fields auto-save via safe rules.

**Wave 9 — SpecialistModal JS: Copy-to-all + overnight.**
`agents/_form.php`: "Copy first day to all" button (copies the first enabled day's shifts to every other enabled day, re-running `validateDay`/`sortDayShifts`) + a `+1d` badge that shows on any shift whose `to_time < from_time` (overnight), updated on every time-input change and on initial load. Pure JS; no schema/controller change.

**Wave 10 — Admin Commercial Config (new feature, demo CommercialConfig.tsx).**
Migration `m260628_210000_add_commercial_config_table` → singleton `commercial_config` (19 cols: VAT/marketing/processing/fixed/min-marketing/max-deposit, settlement+billing knobs, SMS/WA sell+cost). `common/models/CommercialConfig.php` (Timestamp+Blameable, `getInstance()` singleton loader). `backend/controllers/CommercialConfigController.php` (`actionIndex` load/save, admin-only). `backend/views/commercial-config/index.php` (3 aurora cards: Fees & Tax / Settlement & Billing / Messaging Costs + "rates apply to new charges only" note). Menu item added. **Note:** FinanceLedgerService still reads rates off `Shop` fields — wiring the ledger to read from `commercial_config` is the remaining follow-up.

**Wave 11 — Notifications usage tiles.**
`settings/_notifications.php`: "This month's usage" card mirroring the demo `UsageStat` (In-app `/ unlimited` · Always free; SMS `/100 free`; WhatsApp `/50 free`). `SettingsController` passes `notifSentThisMonth` (live in-app count) + `notifUsageSMS`/`notifUsageWA` (0 until a provider is wired). `index.php` forwards the vars.

**Wave 12 — Admin settlement (Withdrawal) richness.**
Worked WITH the existing `WithdrawalController::actionUpdate` settlement flow (doc uploads via `DocumentUploadWidget`, `navagoo_invoice`/`transfer_receipt` already there). `withdrawal/_settlement_form.php`: added an aurora **FormulaStrip** (Collected + Tips − Navagoo Fees − Fee VAT = Net payout) + **server-side + live-JS mismatch alerts** when `amount_transferred` differs from `net_transferable_amount` by >1 SAR. The lower form keeps its own scoped `<style>` block (self-contained, not Bootstrap-dependent). `update.php` wraps it in the aurora chrome.

**Wave 13 — Admin Notification Triggers.**
`backend/views/notification-trigger/{index,_form,_row,create,update}.php` completed (aurora-admin chrome, AJAX active/optional toggles, delete confirm). Menu item added. Backed by `NotificationTrigger` model + migrations `m260628_120000`/`m260628_130000` (timing modes).

**i18n:** 45 missing `Yii::t` keys across the settings hub, notifications card, agent-form section heads, and the new admin pages were added to both `en` + `ar` (frontend + backend). A stray `Yii::t('backend','0.00 ')` placeholder in the settlement form was unwrapped to a plain `'0.00'`. Full-tree scan now reports **0 missing keys**.

---

## Finance 5-tab hub (2026-06-28)
`EarningsController::actionIndex` now acts as a Finance hub with `?tab=earnings|charges|invoices|settlement|subscription`.
- `earnings/index.php` → hub shell (5-tab segmented, same pattern as settings/index.php)
- `earnings/_earnings.php` → extracted existing KPI + table content
- `earnings/_charges.php` → immutable charge ledger; JS client-side Status/Type filters
- `earnings/_invoices.php` → invoice table + inline Pay modal (action wiring deferred)
- `earnings/_settlement.php` → FormulaStrip (uses `FinanceLedgerService::bookingSettlement` keys: `marketingFees`/`processingFees`/`netPayout`) + Eligible Earnings table + Withdrawals list
- `earnings/_subscription.php` → stub ("coming soon")
All files syntax-clean (php -l). Key gotcha: `bookingSettlement()` key names ≠ what the agent initially wrote — fixed in both controller fallback and view.

### Finance polish (2026-06-29) — P&L tiles · settlement eligibility · inline request-transfer
Closes the 3 verified-open finance parity gaps against the demo Earnings.tsx / Settlement.tsx:
- **Earnings P&L quartet.** New `FinanceLedgerService::shopPnl(int $shopId)` sums the ledger engine
  over the shop's TERMINAL bookings → `{revenue: Σ(bookingRevenue+tips), netEarnings: Σ bookingNetEarnings,
  navagooFees: revenue−netEarnings}`. `EarningsController::actionIndex` passes `plRevenue/plNavagooFees/plNetEarnings`;
  `_earnings.php` row 1 reworked to **Revenue / Navagoo fees / Net earnings / Withdrawable** with the
  threshold-aware Withdrawable caption (Owed to Navagoo <0 / Eligible to withdraw ≥min / Minimum {amount})
  and the request CTA folded into that tile. Legacy wave-17 summary row preserved (its navigate-away
  Withdraw link dropped — the headline tile is now the single CTA). Per-row Net Earnings / Navagoo Payout
  table columns untouched.
- **Settlement eligibility table.** `_settlement.php` "Eligible Earnings" (3 cols) → demo's **8 cols**:
  Booking(id+date) / Customer / Specialist / Status chip / Booking Value / Tips / Charges(marketing+processing+feeVat)
  / Net Payout / Held(days). Per-row terms from `bookingSettlement()`; degrades to "—" for standalone tip
  earnings. Controller eager-loads `booking.customer(.userProfile).agent` on the `$shopEarnings` query (no N+1).
  Formula strip + withdrawals-history unchanged.
- **Inline request-transfer.** New `EarningsController::actionRequestTransfer` (POST/JSON): bundles the SAME
  eligible set into one Withdrawal and returns `{success,id}` or a `warning` "nothing eligible" payload.
  Bundling extracted to **`common\components\WithdrawalBundlingService`** (eligible selection · totals/VAT ·
  REQUESTED flip · earning+payment linkage) — `AgentsWalletController::actionCreate` now delegates to it
  (its `processEarnings`/`processPaymentsForEarnings` are thin delegators), so wizard + inline stay in lock-step.
  CTA on both the Withdrawable tile and the settlement formula strip (`#ng-request-transfer`, always rendered,
  disabled until min met so the warning path is reachable); shared JS in `earnings/_request_transfer_js.php`,
  included once from `index.php`. Verified end-to-end on [[dev-test-login]]: P&L tiles reconcile
  (Rev 17 − fees 0 = net 17), 8-col table aligns (9 th / 9 td), POST returned a real `NTR-…` id and flipped
  the earning to REQUESTED. **No data-model/API change** (no migration; reuses existing tables/endpoints).

---

## Waves 14–21 — full gap-closure push (2026-06-28, schema + 8 parallel streams)
Driven off the fresh re-analysis [`PARITY_REANALYSIS_2026_06_28.md`](PARITY_REANALYSIS_2026_06_28.md). One Workflow:
a serial **schema** phase, then **8 disjoint-file domain agents** in parallel, then translate + verify.
Final state: **64 PHP files `php -l` clean · migrations up-to-date · 0 missing i18n keys** (full-scan verified).

**Schema (2 migrations, idempotent per-column guards).**
`m260628_220000_add_notification_templates_and_approval` → `notification_trigger`: `sms_template_{en,ar}`,
`wa_template_{en,ar}`, `inapp_template_{en,ar}`, `approval_status` (0=pending/1=approved), `approved_at`, `approved_by`.
`m260628_230000_add_invoice_billing_lifecycle` → `invoice`: `period`, `issued_at`, `due_at`, `paid_at`,
`document_status`, `payment_method`, `payment_slip_path`. Shared `Invoice` model updated centrally.

**Wave 14 — Calendar drag BUG fix + booking-detail fidelity.** Root cause closed: `booking-calendar.js`
posts `{id, datetime:"Y-m-dT HH:MM", agent_id?}` and `BookingCalendarController` now reads `post('datetime')`
and regex-parses it into date+from (same fix covers drag-reschedule, drag-reassign, empty-slot create).
`_detail.php` now renders the discount/online/in-person/tip/refund payment rows (view-model already supplied them);
`_timeline.php` renders a terminal node for Cancelled/No-Show instead of ghosted happy-path steps.

**Wave 15 — Solo bookings list parity.** `agents-bookings/`: inline solo-row drawer (reuses `_detail.php`),
3-dot quick-actions, live client-side search applied to party rows too, Collection column.

**Wave 16 — Notifications engine.** Admin (`notification-trigger/`): per-channel bilingual template authoring,
click-to-insert variable catalogue, `approval_status` badge + Approve/re-pend actions (edit resets to pending).
Shop (`settings/_notifications.php`): paid-channel picker gated to admin-approved channels, real usage tiles.
New `common/components/NotificationDispatchService.php` resolves channel→template→send + emits in-app NotifSent + increments usage.

**Wave 17 — Finance hub completion + CommercialConfig wiring.** `earnings/_charges.php` status/type filters now
rendered + applied; `_earnings.php` per-row columns (timing pill, Net Earnings, Navagoo Payout, Eligible+hold, Collection);
`_invoices.php` Pay flow (card vs bank-slip upload → `payment_method`/`payment_slip_path`/`paid_at`/`document_status`);
`_subscription.php` wired to `subscription_package`/`user_card`. **`FinanceLedgerService` now reads rates from
`CommercialConfig::getInstance()` when a row exists, else falls back to per-shop column → `Settings.taxes` → CEO default**
(additive, rates stamped at calc time — historical ledger rows untouched). This closes the "CommercialConfig was write-only" gap.

**Wave 18 — Services catalogue forms.** `shop-service/_form.php`: VariantPicker wired to `service_image_variant`,
routine-before/after + free-addon picker wired to `service_freebie_link` with "+N min" roll-up, bilingual description.

**Wave 19 — Team Structure + specialist guards.** `agents/_form.php` "Reports to" manager picker (`user_profile.reports_to`);
`agents/_structure.php` interactive org-chart (add-child/rename/reparent/delete via AJAX); `AgentsController::actionDelete`
now counts live/future bookings → deactivates instead of hard-deleting when any exist.

**Waves 20–21 — Admin finance screens.** New `AdminInvoiceController` + `backend/views/admin-invoice/`
(platform-wide invoice list + status filter + verify-payment + upload-document) and `AdminChargeController` +
`backend/views/admin-charge/` (platform ledger + status/shop/type filters + rate-label column). Both added to the admin menu.

**i18n:** a full-tree scan after the run found 57 untranslated keys (this run's new strings + pre-existing debt in
`shop-category/catalogue.php`, `agents/_payroll.php`, `package/_form.php`); all added to en+ar (frontend+backend). Scan now 0.

## Frontend refactor — shared `Aurora` view helper (2026-06-28)
First refactor pass on the shop frontend, targeting the worst copy-paste: per-view helper
closures duplicated across dozens of files. New `frontend\components\Aurora` (static helpers):
- `Aurora::money($amount, $withSymbol=true)` — canonical `SAR 1,234.56` (Western digits). Replaced
  **15** `$money` closures that had **5 divergent implementations** (number_format vs asDecimal) →
  one source, consistent output.
- `Aurora::sectionHead($icon,$title,$subtitle=null)` — bare-icon card header. Replaced **6** closures.
- `Aurora::sectionHeadChip(...)` — icon-in-chip header variant. Replaced **3** closures.
- `Aurora::badge($tone,$label)` / `badgeTone($tone)` / `iconChip(...)` — available for adoption
  (centralises the 5-tone badge vocabulary used ~180×; call sites not yet migrated).

**Migration method:** each closure *definition* was replaced with a one-line alias delegating to
`Aurora::` (e.g. `$money = static fn($a,$s=true) => \frontend\components\Aurora::money($a,$s);`).
Call sites and `use ($money)` captures (in `_charges.php`, `view.php`, `_notifications.php`) stay
untouched → zero call-site risk. **NOT unified:** `$avatar` (6 files, 3 divergent signatures) and
`branch/_detail.php`'s one-off sectionHead (different padding/no subtitle) — left as-is to avoid regressions.

**Verified:** 25 files `php -l` clean; logged in as the [[dev-test-login]] shop owner and rendered all
15 affected pages (dashboard, 5 earnings tabs, wallet, notifications, + 8 forms) — all HTTP 200, money
still outputs `SAR`, `Aurora` autoloads from views, no "Class not found"/exception traces.

### Skill-driven refactor round 2 (2026-06-28) — build→fix cadence
Ran as one workflow: 4 targets, each a **build wave (one skill) → adversarial fix wave (a different review
skill)**, then an authenticated render-verify gate. Skills applied by agents reading `~/.claude/skills/<s>/SKILL.md`.
Result: **87 PHP files `php -l` clean · 29/29 pages render HTTP 200** (logged in as the [[dev-test-login]] owner).

- **Avatar reconciliation** [build `frontend-design` · fix `code-reviewer`] — 6 divergent `$avatar` closures
  (3 signatures) → `Aurora::avatarInitials/avatarChip/avatarPhoto` + `initials()`. Each closure replaced by a
  delegating alias (captures `$avatarCluster`/`$avatarStack` preserved). Fix wave verified byte-for-byte markup
  parity against the originals (`git show HEAD:`) — no regressions.
- **Badge tone centralisation** [build `tailwind-design-system` · fix `bugs-are-annoying`] — CONSERVATIVE: 0
  full-pill `badge()` migrations (no site's wrapper exactly matched the canonical), but **18 files** adopted
  `Aurora::badgeTone($tone)` for the tone class-string where provably identical. Fix wave confirmed byte-equivalence.
- **Controller extraction** [build `php-pro` · fix `architect-review`] — pure behaviour-preserving logic pulled
  from the two biggest controllers into new stateless `frontend\components\BookingFinanceMath` (group-booking
  money math). No action/request/response logic moved.
- **Tab-JS consolidation** [build `frontend-dev-guidelines` · fix `logic-lens`] — new `frontend/web/js/aurora-tabs.js`
  (registered once in `TailwindAsset.php`) generically wires every `ng-<group>-tab`/`ng-<group>-pane` group and
  dispatches a `ng:tab-change` CustomEvent. Replaced the per-view inline tab JS on finance/settings/team; the team
  page keeps a small listener on that event for lazy org-chart init (clean extension point, not a double-binding).

### Skill-driven refactor rounds 3–4 (2026-06-29) — same build→fix cadence
Continued the cadence (build wave = one skill, fix wave = a different review skill, render-verify gate). All passes
conservative; **93 PHP files `php -l` clean · pages render HTTP 200** (test-owner login) after each round.

- **iconChip adoption** [`tailwind-patterns`→`code-reviewer`] — 0 migrations: every inline `inline-grid` chip puts a
  `size-*` token BEFORE `place-items-center`, so a byte-identical swap to `Aurora::iconChip` was impossible. Correctly
  left untouched (the conservative call, confirmed by the fix wave). `iconChip()` stays available for new code.
- **CalendarFormat** [`backend-dev-guidelines`→`architect-review`] — pure clock/format/label helpers extracted from
  `BookingCalendarController` into `frontend\components\CalendarFormat` (119L, 24 controller call-sites).
- **clipboard/swatch JS** [`cc-skill-frontend-patterns`→`bugs-are-annoying`] — new `frontend/web/js/aurora-forms.js`
  (169L, registered in `TailwindAsset.php`) unifies copy-to-clipboard + colour-swatch picking; 2 sites migrated (parity proven 11/11).
- **AJAX `ngPost` helper** [`frontend-dev-guidelines`→`logic-lens`] — added `window.ngPost(url,data)` (CSRF-aware) to
  `aurora-core.js`; adopted only where the existing code was a trivially-identical CSRF+fetch POST.
- **SiteController decomposition** [`backend-dev-guidelines`→`architect-review`] — pure dashboard KPI/donut math →
  `frontend\components\DashboardMetrics` (183L, 6 call-sites).
- **ShopServiceController decomposition** [`php-pro`→`code-reviewer`] — pure form-data shaping → `frontend\components\ShopServiceFormData`
  (75L, 6 call-sites); uploads/DB/request stay in the controller.
- **Security + simplify hardening** [`code-simplifier` + `cc-skill-security-review`] of all new shared modules —
  verdict: **no real issues**. Aurora HTML-emitting helpers `Html::encode()` every user value (12 calls); the only
  un-encoded interpolations are internally-controlled (icon names, tone keys, class literals). `ngToast/ngConfirm`
  inject dynamic values via `.textContent`, only static scaffold via `innerHTML`. CSRF handling in `ngPost` correct.

**Shared modules after 4 rounds:** `components/{Aurora 208L, BookingFinanceMath 103L, CalendarFormat 119L,
DashboardMetrics 183L, ShopServiceFormData 75L}` + `web/js/{aurora-tabs 137L, aurora-forms 169L, ngPost in aurora-core}`.
Totals: 30 copy-pasted closures eliminated · 6 avatar closures unified · 18-file tone vocabulary centralised · tab/clipboard/swatch
JS consolidated · 4 controllers thinned via behaviour-preserving service extraction. Every round render-verified, security-reviewed.

**Still open (deferred — diminishing returns / higher risk):** remaining bespoke inline JS (booking-calendar drag, per-form
validators), deeper decomposition of `BookingCalendarController`/`AgentsBookingsController`/`BookingController` (still 1200–1900L),
and full `Aurora::badge()`/`iconChip()` adoption (needs the varied pill/chip class-orders normalised first — a visual-neutral but
large mass edit). Backend-admin browser verification still blocked on a correct admin password.

## Milestone-N phase-2 sync — dynamic notifications completed (2026-07-04)

Full disposition of demo v0.13.0 in [`DEMO_SYNC_MILESTONE_N_PHASE2_PLAN.md`](DEMO_SYNC_MILESTONE_N_PHASE2_PLAN.md)
(every demo commit `5cbd4b3..cb4712b` marked done/superseded/N-A/ported). Portal commits `5f368af..bb01f13`.

- **Shop settings notifications tab** rebuilt as the demo's final fixed-header table (preview
  expand, schedule label, real per-trigger sent-counts, 3-up channel buttons + ngConfirm cost
  dialog, Platform-managed rows). The demo's interim multi-channel `channels[]` model (f40a7e6)
  was superseded in-demo by the single-paid-channel model the portal already had — skipped.
- **Admin commercial config messaging** → two per-channel cards (sell / cost / refund-on-fail /
  free-month) + live margin badge. Migration `m260704_100000` (4 nullable columns, applied ✓).
- **Notification engine completed**: over-allowance billing to the append-only `charge` ledger
  (VAT'd, linked via `meta.notification_id` — the SHARED `notifications` table untouched),
  `markNotifFailed()` refunds (negative STATUS_REVERSED row, min(perMsg×count, original)),
  `notifications/process-due` cron (5 min, withoutOverlapping, unix-timestamp compares,
  same-day guard), event call sites in booking create/cancel/complete flows.
  17 new unit tests green; fire→bill→fail→refund verified live on the dev stack.
- **Bell inbox**: navbar dropdown (8 recent, mark-all-read, View all) + POST-only scoped
  mark-read + feed page click-to-read with bell↔page DOM sync.
- **Finance surfaces**: per-channel notif-fee split in the settlement strip, "SMS/WhatsApp
  notification" labels, reversal annotation on charges.
- **Fixed in passing**: `/agent-slots` 500 (broken leftover scoping from the security pass —
  the frontend search model already scopes); i18n 19 fe + 16 be keys en+ar (session scan 0
  missing); code-reviewed (0 critical/high; both mediums fixed: reversal-status convention
  aligned with FinanceLedgerService, cron overlap-guarded).
- **GOTCHAS recorded in the playbook**: console ErrorHandler escalates PHP 8.2 E_DEPRECATED
  (MultiLanguageBehavior dynamic props) — mask in console commands touching translated models;
  MySQL JSON columns need `JsonExpression` on write (arrays on read).
- **Still open**: `settlement_received` dispatch (transfer-scoped, backend tier), group-booking
  call sites, real Msegat/T2 delivery + DLR per the demo integration handoff spec.

## Known gaps / follow-ups
1. **Tailwind production build** — still on the Play CDN (dev only). Run
   `npm i -D tailwindcss postcss postcss-loader` + a webpack rule → build `/build/tailwind.css`
   and swap the CDN `<script>` in `tailwind.php` for `<link>`. (Blocked earlier: npm install
   needs the user to run it.)
2. **Dashboard charts** — the Chart.js booking-status/occupancy/customer charts are NOT yet
   ported to the tailwind layout (KPIs + upcoming + rating are live).
3. **Sub-pages** — each surface's create/view/update views are still AdminLTE.
4. **Dropped GridView features** on the converted lists: CSV/Excel **export**, the
   **column-chooser**, and Kartik **Select2/DateRange** filter widgets. Basic GET filters
   (id/customer/status, etc.) are preserved; re-add the rest as Tailwind components if needed.
5. **agents-bookings** is now a booking **list** (was the daily calendar); the walk-in wizard
   JS still exists on the controller but isn't reached from this view.
6. **Visual verification of authed pages is pending** — the converted real routes are
   auth-gated; they pass `php -l` + careful real-data wiring, but need a shop-owner login to
   confirm in a browser (no password available to the agent). POC routes render the chrome
   for guests as a stand-in.

## Booking calendar + detail (ported from the demo, 2026-06-22)
Full booking surface on the tailwind layout, wired to REAL data:
- **List** `/booking/index` · **Day+Month calendar** `/booking/calendar?view=day|month&date=&zoom=`
  · **Detail** `/booking/view?id=` (+ AJAX `/booking/detail-partial`).
- **Logic layer** `frontend/components/BookingScheduleService.php` (port of the demo's
  lib/schedule.ts): working hours from `user_shift` (`"dayId:HH:MM"`, dayId per
  `UserProfile::DAYS_MAP`), shop window from `open_at`/`close_at`, availability = shifts −
  time-off − bookings, `checkPlacement` (overlap/time-off/working-hours/can-perform via
  `user_shop_service`), `freeSlots`, working-specialist columns, full day layout.
  Presentation helpers in `BookingCalendarPresenter.php` (status colours, month grid).
- **Actions** (BookingController): `actionReschedule`/`actionReassign` (TX → overlap-check →
  delete `agent_slots` → update booking → recreate slot mirror), `actionSlots` (JSON),
  `actionTransition` (start/complete/no-show), `actionTimeOff`/`actionRemoveTimeOff`.
- **Time-off** is its OWN table `agent_time_off` (+ `common\models\AgentTimeOff`,
  migration `m260622_130000`). Not reused from user_shift/agent_slots on purpose.
- **Day view** (`_calendar_day.php`): vertical gantt, out-of-shift shading, hatched
  removable time-off blocks, **drag** to reschedule/reassign (HTML5 DnD → the actions),
  zoom, fullscreen, status legend, empty-slot→prefilled create. **Detail** (`_detail_modal.php`
  + `_timeline.php`): 3-column body, payment summary, status timeline, Start/Complete/
  No-show/Cancel/**Reschedule**. Modals: `_timeoff_modal.php`, `_reschedule_modal.php`.
- **Verify** (real data): log in as the [[dev-test-login]] shop owner and open `/booking-calendar?view=day|month`.
  (The old `poc-bookings/*` guest-preview stubs were removed 2026-06-29.)
- **Deferred:** collect-payment / tips / refund-zones (separate finance subsystem); the
  legacy `/booking/bookings` FullCalendar still exists (unlinked from the new chrome).

## Verify
- Lint: `docker exec projects-webserver sh -c 'cd /var/www/html/Navagoo && php -l <file>'`
- Render check: log in as the [[dev-test-login]] shop owner (`shop.navagoo.localhost`) and curl/click pages;
  toggle `document.documentElement.dir` for RTL. (POC guest-preview routes were removed 2026-06-29.)
- Calendar: `/booking-calendar?view=day&date=2025-12-10` + `&view=month` (authenticated).
- Real pages: log in as a shopOwner and click through every sidebar item.

## Automated tests (2026-06-29)
**Codeception is now installed + configured.** Dev deps added to `composer.json`: `codeception/codeception ^5`,
`codeception/module-asserts`, and **`behat/gherkin` pinned `~4.12`** (4.17 has an `i18n.php` path bug that breaks
codecept 5). Config scaffolded: `common/codeception.yml`, `common/tests/unit.suite.yml`, `common/tests/_bootstrap.php`
(boots a minimal Yii console app so unit tests get a live `Yii::$app` — formatter/db/security).

- **Unit tests** for the refactor's pure logic, all green:
  `common/tests/unit/components/{Aurora,CalendarFormat,DashboardMetrics,FinanceLedgerService,ShopServiceFormData}Test.php`
  — **99 tests / 163 assertions**. Run: `docker exec projects-webserver sh -c 'cd /var/www/html/Navagoo/common && ../vendor/bin/codecept run unit components'`.
  (First time on a fresh checkout: `composer install` then `cd common && ../vendor/bin/codecept build`.)
- **Render smoke test**: `tests/smoke/render-smoke.sh` — logs in as the [[dev-test-login]] owner, asserts HTTP 200 +
  no PHP/Yii error signatures across 15 key pages (exits non-zero on failure). Last run: **15/15 pass**.
- FinanceLedgerService coverage includes VAT math, per-shop-vs-default rate precedence, the CommercialConfig fallback, and refund-zone thresholds.

## 2026-07-06 — "Navagoo Plans" (Finance → Subscription) re-wired to the real tables
Plan: [`NAVAGOO_PLANS_SHOP_PAGE_PLAN.md`](NAVAGOO_PLANS_SHOP_PAGE_PLAN.md). The shop-side
Subscription tab was mis-wired to `subscription_package` (the shop's own customer packages).
It now backs the shop's **SaaS subscription to Navagoo** — the demo's `Subscription.tsx`:
- **Tables (already existed, m260624_130100):** `navagoo_subscription_plan` (catalogue,
  admin-managed via `ShopController::actionPlans`) + `shop_subscription` (one row per shop).
  New migration **`m260706_120000_navagoo_plans_shop_page`** added
  `shop_subscription.free_period_ends_at` and **seeded 3 active plans** (Starter 99 /
  Professional 199 / Business 349 SAR/mo; 6-mo = ×6×0.9, 12-mo = ×12×0.8). Applied ✓.
- **Models:** `NavagooSubscriptionPlan` (`priceForPeriod`/`perMonthPrice`/`savePctForPeriod`/
  `featureList`/`findActivePlans`, STATUS_/PERIOD_ consts), `ShopSubscription`
  (`periodMonths`/`isActive`/`isFreePeriod`/`isCancelled`, `free_period_ends_at` in rules).
- **Controller `EarningsController`:** `subscription` tab loads active plans +
  `ShopSubscription::findCurrentForShop` + user_card; `actionSubscriptionActivate` upserts the
  single shop_subscription row (plan+period+status=active+next_billing_at+card_last4);
  `actionSubscriptionCancel` flips status=cancelled. add-card/set-default unchanged.
- **View `earnings/_subscription.php`:** full demo-parity rebuild — current-status card
  (Crown, plan/No-active-plan, Free-period/Active/Cancelled badge, trial/renew line, Cancel),
  **functional** Monthly/6mo/12mo segmented (JS swaps server-rendered per-month price + Save%
  + billed line; no client money math), plan grid, payment cards + add-card modal.
- **i18n:** 14 new keys in both `ar`+`en` frontend.php (incl. ICU-plural relative-days).
- **Verified in-browser** (owner login): render → period toggle → Activate → Active/Renews +
  Current-plan → Cancel. Test subscription row deleted afterwards (seed plans kept).
- **Do NOT confuse** `subscription_package` (shop→customer) with `navagoo_subscription_plan`/
  `shop_subscription` (shop→Navagoo SaaS). The shop's own packages still live at `/package`.

### 2026-07-06 (later) — admin "Navagoo Plans" made menu-reachable
The ADMIN side (demo admin → Shops → "Subscription Plans" + "Subscription Dashboard") was
already fully built in wave-3 (`ShopController::actionPlans/actionSubscriptions/actionSubscription`
+ `backend/views/shop/{plans,subscriptions,_tabs}.php` — plans CRUD, dashboard table with
assign/activate/cancel/flag) but was **unreachable from the sidebar menu** (only via the
in-page 4-tab segmented on Shops). Changes:
- `backend/views/layouts/menu/Menu.php` — two new entries under **Shop Details**:
  **Navagoo Plans** (`/shop/plans`, crown) + **Subscriptions** (`/shop/subscriptions`,
  credit-card), visible to administrator/`shop_index` permission. Both chromes pick this up
  (the tailwind `_tw_admin_sidebar` requires the same Menu.php).
- `_tw_admin_sidebar.php` — added `crown` to the fa→lucide icon map (was falling back to dot).
- `subscriptions.php` — added the demo CardHeader ("Subscription dashboard / Per-shop plan
  status & billing") inside the card, matching Shops.tsx SubscriptionDashboard.
- `ShopController::nextBillingFrom()` — now uses `ShopSubscription::addMonthsClamped()` (the
  end-of-month overflow fix), so admin + shop-portal billing dates compute identically.
- i18n: 3 new backend keys in ar+en (خطط نافاجو / لوحة الاشتراكات / حالة الخطة والفوترة لكل متجر).
- Verified: php -l clean on all 6 touched files; `/shop/plans` + `/shop/subscriptions` curl 302
  (routed, auth-gated); i18n hook clean. **In-browser admin screenshot BLOCKED** — the
  backend admin password recorded 2026-06-28 no longer matches the stored hash
  (password_verify=false → genuinely rotated). Needs the current admin password to complete
  the visual check (pages themselves were browser-verified when built in wave-3).

### 2026-07-06 — "Navagoo Plans" WAVE 2 (standalone page, demo v0.19) + full portal design audit
- **Demo synced v0.13→v0.19** (origin/dev). Subscription became a top-level shop page
  (`/shop/plans`); ported to portal `/navagoo-plans` with offers (coupon tickets, live
  repricing), tier gradient cards, upgrade/downgrade + proration, past-due, billing. New:
  migration `m260706_150000_navagoo_plans_v2`, models `NavagooOffer`/`ShopOfferEnrollment`
  (+ plan tier/band + sub term/status helpers), `NavagooPlansController`,
  `frontend/views/navagoo-plans/{index,_offer_ticket}.php`, sidebar+legacy menu entries,
  Finance Subscription tab removed (redirect). 84 i18n keys ar+en. Full commit-free changeset
  browser-verified end-to-end (subscribe/upgrade/downgrade/cancel + past-due ledger charge);
  smoke 30/30. Details: playbook §7 (2026-07-06 WAVE 2) + `NAVAGOO_PLANS_SHOP_PAGE_PLAN.md`.
- **Full shop-portal design-parity audit** (18 sub-agents, one per surface) →
  **`ai_specs/05_PLANS/SHOP_PORTAL_DESIGN_PARITY_AUDIT.md`**. Class-level typography/color/
  spacing/structure comparison vs the v0.19 demo. Tokens ~95% identical → gaps are per-surface
  class usage. **29 High / 49 Med / 48 Low** across 18 surfaces. 4 SYSTEMIC (fix once): missing
  `focus-visible` rings (6 surfaces), header avatar wrong shape/size/fill vs demo ShopTile
  (5), header action buttons undersized (4), catalog-card banner-header vs demo image-left
  layout (3). Lowest parity / most work: Dashboard-Home 42% (demo replaced Dashboard with a
  schedule-hero Home — portal still on old Dashboard), Services+Packages 55%, Notifications
  58%, Modals/Drawers 68%, Customers 72%, Settings 78%. Highest: Navagoo Plans 93%. The doc
  has a per-surface findings table + ordered plan for every surface.
- **Audit EXECUTED same day** (see the ✅ EXECUTION STATUS block at the top of the audit doc):
  4 systemic fixes + all shared chrome (orchestrator) + **14 sub-agents** applying 80 per-surface
  class changes. Global wins landed: focus-visible rings + global outline + shadow-focus token;
  colored ShopTile avatars (sidebar+navbar); st-* → demo hues + navy-* ramp; **toast** rebuilt as
  the demo white-card (bottom-end); modal backdrops → navy-950/45; ngConfirm text-lg + rounded-xl
  demo-token buttons; **Aurora::TONES badges** gained ring-1 ring-inset + -700 text (all badges);
  active segmented-tab → text-brand-700; dashboard hero → bg-aurora-h; content max-w 1600 + responsive
  gutters. Verified: php -l all changed, node -c both JS, build:css rebuilt, **smoke 30/30**, browser
  spot-checks (chrome/toast/confirm/dashboard).

### 2026-07-07 — Home re-port + Analytics (new) + sidebar IA to demo v0.19
- **Menu (`_tw_sidebar.php`) reorganised to the demo shop nav**: Home on top; sections
  **Operations** (Calendar · Analytics · Services · Customers), **Money** (Finance · Transfer
  Requests · Specialists Tips · Agents · Notifications), **Growth** (Promo Codes · Package), **More**
  (Branches · Customer Invitations · Reviews · Help center); **Navagoo Plans + Settings pulled into
  the FOOTER** (Plans on top, Settings + collapse toggle below) — matches Sidebar.tsx. Active-state
  detection scans `$detectSections` (nav + footer). Verified in-browser.
- **Home (`frontend/views/site/index.php` + `SiteController::buildDashboardStats`)** re-ported to the
  demo v0.19 ShopHome: time-of-day greeting, aurora banner → /navagoo-plans, **5-KPI band** (today's
  bookings + next-in-min, expected value + deposits, revenue-this-week + WoW delta, utilization donut,
  team on shift), **schedule hero** (today's timeline) + right column (**attention feed** + **balance
  panel**, real SAR figures), **bottom band** (week-at-a-glance Chart.js bars, this-week ops, recent
  activity, quick actions). All from real `$stats` — new SiteController methods for greeting/today/
  week/team/attention/balance/banner. No mock data.
- **Analytics — NEW page** (`ShopAnalyticsController` + `views/shop-analytics/index.php`, route
  `/shop-analytics/index`): the demo shop Analytics ported fresh (didn't exist). Real-DB via
  `FinanceLedgerService` (reconciles with Earnings): revenue/net/bookings KPIs w/ period deltas,
  revenue+bookings trend, top services + specialists, status + category donuts, customer mix, peak
  hours; allowlisted period selector (this/last month · 3/6/12m). **Chart.js v3.7.1** self-hosted
  (`frontend/web/js/libs/chart.min.js` via registerJsFile — the layout does NOT auto-load it).
- Verified: php -l all; i18n hook clean (33 new frontend keys ar+en total across both ports);
  **build:css**; **render smoke 31/31** (added `/` + `/shop-analytics`); browser: Home (5 KPIs +
  banner + week canvas + attention + balance SAR 504.17, 0 console errors), Analytics (5 Chart.js
  charts rendered, SAR 841 real, period selector re-queries).
- **Scoped-down (real-data limits, flagged by the Analytics agent):** on-time start/finish,
  app-vs-walk-in & Navagoo-vs-shop origin donuts, busiest-day heatmap — omitted (need demo-only
  schema fields the portal doesn't carry). Utilization = capacity approximation (specialists×8h×days).

### 2026-07-07 (later) — Analytics scoped-down widgets ENABLED (no new schema)
Turned out NO new columns were needed — the fields exist:
- **Booking origin** (App / Walk-in) ← `booking.booking_method` (`mobile` / `walk_in_shop_portal` /
  `walk_in_specialist_app`; NULL→App, the legacy default).
- **Customer source** (Navagoo / Shop) ← each in-period customer's EARLIEST booking method (one
  `MIN(id)` subquery, no N+1).
- **Busiest days** ← `date('w')` of `booking_date`.
- **On-time start/finish** ← actual start = existing **`booking.start_time`** (a UNIX ts, set on the
  →INPROGRESS transition), finish = `completed_at`/`end_time`, vs scheduled (`booking_date`+`from_hour`
  + duration). Computed over the non-null/numeric subset with an honest "based on N …" caption + empty
  state; never /0.
- **WRITE-PATH GAP CLOSED:** the mobile/specialist API already stamped `start_time` on start
  (`api/controllers/agent/BookingsController.php:371`), but the PORTAL transition did NOT →
  `BookingController::actionTransition` now sets `$model->start_time = time()` on →INPROGRESS
  (mirrors the API). **Live-verified:** POST `/booking/transition?id=…&status=4` → `start_time`
  stamped as a unix ts (reverted the test row after). No schema change, no shared-API change (the
  API already wrote it). All 4 widgets are Chart.js, real-data, shop+period scoped; 8 canvases render;
  smoke 31/31; php -l + i18n clean.

---

## 2026-07-16/17 — Admin (backend) content parity sweep vs demo-admin v0.28

Full review of the backend admin against the demo **admin** portal (v0.28, up from v0.19),
then aligned it area-by-area. Report + wave table: `ai_specs/05_PLANS/ADMIN_PARITY_V28_SWEEP_2026_07_16.md`.

- **Reviewed** all 15 admin areas (6 parallel agents, code-level demo↔backend). Corrected one
  reviewer misread: admin Home is an `if/else`, NOT shop-contaminated.
- **Shipped + browser-verified** (Wave 1 + Wave 2): Home attention feed (data-shape bug fixed),
  Finance (TR fee cols / settle strip / balances / charges CSV+PDF+Pending / commercial-config /
  shell dedup), Shops+Subscriptions (branch badge, Plans tier-cards + Feature matrix + PlanModal,
  Offers CRUD, real Payment-method toggles+guard, Subscription-dashboard KPIs+pending-verif),
  Bookings/People/Notifications/Events (Events search+target+expandable payload), Settings 5
  workflow fields, Marketing push "Shops" audience, Catalogue Women/Men filter, Geography
  bilingual + active toggle, Support Policies tab + 7 contact channels, Invitations pending/history
  split, Users&Roles Shop+Last-active cols, NEW Live-chat stub+nav, Dashboard 5 BI tabs
  (Overview sparklines + Volumes/Performance/Financials/Flags charts).
- **4 migrations** (all additive/nullable, api-impact-checked = no mobile contract change):
  geography name_ar/active, commercial_config grace/trial + payment-method flags, settings.tiktok
  + faq.audience. Plans needed no migration (columns pre-existed).
- **Pre-existing bug fixed:** `shop-category/catalogue.php` 500 on `$parent->name_ar`
  (MultiLanguageBehavior virtual attr not on this route) — guarded.
- **Flag (mobile API):** when FAQs get `audience=shop`, the mobile FAQ endpoint should filter to
  `audience=customer`. Not changed; flagged per shared-API rule.
- **Uncommitted** — awaiting user review/commit. Admin CSS rebuilt (`build:css:admin`).

---

## 2026-07-17 — Demo motion system ported to BOTH portals (admin + shop)

Ported the demo's animation language (`Navagoo_MI/src/lib/motion.ts` + `components/motion/*`)
to the server-rendered portals. Demo is framer-motion; ours is pure CSS (+ a tiny replay JS).

**Why CSS is enough:** the demo has **zero `whileInView`** — every `<Reveal>`/`<Stagger>` is
mount-time (`initial→animate`). Server-rendered "mount" = page load, so no IntersectionObserver
is needed. Tokens are explicit numbers, so they port 1:1.

**Tokens (identical in both portals, from lib/motion.ts):** DURATION base .31s / quick .22s ·
EASE.out `cubic-bezier(.22,1,.36,1)` · EASE.spring `cubic-bezier(.34,1.56,.64,1)` · SPRING.hero
.55s · RISE hero 16px / **data 0 (fade-only — a transform on `<tr>` forces table reflow)** ·
STAGGER hero 90ms / data 26ms · STAGGER_CAP hero 24 / data 12 · page transition opacity-only
(`<main>` is the scroller). `prefers-reduced-motion` kills all.

**HERO spring is an approximation:** framer runs a physics spring (duration .55/bounce .32);
CSS can't. We use the demo's own `EASE.spring` bezier at the same duration — near-identical.

**Two dialects (deliberate — tokens/feel identical, API differs):**
- **admin** (`backend/web/css/tailwind.src.css` + `backend/web/js/ng-motion.js`): `.ng-page`,
  `.ng-reveal`, `.ng-stagger`, `.ng-stagger-data`. Index + cap via **`:nth-child`** → applying =
  ONE class on the container, no per-row PHP, no JS. `ng-motion.js` replays on tab switch
  (`data-ng-tab`/`data-support-tab`/`data-tab` panels are display-toggled).
- **shop** (`frontend/web/css/tailwind.src.css`, pre-existing): `.ng-page-transition`,
  `.ng-stagger-hero`/`.ng-stagger-data` + **`.ng-stagger-item` on every child** (`aurora-core.js`
  sets `--ng-stagger-i`, caps 24/12), `.ng-table-animate` on a `<table>` (nth-child, cap 12),
  plus `.ng-hover-lift`, menu + RTL-aware toast anims. Added `.ng-reveal` (was missing).
  Unifying the two dialects is an open cleanup.

**BUG found + fixed:** a container with `ng-reveal` that *holds* a stagger fades 0→1 over its
children, **masking the cascade** (opacity multiplies). The demo never wraps a `<Stagger>` in a
`<Reveal>` (zero occurrences). Fixed with ONE rule per portal instead of un-picking ~200 files:
`.ng-reveal:has(.ng-stagger,.ng-stagger-data){animation:none}`. `.ng-page*` is exempt by design
(the demo's PageTransition does wrap Staggers).

**Coverage:** admin ~157 views (6 parallel agents); shop 48 views (4 agents, on top of ~20 that
already had it). Note the demo itself only animates 18 admin files (e.g. `Events.tsx` has none) —
we extended the same language to every screen per the user's request.

**Verified (computed styles, live):** admin header `ng-rise-in .55s` spring; admin rows
`0→26→52…286ms` then `none` at row 12 (cap); guard turns the table card's animation to `none`;
shop KPI items `--ng-stagger-i` 0..3 → 0/90/180/270ms; shop table rows 0/26/52/78/104ms.
Lint clean on all 246 changed files · admin smoke 9/9 · shop smoke 30/30.

**Uncommitted** — awaiting user review/commit. Both bundles rebuilt (`build:css` + `build:css:admin`).

---

## 2026-07-21 — Admin Home rebuilt to full demo parity (v0.28)

User flagged the admin Home "looks nothing like" the demo (`/admin/home`). Compared the demo's
full home (local demo == deployed dev, same commit cb48c8d) against ours; the skeleton matched but
the depth was thin. Rebuilt `backend/views/site/_admin_home.php` + supporting aggregation:

- **KPI card 6**: Open Refunds → **Navagoo earnings MTD** + MoM delta chip (new date-scoped
  `SiteController::navagooEarningsInRange()` — `buildFinanceStats` is lifetime, so it couldn't drive
  MTD). Card 4/5 captions → "owed to shops" / "owed to Navagoo".
- **Attention feed** (biggest gap): 4 plain rows → up to **10 rich rows** via
  `PlatformBiService::flagsDetailed()` (added 10th flag `failed_notifs`), filtered count>0, severity
  count-chip + label + **money** on the right (pastDue→atRisk, refunds/settlements/bankVerifs→
  moneyInMotion) + deep-link.
- **Money-in-motion** card (was missing), **Quick actions** 2→4, **Today-vs-yesterday** +Cancellations
  (4→5 rows), **Top shops** now shows GMV + negative-contribution watchlist button, **Platform week**
  real WoW% (`buildPlatformWow`), **Subscriptions pulse** rebuilt to the demo **5-cell** grid
  (Active/Trial/Past-due/Expired/Verify) — added `verifyQueue` to `SubscriptionMetricsService::pulse()`.
- 63 new bilingual keys. php -l clean · admin CSS rebuilt.

**failed_notifs = 0** (no delivery-failure column in the notifications send-log; flag stays, filtered
out until a real failure signal exists).

**✅ Browser-verified 2026-07-22** (was blocked 2026-07-21 by a login issue, since resolved —
`admin`/`NavAdmin!2026` authenticates fine). Confirmed live on `backend.navagoo.localhost`: Navagoo
Earnings MTD card + MoM chip, "owed to shops"/"owed to Navagoo" captions, the rich Needs Attention
feed (4 populated rows incl. money on the refunds row: "17 open refund cases · ⃁1,986.97"),
Money-in-motion card, all 4 Quick Actions, the 5-row Today-vs-Yesterday (Cancellations present),
Top Shops' negative-contribution watchlist link, real Platform Week WoW% ("+100.0% vs prior week"),
and the 5-cell Subscriptions pulse grid (Active/Trial/Past-due/Expired/Verify queue). Zero console
errors. Uncommitted.

## 2026-07-21 — Shop sidebar IA fixed to demo parity

Design review flagged the shop sidebar's groups/labels/items diverging from the mockup. Full
plan/detail in `SHOP_SIDEBAR_IA_PARITY_FIX_2026_07_21.md`; confirmed against demo `nav.ts`
`SHOP_NAV` (source of the mockup). `ShopNav.php::sections()` restructured:

- **Operations**: "Calendar" → **Bookings**; **Packages** item moved in from Growth.
- **Growth**: Promo Codes relabeled **Marketing** (demo's Marketing page *is* the promotions
  manager); new **Invitations** item wired up (`CustomerInvitationsController` — already built,
  never linked in the sidebar); **Branches** moved in from "More".
- New **System** section: single **Help** item (was "Help center" buried in "More").
- "More" trimmed to the two demo-orphan features: Reviews, Manage Social Media.

Added `System`/`Invitations`/`Help` i18n keys to both `en`/`ar` frontend message files (reused
existing `Bookings`/`Marketing`/`Packages` keys). No route/controller changes — both moved-in
items already existed, just weren't in the nav. php -l clean; browser-verified live on
`shop.navagoo.localhost` (sidebar hrefs match plan exactly; `/customer-invitations` and
`/package` both render correctly with proper active-nav highlight). Uncommitted.

## 2026-07-21 — Settings (5-tab) parity fixes, incl. a real Payments rendering bug

Design review across all 5 Settings tabs. Full detail in `SETTINGS_TABS_PARITY_FIX_2026_07_21.md`.
Every finding was checked against the **live** rendered page, not just source — source reading
alone was actively misleading on Payments.

**Critical bug found + fixed**: Payments tab showed all three methods as unlabeled "Walk-in"
rows — titles/descriptions were completely absent from the DOM. Root cause:
`yii\bootstrap4\ActiveField::checkbox()` reads `'template'` from **its own** `$options` arg, not
the field-config array — passing the custom pill template via `$form->field($model, $attr,
['template' => ...])` gets silently clobbered back to Bootstrap4's default checkbox markup the
instant `->checkbox()` runs. Fixed in `frontend/views/settings/_payments.php` (3 fields) by moving
`'template'` into `->checkbox([...])`'s own options array. Browser-verified: all three rows now
show correct icon/title/description. (Two-toggle APP/WALK-INS redesign — the report's other
Payments complaint — scoped but not built; no schema change needed, `walkin_*_enabled` columns
already exist.)

**Product/business decisions** — asked via AskUserQuestion rather than guessed, then implemented:
- **Bank/IBAN** → made inline-editable on the General tab (was read-only, "add via withdrawal
  request"). `common/models/BankAccount.php` gained Saudi-IBAN format validation + normalization;
  `SettingsController::saveBankAccount()` persists it alongside the Shop save (no-op if both
  fields left blank). Verified live round-trip; test row cleaned from the dev DB after.
- **Min withdrawal default** → lowered 5000→50 SAR via
  `m260721_213000_lower_shop_min_withdrawal_default.php` (backfills only shops still on the
  untouched old default, preserves any admin-customised override). Applied + verified
  (Commercials tab now shows ⃁50.00).
- **Commercial Name vs Shop Title** → kept both, intentionally distinct (legal/single-lang name
  vs. bilingual public title). No code change.
- **Notifications** → added a platform-wide "Reminder — 3 hours before" trigger and authored
  in-app template copy (EN+AR) for all 7 triggers, which had NULL templates on every channel
  (confirmed by direct DB query — that's also why message previews were blank everywhere).
  A required follow-up migration then approved all 8 triggers, because the Settings view actually
  reads `NotificationDispatchService::channelUsable()` (requires `approval_status=1` for **every**
  channel including in-app) rather than `NotificationTrigger::channelUsable()` (whose own docblock
  says in-app never needs approval) — a real inconsistency between the two, discovered and worked
  around rather than reconciled. SMS/WhatsApp stay correctly locked (still no templates authored).
  Verified live: all 4 customer triggers show "On · free" with real preview text; new 3h trigger appears.

3 new migrations applied (`m260721_213000`, `m260721_214500`, `m260721_215500`). php -l clean on
all touched files. Uncommitted.

## 2026-07-21 — Navagoo Plans: bank-transfer subscribe rebuilt to upload→verify (was auto-netted)

Design review: Subscribe-to-Growth via bank transfer showed "Settled from your payouts"
(auto-netted from future settlements) instead of the designed "Upload slip → verified"
flow, had no upload control at all, and could mint duplicate outstanding charges on
resubscribe (no idempotency guard). Full trace + decisions in
`NAVAGOO_PLANS_BANK_TRANSFER_FIX_2026_07_21.md`.

Traced the existing codebase first rather than building fresh: `Invoice` already has
`STATUS_PENDING_VERIFICATION` + slip columns; `EarningsController::actionPayInvoice()`
already ships a hardened slip-upload endpoint; `_invoices.php` already has the exact
designed copy ("Upload slip → verified"); the admin "Pending bank-transfer
verifications" queue (`ShopController::actionSubscriptions()` + `AdminInvoiceController
::actionVerify()`) already exists — none of it was wired to subscriptions. Also found a
real pre-existing bug along the way: `actionPayInvoice()` never set the invoice's
`status` to `pending_verification` (only `document_status`), so a slip upload never
actually reached that admin queue for ANY invoice type — fixed as part of this pass
(refactored onto a new shared `FileValidationConfig::validateTransferSlip()`, which also
adds a polyglot-signature scan the old hand-rolled check didn't have).

**Built** (per your decisions — one-step upload matching the demo exactly; keep
subscription status=past_due and fix the display only, not add a new status):
- `NavagooPlansController::actionSubscribe()` — bank-transfer-no-trial now requires +
  validates + saves the slip in the SAME request, raises a real linked Invoice+Charge
  (new `Invoice::TYPE_SUBSCRIPTION`) instead of an unlinked auto-netting Charge, and
  blocks a second attempt while one is unresolved (fixes the duplicate-invoice bug).
- `AdminInvoiceController::actionVerify()` — now reactivates the linked
  `ShopSubscription` for a `trigger=subscription` invoice (previously had zero
  subscription awareness).
- `navagoo-plans/index.php` — Subscribe modal's bank-transfer tile gained an inline
  upload dropzone (FormData/multipart only when a slip is required); hero shows
  "Pending verification" instead of "Past due" when a linked invoice is awaiting
  verification (display-only — stored status unchanged, zero ripple into admin
  metrics/gates); Outstanding row shows an "Awaiting verification" badge.
- Caught + fixed a self-introduced duplicate-display bug mid-build: the legacy
  `$dueSubCharges` query didn't exclude charges now linked to an invoice, so a fresh
  subscribe briefly showed twice ("CHG-N" + the real invoice row).

**Verified live end-to-end** (shop 20, cleaned up after): subscribe w/ real uploaded
slip → invoice+charge created+linked, correct amounts/VAT; duplicate resubscribe
blocked (zero new rows); Plans page showed "Pending verification"; admin's existing
"To verify" queue picked it up with no code changes needed there; clicked Verify →
charge flipped paid, subscription flipped active, Plans page moved the invoice
Outstanding→History. php -l clean on all touched files. Uncommitted.

**Deferred** (flagged, not fixed): `actionUpgrade()`'s bank-transfer-with-proration
path still needs the same slip treatment (not in this bug report's scope); admin
`shop/subscriptions.php` badge map has no `past_due` entry (renders "None") — found
while tracing Decision B, unrelated pre-existing gap.

---

## 2026-07-21 — Admin sidebar reorganised to demo nav IA

User: "the admin menu isn't the same [as the demo]; keep our extras." Rewrote the ACTIVE part of
`backend/views/layouts/menu/Menu.php` (Home/Dashboard + 4 sections; dead commented tail untouched)
to the demo ADMIN_NAV order/sections/labels — verified live in the sidebar:

- **Operations**: Shops · Bookings · People
- **Money**: Finance · P&L · Costs · Subscription Plans · Catalogue · Notifications · Geography
- **Growth**: Marketing · Live chat · Invitations · Support & Content
- **System**: Events · Users & Roles · Settings

Fixes: People moved Growth→Operations; Notifications/Geography/Catalogue/Subscription-Plans moved
into Money; clean demo labels (Shops not "Shop Details", P&L not "Platform P&L", Costs not
"Costs (COGS)", Support & Content).

**Follow-up (same day): fully FLATTENED.** Demo `src/portals/nav.ts` ADMIN_NAV is flat (no
treeview). Removed all 11 `has-treeview`/`items` groups from the active array; every former
child is now a flat sibling in the same section (e.g. Shops · Add Shop; Finance · Payment
Details · Navagoo Earnings · Cancellations & Refunds · Transfer Requests; Settings + its 4
config pages). Zero treeview in the active menu; each item keeps its own `visible` gate.
Commented dead-code tail (Lookups/Reports/system Files) left untouched. No CSS rebuild needed.

Superseded submenu approach (for history): **Extras were briefly submenus (▸)** under the closest item:
Shops[+Add Shop] · People[+Specialists,+Customers] · Finance[+Payment/Earnings/Cancellations/
Transfers] · Subscription Plans[+Dashboard] · Catalogue[+Categories,+Services] · Notifications
[+Push] · Geography[+Districts] · Marketing[+Ads] · Support & Content[+FAQs,+Contact,+Demo
Requests] · Users & Roles[+Managers]. **Every `visible` permission gate preserved** (parent =
OR of children's gates; each child keeps its own). Shops active-state narrowed to exclude
plans/subscriptions/offers/payment-methods (those belong to Subscription Plans). php -l clean;
8 new bilingual labels. Admin password (username `admin`, id=1) rotated on request via a
one-off console script — new value not recorded here (ask the owner).

Note: host `/private/tmp` volume was ~99% full (228Gi disk) — freed this session's large agent
transcripts; unrelated to the app.

---

## 2026-07-22 (cont.) — Admin menu strict demo-match + Settings tab-bar + shop nav icons

Continued from the sidebar reorg. Corrections after live review:

- **Admin menu → strict demo parity (19 items).** The earlier "flatten extras as siblings"
  was wrong: those extras (Specialists, Services, Push, FAQs/Contact/Demo, Settings sub-pages,
  Subscription Dashboard, legacy finance pages…) are TABS inside their parent's consolidated
  page, not menu items. Reduced `Menu.php` to exactly the demo ADMIN_NAV: Home · Dashboard ·
  [Ops] Shops/Bookings/People · [Money] Finance/P&L/Costs/Subscription Plans/Catalogue/
  Notifications/Geography · [Growth] Marketing/Live chat/Invitations/Support & Content ·
  [System] Events/Users&Roles/Settings. Each parent's `active` broadened to stay highlighted
  on its tab/sub-routes; `visible` OR-merged the children's gates. Route→lucide map in
  `_tw_admin_sidebar.php` gives exact demo icons.
- Parents already carry their tabs (People ?tab=, Catalogue segmented, shop/_tabs, Support,
  admin-finance/_tabs, Geography, Marketing, Users&Roles). **Fixed a real gap:** Settings
  General (index→_form) wasn't rendering `settings/_tabs.php` — now it does (update.php renders
  `_tabs` active=general; removed _form's duplicate header).
- **Two pre-existing fatals fixed** (hit during review, unrelated to the menu): settings
  `_form.php` `$form` captured before ActiveForm::begin → by-reference; notification-trigger
  `_row.php` closure return type `string`→`array`.
- **Genuinely orphaned non-demo admin pages** (no menu, no tab): `/referral` (Invitation codes),
  `/push-notification`, `/demo-request`. User to decide a home (kept reachable by URL for now).
- **Shop nav (`frontend/components/ShopNav.php`)** already matched demo SHOP_NAV + spacing;
  fixed 2 icons only: Bookings `calendar-range`→`calendar-days`, Customers `contact-round`→
  `users`. The portal-only "More" section (Reviews · Manage Social Media) kept per user.

All verified live. Still uncommitted.

---

## 2026-07-22 (cont.) — Admin static-data / dead-logic sweep (6-agent audit)

Audited EVERY demo-facing admin surface (19 menu areas across ~40 controllers/views) with 6
parallel agents for (a) static/mock data and (b) cosmetic-only logic. **Result: the admin is
genuinely wired to the real DB** — the prior parity waves were solid. Findings + fixes:

- **Finance/P&L/Costs · Shops/Subscriptions · Catalogue/Geo/Notifications** → 100% clean. All
  cards/KPIs are live aggregates (FinanceLedgerService, PlatformPnl/BiService, SubscriptionMetrics,
  grouped counts); all toggles/reorder/CRUD/forms persist to DB with working handlers.
- **Bookings/People** → clean (Classifications admin-override really persists+audits+re-derives
  charges). Removed orphan `BookingController::actionBookings` + `views/booking/bookings.php`
  (broken: undefined $events + admin-has-no-shop; unreachable — no route/menu link).
- **Home/Dashboard** → clean. Removed an unreachable `_subscription_pulse` render in site/index.php
  and deleted the now-orphan `views/site/_subscription_pulse.php` (also had a latent list-vs-keyed
  bug).
- **Growth/System** → clean (Marketing metrics live; Users&Roles/Managers read/write real RBAC;
  timeline-event audit is a live filtered query; Settings tabs all persist). **Live Chat was the
  only real static/mock**: 3 seeded fake conversation rows (mirroring the demo's FAKE_THREADS
  coming-soon). Replaced with an honest empty state ("No conversations yet" — bilingual added) +
  kept the coming-soon card. Real wiring still needs a Conversation/Message model (schema + shared
  API) — out of scope.

Everything verified: all changed admin pages return HTTP 200, no errors; php -l clean.
Non-blocking notes left as-is (real data, not mock): withdrawal KPI tiles are page-scoped by
design; shop `_tabs` Requests badge defaults to 0 on 4 tabs (cosmetic); SiteController
notifications.unread=0 (no admin-notification source in schema).

---

## 2026-07-22 (cont.) — NVG-BEA-002 W2: fee engine wired into real flows

See `ai_specs/05_PLANS/NVG_BEA_002_PAYMENT_PROCESSING_FEE_PLAN.md` live tracker for the full
writeup. Short version: the processing-fee Charge previously only got created via group-booking
creation, cancel, classification override, or demo billing — a **normal completed booking never
got one**. Fixed by:
- `FinanceLedgerService::processingRatePct/Fixed` now resolve `ppf_pending_rate/_fixed` first when
  `ppf_effective_date` has arrived (lazy, no cron, no write-back — promotion is W3), then the
  per-shop override, then CommercialConfig, then the CEO default.
- `buildProcessingFee()` gained a booking-scoped existence guard (one processing_fee Charge per
  booking_id, ever) — this also fixed a latent double-stamp bug (group-booking creation stamps the
  fee immediately; a later cancel re-running `deriveBookingCharges()` would have stamped a second).
- New `FinanceLedgerService::ensureProcessingFee(Booking)`, called from
  `BookingCompletionService::ensureEarnings()` — the one chokepoint every real completion path
  (solo, group-child, in-store collect-then-complete) already shares. Soft-guarded (try/catch),
  same pattern as the rest of the booking controllers.
- Deliberately did NOT hook `PaymentEventHandler::onPaymentSuccess` (dead code, never sets
  `amount_collected`) — documented the reasoning in the plan doc.

Files: `common/components/FinanceLedgerService.php`, `common/services/BookingCompletionService.php`.
`php -l` clean; `codecept run unit` — FinanceLedgerServiceTest 28/28, BookingCompletionServiceTest
2/2 green; pre-existing unrelated failures (Otp/Sms/Token model tests) untouched by this change.
Still uncommitted (stacked on the uncommitted W1 schema/model changes).

Still uncommitted.

---

## 2026-07-22 (cont.) — NVG-BEA-002 W4: shop portal surfaces (frontend tier)

See `ai_specs/05_PLANS/NVG_BEA_002_PAYMENT_PROCESSING_FEE_PLAN.md` live tracker for the full
writeup. Short version, frontend-tier only:
- **Settings → Commercials**: shows an "upcoming payment processing rate" notice when the shop has
  a scheduled-but-not-yet-live `ppf_pending_rate/_fixed` (`ppf_effective_date` strictly in the future).
- **Earnings**: new "Payment Processing Fee" per-booking column, batched Charge lookup (one query
  per page), reads the stamped `total_amount` as-is (never recomputed from current rates).
- **Settlement Request** (`agents-wallet/create`): new "Payment Processing Fee" column + "Total
  Payment Processing Fees" deduction card; net formula now matches `earnings/_settlement.php`
  exactly (`final_collected + tips − marketing fees − payment processing fees − VAT`); only UNPAID
  processing-fee charges are deductible. Two new private controller helpers
  (`unpaidProcessingFeesByBooking()`, `settlementRowBreakdown()`) are shared between
  `actionCreate()` and the new `actionPdf()` so the two surfaces can never diverge.
- **Settlement PDF**: new `AgentsWalletController::actionPdf($id)` (Mpdf, follows the only prior
  pattern in the repo — `backend\controllers\PaymentController::actionGeneratePdf`) + new
  `frontend/views/agents-wallet/_pdf.php` (plain HTML/inline styles) + a "Download PDF" button on
  `agents-wallet/view.php`.

Files: `frontend/controllers/{SettingsController,EarningsController,AgentsWalletController}.php`,
`frontend/views/settings/{_commercials,index}.php`, `frontend/views/earnings/_earnings.php`,
`frontend/views/agents-wallet/{_form,create,view,_pdf(new)}.php`.
`php -l` clean on all touched/new files. Browser-verified logged in as the dev test shop owner —
`/settings/index`, `/earnings/index`, `/agents-wallet/create`, `/agents-wallet/view?id=27` all
HTTP 200, new elements present, zero PHP error/warning/notice markers; `/agents-wallet/pdf?id=27`
returns a valid 1-page PDF with the per-booking table + totals + net formula rendering correctly
(fee values are 0.00 in this dev DB — no `processing_fee` Charge rows exist yet for this shop's
bookings, pre-dating the W2 wiring — not a bug in this wave).
23 new `Yii::t('frontend', …)` keys (6 UI + ~17 PDF template) — ar/en translations NOT added here
(single-writer rule on `common/messages/*`); orchestrator to add them.
Still uncommitted.

---

## 2026-07-22 (cont.) — NVG-BEA-002 W5: refund reason capture + Navagoo-absorption reversal

See `ai_specs/05_PLANS/NVG_BEA_002_PAYMENT_PROCESSING_FEE_PLAN.md` live tracker for the full
writeup. Short version:
- **Capture** `booking.refund_reason` (+ `cancellations_and_refunds.refund_reason`) automatically:
  `BookingController::actionCancel` (customer/shop selector already in the cancel modal),
  `BookingHelper::customerCancelBooking`/`createCancellationRefundRecord` (the customer-initiated
  path shared with `api/` — only the common helper touched, api/ untouched per the shared-mobile-
  tier rule), and both no-show transition paths (`BookingController::actionTransition`,
  `AgentsBookingsController::actionUpdateStatus` — stamp-only, no refund record exists on that path).
- **Admin dropdown**: `backend/views/cancellations-and-refunds/_form.php`'s `refund_reason` field
  converted from a free-text textarea to a Select2 enum dropdown (5 values). Discovered and fixed
  this whole admin form was pre-existing **completely broken** (fatal `UnknownPropertyException` on
  load — ~9 fields bound to columns that don't exist on the table, stale gii-scaffold vs. real
  schema) — had to fix all of them to get the page to render at all for verification.
- **Absorption**: new additive `FinanceLedgerService::buildProcessingFeeReversal()` (mirrors
  `buildReversal()`) — full negative reversal of a processing-fee Charge, gated to
  `navagoo_goodwill`/`platform_issue` reasons only. Wired into
  `CancellationsAndRefundsController::actionUpdate` via `maybeReverseProcessingFee()` — idempotent
  (checks for an existing `reversal_of_id` pointer first), soft-guarded.

Files: `frontend/controllers/{BookingController,AgentsBookingsController}.php`,
`common/helpers/BookingHelper.php`, `common/components/FinanceLedgerService.php`,
`backend/controllers/CancellationsAndRefundsController.php`,
`backend/views/cancellations-and-refunds/_form.php`.
`php -l` clean on all 6. `codecept run unit` — FinanceLedgerServiceTest 33/33,
BookingCompletionServiceTest 2/2 green; full suite unchanged at 261 tests / 6 errors / 15 failures
(same pre-existing baseline, unrelated to finance/booking/cancellations code). Curl smoke:
`GET /cancellations-and-refunds/update?id=55` now HTTP 200 (was a pre-existing 500) with the new
dropdown rendering all 5 options.
5 new `Yii::t('backend', …)` keys — ar/en translations NOT added here (single-writer rule on
`common/messages/*`); orchestrator to add them: "Customer Cancellation", "Shop Cancellation",
"Navagoo Goodwill (fee absorbed)", "Platform Issue (fee absorbed)", "Select Refund Reason...".
Still uncommitted.

---

## 2026-07-22 (cont.) — NVG-BEA-002 W6: tests + verify → feature COMPLETE (W1–W6, uncommitted)

The Payment Processing Fee (gateway cost pass-through) effort is done. Full detail per wave in
`ai_specs/05_PLANS/NVG_BEA_002_PAYMENT_PROCESSING_FEE_PLAN.md` (live tracker). What landed:

- **W1** — additive schema: `shop.payment_processing_fee_rate/_fixed` + `ppf_pending_rate/_fixed/
  _effective_date`, new `commercial_rate_log`, `booking.refund_reason` +
  `cancellations_and_refunds.refund_reason` (m260722_100000, applied).
- **W2** — fee engine wired into the REAL flows: `FinanceLedgerService::ensureProcessingFee()`
  called from `BookingCompletionService::ensureEarnings()` (the single chokepoint every
  completion path shares); effective-date-aware `processingRatePct/Fixed`; one-fee-per-booking
  idempotency guard in `buildProcessingFee()`.
- **W3** — admin governance: shop-form PPF override card + CommercialConfig hard-cap (5% / 1.50)
  and floor (2.5% / 0.50 w/ sub-floor confirm) validation, mandatory change reason, 7-day
  effective-date notice, `commercial_rate_log` audit rows + owner email, lazy pending→current
  promotion on admin open.
- **W4** — shop portal: Commercials upcoming-rate notice, Earnings PPF column, Settlement Request
  PPF column + total card + aligned net formula, settlement PDF (Mpdf) + download button.
- **W5** — refund_reason capture in all cancel/no-show paths + admin enum dropdown (fixed a
  pre-existing fatally-broken cancellations-and-refunds admin form in passing) +
  `buildProcessingFeeReversal()` absorption (full negative reversal, navagoo_goodwill /
  platform_issue only), wired idempotently into the admin refund update.
- **W6** — new `common/tests/unit/components/PaymentProcessingFeeTest.php`: 13 tests / 66
  assertions, all green, covering AC-1 (200 → SAR 8.00 at 3.5%+1), stamped forward-only +
  idempotency (transaction-rolled-back DB test), per-shop override precedence, pending-rate
  date gating, hard-cap/sub-floor/7-day validation, absorption gating, pay-on-visit zero.
  Full suite: 274 tests / 622 assertions — FinanceLedgerServiceTest 33/33 +
  BookingCompletionServiceTest 2/2 + PaymentProcessingFeeTest 13/13 green; 6 errors + 15
  failures are the unchanged pre-existing baseline (Otp ×6 E; Aurora ×5, CalendarFormat ×4,
  SmsLog ×5, TokenExpiration ×1 F — none finance-related). Curl smoke: all 4 admin surfaces
  (commercial-config, shop/update PPF card, cancellations-and-refunds enum dropdown,
  admin-finance/transfer-requests) HTTP 200, zero exception markers; shop portal surfaces 302
  to login for guest curl (already browser-verified logged-in during W4).

**Open questions / deferred (unchanged):** OQ-4/OQ-5 — Paymob refund-fee behaviour (owner:
Muhannad); BNPL rates (Tabby/Tamara), customer-facing fee display, and automated Paymob
reconciliation are deferred per spec. **Deviation note:** the nominal "stamp at online-payment
success" point lives in `api/controllers/BookingController::actionPay` (off-limits shared mobile
tier) — the fee instead stamps at completion (`ensureEarnings`) or cancel
(`deriveBookingCharges`), both already-reachable paths, so every terminal booking gets its fee.
Translations for the new W3/W4/W5 `Yii::t` keys still pending with the orchestrator
(single-writer rule on `common/messages/*`). Everything W1–W6 uncommitted on `tailwind-poc`.

---

## 2026-07-22 (cont.) — NVG-BEA-005 W2: attribution engine wired into real flows (uncommitted)

Full detail: `ai_specs/05_PLANS/NVG_BEA_005_ATTRIBUTION_FREEZE_PLAN.md` (live tracker, W2 entry).
W1 schema (mobile column, `shop.first_portal_login_at`/`freeze_list_cap`/`freeze_locked_at`,
`customer_freeze` consent cols, `commercial_config.freeze_list_cap`) was already applied. This
wave made classification actually FIRE and the marketing fee actually charge:

- `FinanceLedgerService::resolveClassification()` no longer probes non-existent static methods
  (`CustomerClassificationService::classify()` / `CustomerClassification::forBooking()` — the
  bug that made every real booking silently resolve to `shop_owned`). It now delegates to
  `CustomerClassificationService::classifyOnFirstBooking()`: look up an existing row by
  (shop_id, customer_id) OR (shop_id, normalized mobile), else classify NOW via the 3-part rule.
  Walk-in vs app discriminator = the booking's real `booking_method` column (already handled
  correctly inside `classifyOnFirstBooking()` — it just had zero call sites before).
- Classification now fires at the two real classifying events: `WalkInBookingService::create()`
  (after commit, non-fatal) for the first shop-admin walk-in, and inside
  `FinanceLedgerService::deriveBookingCharges()` for the first app booking (via
  `resolveClassification()` when the caller omits `opts['classification']`).
- Single grace source: new `FinanceLedgerService::computeInGrace(Shop $shop)` /
  `graceWindowDays()`, anchored to `shop.first_portal_login_at` + `CommercialConfig.
  grace_window_days` (CEO default 3d). `deriveBookingCharges()` computes it automatically
  whenever `opts['inGrace']` is omitted; explicit opts (tests, admin re-derive) still win.
  `first_portal_login_at` is stamped once in `SignInController::actionLogin()` right after
  `$model->login()` succeeds. `CustomersController`'s Customers-page grace banner (previously a
  disconnected hardcoded 30-day window off `shop.created_at`) now reads the SAME source.
- Mobile-keyed survival (OQ-21): `CustomerClassificationService::classifyCustomer()` takes an
  optional `$mobile`, stamps it normalized (reusing `CustomerFreeze::normalizeMobile()`) on new
  rows, and a new `findRow()` helper checks customer_id first then mobile — so a customer who
  got soft-deleted and re-registered under a new User id keeps their original attribution.

Files: `common/components/{FinanceLedgerService,CustomerClassificationService}.php`,
`common/services/WalkInBookingService.php`, `frontend/controllers/{SignInController,
CustomersController}.php`. `php -l` clean on all 5. `codecept run unit`: 274 tests / 622
assertions, 6 errors / 15 failures — unchanged from the W6-BEA-002 baseline (Otp/Aurora/
CalendarFormat/SmsLog/TokenExpiration, none classification/finance-related);
FinanceLedgerServiceTest 33/33 + PaymentProcessingFeeTest 13/13 + BookingCompletionServiceTest
2/2 green (none of these three exercise `deriveBookingCharges`/`resolveClassification` directly,
so the explicit-opts behavior they DO exercise is provably unchanged).

**Out of scope / deferred to later BEA-005 waves (unchanged):** freeze-list bulk CSV/consent/cap/
lockout UX (W3); deep-link attribution proxy (`viaDeepLink` stays hardcoded `false` — W4); shop
UI polish + Classifications tab labels (W5); translations for any new-in-W2 strings (none added —
this wave touched no user-facing copy) + full acceptance-criteria test pass (W6). No `api/` files
touched, no migrations, no `common/messages/*` edits. Uncommitted on `tailwind-poc`.

---

## 2026-07-22 (cont.) — Feedback widget: silent comment loss fixed (uncommitted)

User's 18:08 in-app review comment (POST `/feedback/store` from `/navagoo-plans/index`) was LOST:
their session had been logged out server-side moments earlier, the POST got a 302 → login page
(200 HTML), and the old widget code in `aurora-core.js` treated any `r.ok` final response as
success — so it showed "تم حفظ التعليق" while nothing was written to
`frontend/runtime/feedback/inbox.md` (unchanged since 2026-07-14, confirmed via apache access log:
`POST /feedback/store → 302 → GET /sign-in/login`).

Fix (aurora-core.js feedback sender only): success now requires a JSON `{ok:true}` body (a
redirect-to-login is HTML and falls through to the error toast); added
`X-Requested-With: XMLHttpRequest`; on failure the textarea KEEPS its text so the comment
survives a re-login + resend; clearer bilingual failure toast (inline rtl() pattern, consistent
with the rest of aurora-core.js — no Yii::t available in that file). No Tailwind class changes →
no CSS rebuild needed (served via AssetManager `?v=` timestamp).

Browser-verified both paths on shop.navagoo.localhost: dead-session POST resolves saved=false
(error toast), fresh-session widget send lands in inbox.md (test entry removed after
verification). OPEN QUESTION flagged as a follow-up task: shop-portal sessions are being dropped
server-side within minutes in local dev (two independent sessions died < 2 min after activity;
DbSession, no authTimeout configured; unit suite has no Db cleanup module — cause not yet found).

---

## 2026-07-23 — NVG-BEA-005 COMPLETE: Client Attribution Engine & Freeze List (W1–W6, uncommitted)

**The attribution engine is now LIVE end-to-end.** Full plan + per-wave tracker:
`ai_specs/05_PLANS/NVG_BEA_005_ATTRIBUTION_FREEZE_PLAN.md` (status COMPLETE).

**What shipped, per wave (all on `tailwind-poc`, uncommitted):**
- **W1 schema** (m260722_170000, applied): `customer_classification.mobile` (+index),
  `shop.{first_portal_login_at,freeze_list_cap,freeze_locked_at}`,
  `customer_freeze.{consented_at,consented_by,batch_id}`,
  `commercial_config.freeze_list_cap` (default 2000). Additive-only; zero api/ impact.
- **W2 engine wiring** (the critical fix — engine was DEAD before):
  `FinanceLedgerService::resolveClassification()` now delegates to
  `CustomerClassificationService::classifyOnFirstBooking()` (was probing non-existent
  static methods → silently shop_owned for every booking → marketing fee never
  charged). Classification fires from `WalkInBookingService::create()` (walk-in ⇒
  shop_owned) and from `deriveBookingCharges()` (app booking ⇒ 3-part rule with
  freeze-list check). Single grace source: `computeInGrace()` anchored to
  `shop.first_portal_login_at` (stamped once at login) × `CommercialConfig.
  grace_window_days` (default 3). Mobile-keyed row survival for re-registered
  customers (OQ-21).
- **W3 freeze-list UX**: bulk CSV/paste upload with header/junk tolerance +
  in-batch dedupe (`CustomerFreeze::parseBulkMobiles()`), cap enforcement
  (`effectiveCap()`: shop → config → 2000), mandatory consent checkbox logged to
  `consented_at/by` + shared `batch_id`, 3-day window with countdown banner →
  permanent locked state (`shop.freeze_locked_at`, self-healing lazy stamp),
  daily reminder email (`console/yii freeze-reminder/send`, scheduled).
- **W4 attribution extras**: deep-link proxy (delivered invitation to that mobile
  from THIS shop ⇒ shop_owned/deep_link on first app booking) + admin-only
  post-window freeze add (backend People→Classifications modal).
- **W5 shop UI polish**: Customer Invitations "Invited Customers" tab gained
  Classification / Reason / Classified On columns (batch-loaded, no N+1) + link to
  /customers#classifications; no fee amounts exposed to the shop.
- **W6 translations + tests + verify** (this entry): only **2 residual i18n keys**
  were genuinely missing after BEA-003's shared sweep ('Invalid communication
  method.', '{days} day(s) left to upload your existing-customer list') — added to
  en+ar; parity verified (frontend 1900/1900, backend 3320/3320, common 232/232).
  **31 new unit tests, all green**: `CustomerClassificationServiceTest` (NEW, 8 —
  resolve() priority incl. freeze-list/app-first/walk-in, persist-once immutability,
  mobile-keyed survival), `FinanceLedgerServiceTest` (+10 → 43 — computeInGrace
  anchor/boundary/elapsed, marketing-fee grace waiver + min-fee floor + status),
  `CustomerFreezeTest` (NEW, 13 — parseBulkMobiles edge cases incl. the string-key
  regression guard, effectiveCap resolution). Full suite 335/756: 6 errors /
  15 failures = the exact known baseline (Otp/Sms/Token/Aurora/CalendarFormat).
  Smoke: backend classifications tab 200 + freeze modal; shop /customers 200 with
  live grace banner + freeze countdown; /customer-invitations 200 (renders the
  pre-existing subscription-entitlement gate on the dev shop — not a BEA-005 issue);
  freeze-reminder/send clean (sent=1). No production code changed in W6.

**Deferred (recorded in the plan's "Product flags", need product/mobile coordination):**
- Phone-only specialist walk-in stub (no User row) — SPECIALIST_APP api contract change.
- TRUE deep-link *registration* attribution — needs api signup-time token capture
  (W4's invitation-delivery proxy stands in until then).
- Fee-basis deviation: BRD says 5% × Net Collected (Excl. VAT); live code uses booking
  value excl. VAT (demo-parity). NOT changed — needs product confirmation.
- 15-day dispute window not implemented — disputes are handled offline per the BRD.

## 2026-07-23 (cont.) — Dev session-drop root cause: single-login enforcement (uncommitted)

**Symptom:** logged-in shop-portal sessions died server-side within minutes in local
dev (authed page → next POST 302 → /sign-in/login as guest; evidence 2026-07-22
15:07–15:18 UTC in apache access.log).

**Root cause (confirmed, not H4):** `common/behaviors/GlobalAccessBehavior::beforeAction`
enforces ONE active session per account: every login (`LoginTimestampBehavior::afterLogin`)
rotates `keyStorage["user.single_login.{appId}.{userId}"]`, and any other session whose
`single_login_token_*` no longer matches is logged out + destroyed on its next request.
In local dev the real user, Claude browser panes, parallel agents and
`tests/smoke/render-smoke.sh` (which logs in on EVERY run) all share the
Beautycenter123 test account → every login kicked everyone else. Ruled out: H4
device-token (UA/IP stable, same generateDeviceToken via inheritance), DbSession
GC/expiry (rows carried normal `time()+1440s` expire), cron/tests deleting rows.

**Fix (dev-safe, prod behaviour unchanged):**
- `GlobalAccessBehavior::singleLoginEnforced()` — always true in prod; in
  `YII_ENV_DEV` it's OFF unless `SINGLE_LOGIN_ENFORCE=1` (filter_var boolean
  parsing; env() returns raw strings). When off, a mismatched session ADOPTS the
  new authoritative keyStorage token instead of being destroyed → concurrent
  logins to one account coexist in dev.
- `.env.dist`: documented `SINGLE_LOGIN_ENFORCE` (default 0 commented for dev).

**Verified via 2-cookie-jar curl test (scratchpad two-session-test.sh):**
flag off → session A stays AUTHED after B logs in (authed page 200, stable);
flag on → A becomes GUEST after B's login (production kill path intact). php -l clean.

**Related, previously fixed:** feedback widget comment loss (aurora-core.js now
requires JSON {ok:true} and preserves the textarea on failure) — see 2026-07-22 entry.

## 2026-07-23 (cont.) — Navagoo Plans: bank-transfer slip uploader never showed (uncommitted)

**Bug:** in the Subscribe/Activate confirm modal, picking Bank transfer showed the
note ("Upload your transfer confirmation…") but the upload dropzone never appeared
(demo shows it; server also REQUIRES the slip for non-trial subscribe → flow dead-ended
with Confirm permanently disabled).

**Cause:** the classic project gotcha — `#ng-cm-slip-zone` used Tailwind `class="hidden"`
while the toggler `show()` only flips inline `style.display`; the class always won, so
the zone could never become visible.

**Fix (frontend/views/navagoo-plans/index.php):**
- `#ng-cm-slip-zone`: `class="hidden"` → `style="display:none"` (matches every other
  toggled node in this modal; no Tailwind class added → no CSS rebuild needed).
- `openConfirm()` now also resets the slip state on every open: hides the zone,
  clears `#ng-cm-slip-input`, restores the label to TXT.uploadSlip (before, a
  previously chosen file/zone leaked into the next open).

**Verified in-browser (Growth, cancelled sub → non-trial re-subscribe, pay-now 232.88):**
pick Bank transfer → note + "Upload transfer confirmation" dropzone render, Confirm
stays disabled until a file is attached; close/reopen → picker fully reset. Slip-required
condition matches demo Subscription.tsx (`subscribe && bank && !hasTrial`). php -l clean.

## 2026-07-23 (cont.) — NVG-BEA-011 COMPLETE: Group Bookings (W0–W5, uncommitted)

**Plan:** `ai_specs/05_PLANS/NVG_BEA_011_GROUP_BOOKINGS_PLAN.md` (status COMPLETE; the
tracker carries per-wave file/verify detail). All work uncommitted on `tailwind-poc`.

**What landed, by wave:**
- **W1 config** — `commercial_config.max_group_size` (INT NOT NULL DEFAULT 10) +
  `shop.max_group_size` (INT NULL override), migration
  `m260722_180000_add_group_booking_config_schema`, admin fields on Commercial Config +
  Shop form, `GroupBookingService::maxGroupSize()` resolution (shop ?? global ?? 10).
- **W2 engine hardening** — cap enforced in `createGroupBooking` (+UI hint/disable);
  date-encoded party ids `GRP-YYMM-XXXXX` (old hex ids stay valid); per-participant
  cancel `cancelParticipant()` (terminal/non-group/last-active-sibling guards) + Parties-
  drawer action; TOCTOU fix — `SELECT … FOR UPDATE` on the specialists' day inside the
  create transaction; guest-label fallback → organiser (lead booker) name.
- **W3 customer-app API (ADDITIVE — mobile-team flag)** — NEW
  `api/controllers/GroupBookingController.php` + route block in `_urlManager.php`:
  POST `/group-booking/create` (per-participant 422s w/ `participant_index`, conflict
  409s naming the guest), GET `/group-booking/view/<id>`, POST `/group-booking/
  cancel-group` + `/cancel-participant` (lead-scoped), POST `/group-booking/pay` (ONE
  Paymob transaction for the whole group, actionPay-style FOR-UPDATE idempotency, whole
  group fails together). Plus additive `GroupBookingService::cancelAsCustomer()`
  (customer policy: zone refunds, STATUS_CANCELED, cancelled_by='customer') — the shop
  paths are untouched. Contracts documented in `ai_specs/03_API/API_CONTRACTS.md` §3.2.
  **Mobile team must build the app's Group Booking UI against these NEW endpoints —
  zero existing endpoints changed.**
- **W4 portal polish** — guest labels on frontend group-child rows, backend group pill
  now links to the booking, month view group dot/title.
- **W5 (this entry)** — i18n: 11 `frontend` + 28 `backend` keys added to BOTH en+ar
  (api strings use the `backend` category; parity verified 1911/1911 + 3348/3348, 0
  one-sided). Tests: NEW `common/tests/unit/components/GroupBookingServiceTest.php`
  (15 tests / 72 assertions, all green — cap resolution+enforcement, id format,
  resolveLabel chain, cancelParticipant guards, real 3-child creation + conflict-names-
  the-guest, customer-vs-shop cancel semantics; DB tests fully tx-rolled-back). Full
  unit suite 350 tests: 6 errors/15 failures = EXACTLY the pre-existing documented
  baseline (0 regressions). Smoke: backend booking/commercial-config/shop-form 200
  clean; frontend booking-calendar day/list/month 200 w/ group UI; api 5×401 unauth
  envelopes; `group-booking-check/run` PASS.

**Deferred / out of scope (per BRD + product flags):** per-participant identity &
classification (participants share the lead's account/phone — needs optional per-guest
phone capture, an api+app change), split payment, group pricing/discounts, multi-day
groups.

## 2026-07-23 (cont.) — Admin invoices: transfer-slip visibility + ngConfirm verify (uncommitted)

**Ask:** review how the (bank-transfer subscription) invoice appears in the DEMO admin
and match it; use the aurora dialog (not native confirm) for Verify.

**Demo reviewed live** (Navagoo_MI on :6100, admin ceo@navagoo.sa → #/admin/finance/invoices):
columns Invoice/Shop/Period/Charges/FeeVAT/Total/Status/Document/Due + row actions
View · Upload doc (when awaiting) · Verify (when pending_verification). The demo mock
has NO slip artifact — verification is one click. Our real flow stores the shop's
uploaded confirmation in `invoice.payment_slip_path` (fileStorage, @storageUrl/source).

**Changes (backend/views/admin-invoice/index.php):**
- New per-row **"Transfer slip"** action link (receipt icon, opens in new tab) whenever
  `payment_slip_path` is set — the admin can now SEE the shop's bank-transfer
  confirmation before verifying (previously it was never displayed anywhere).
- Document column's "Attached" becomes a link to the stored `invoice_doc` when present.
- Verify confirmation now uses **ngConfirm** (title "Verify payment", Verify/Cancel)
  with graceful fallback to native confirm if aurora-core is absent — replaces the raw
  `confirm()` that violated the aurora dialog rule.
- i18n: added 'Transfer slip' + 'Verify payment' to ar+en backend.php (hook-verified).

**Verified:** php -l clean; slip file for the live pending invoice (#3, shop 15) serves
200 image/png at http://storage.navagoo.localhost/source/1/TKBSEgO3…png so the link
resolves. ngConfirm call shape mirrors the working backend/views/shop/subscriptions.php.
**Visual QA DONE** (user supplied the current admin password — the documented
NavAdmin!2026 is stale): page renders demo-parity (tabs + count chips + table);
invoice #3 row shows Pending-verification badge + the new "Transfer slip" button
(href resolves to the stored PNG); Verify opens the aurora ngConfirm dialog
("Verify payment" / "Mark invoice #3 paid?…" / Cancel·Verify); Escape/Cancel closes
with no state change. Deliberately did NOT confirm — verifying would mark #3 paid and
reactivate the shop's cancelled subscription; left to the user.

## 2026-07-23 (cont.) — NVG-BEA-006 Detailed Charges Report — COMPLETE (W1+W2+W3)

**Plan:** `ai_specs/05_PLANS/NVG_BEA_006_DETAILED_CHARGES_PLAN.md` (now Status: COMPLETE).
Read-only report polish on the immutable `charge` ledger, both portals: admin
(`/admin-charge/index`) and shop (Earnings hub "Detailed Charges" tab). No schema
changes, no `api/` changes, no mobile-contract impact.

**W1 (admin, backend/controllers/AdminChargeController.php +
backend/views/admin-charge/{index,_pdf}.php)** and **W2 (shop,
frontend/controllers/EarningsController.php + frontend/views/earnings/{_charges,
_charges_pdf}.php)** landed earlier the same day (ran in parallel — disjoint files):
date-range + Booking ID filters (query + CSV + PDF, via `ChargeQuery::forBooking`),
Booking Amount (SAR) column (one batched booking lookup, no N+1), derived Charge
Description column/field (shared `chargeDescription()` helper per controller — same
wording logic reused by the interactive table, CSV, and the new Mpdf PDF export), a
filtered-set totals row/footer (one `GROUP BY type` aggregate query — never loop-
summed), Transfer Request ID column, red styling for negative/reversal amounts, and
an Export PDF button next to the existing CSV/print. W2 also fixed a real bug found
along the way: `frontend/views/earnings/index.php` was whitelisting which vars forward
to `_charges.php` and silently dropped every new filter var (date/booking-id filters
reset on every request) — 6 keys added to the whitelist. W2 added its `Yii::t('frontend',
...)` keys bilingually as it went; W1 left its `Yii::t('backend', ...)` keys flagged
for a follow-up i18n pass (both controllers were built by the same messages-writer
agent slot, coordinated with the concurrent BEA-011 W5 agent so nobody double-wrote
message files).

**W3 (this entry) — translations + tests + verify, done 2026-07-23:**
- **i18n:** grepped every `Yii::t('backend', ...)` key across the 3 W1 files against
  `common/messages/en/backend.php` — 11 were actually missing (not the ~17 the W1
  tracker note estimated; 6 flagged strings — `From`/`To`/`Booking ID`/`Apply`/
  `Transfer Request`/`Detailed Charges` — already existed pre-feature as generic
  backend strings reused as-is). Added all 11 to both `en/backend.php` and
  `ar/backend.php` with real Arabic (reusing W2's identical frontend translations
  where the English source text is byte-identical, so the two portals read
  consistently): `Booking Amount (SAR)`, `Clear dates/booking`, `Date range`,
  `Export PDF`, `Generated {date}`, `Navagoo Marketing Fee — {rate}% of booking
  value`, `Navagoo Subscription Fee`, `Overall total`, `Payment Processing Fee —
  {rate}% + {fixed} of collected`, `e.g. 1024`, `{count} billed message(s)`. Parity
  verified programmatically: en/ar both 3361 keys, 0 one-sided drift. `php -l` clean
  on both message files, the controller, and both views.
- **Tests (new):** `common/tests/unit/controllers/{AdminChargeControllerTest,
  EarningsControllerTest}.php` — 38 tests / 57 assertions, all green. Both
  controllers' `chargeDescription()` and `parseFilters()`/`parseChargeFilters()` are
  private; reached via reflection on an instance built with
  `newInstanceWithoutConstructor()` (skips `BackendController::init()`'s web-request/
  cookie dependency, which the console-app unit-test bootstrap doesn't provide and
  neither helper under test touches). `parseFilters()`/`parseChargeFilters()` read
  `Yii::$app->request->get()`, which the bootstrapped console app can't serve
  (`yii\console\Request` has no `get()`) — the `request` component is swapped for a
  real `yii\web\Request` with `queryParams` preset for the duration of each call, then
  restored in `finally`. Coverage: `chargeDescription()` per charge type (marketing/
  processing/subscription/sms/whatsapp incl. meta-count pluralization + floor-to-1/
  net-from-settlement/reversal/unknown-type) on both controllers, including the
  frontend's "Net from Settlement" vs backend's "Net from settlement" wording
  divergence pinned as a regression guard; date-range validation (rejects garbage,
  accepts Y-m-d, one-sided garbage doesn't clobber the valid side) + status/type/
  booking-id parsing differences between the two controllers (admin defaults to
  `'all'` and does a plain int cast on `booking_id`; shop defaults to `''` and strips
  non-digits from `filterBookingId`); `buildQuery()`/`filteredChargesQuery()`
  day-boundary conversion (inclusive 00:00:00/23:59:59 timestamps), verified by
  inspecting the built `ActiveQuery::$where` array directly — no DB execution needed.
  Full suite: **388 tests / 885 assertions, 6 errors / 15 failures** — identical to
  the pre-existing documented baseline (350 tests before this wave + 38 new; diffed
  the failing-test list — all 15 failures/6 errors are pre-existing unrelated flakes
  in Aurora/CalendarFormat/SmsLog/TokenExpiration tests, zero regressions).
- **Smoke** (backend session cookie, `Host: backend.navagoo.localhost`):
  `/admin-charge/index` → 200, no error markers (renders Arabic by default; the CSV
  pull in the same pass showed the new "رسوم اشتراك نافاجو" string rendering correctly
  end-to-end, confirming the i18n wiring); `/admin-charge/export-csv` → 200
  (`text/csv`, Description column populated); `/admin-charge/export-pdf` → 200 (valid
  1-page PDF). Frontend guest `/earnings/index` → 302 (auth-gated, as expected).
- **No production code changed in W3** — purely additive i18n + new test files;
  read-only report surfaces were already correct from W1/W2.

**Open questions / deferred (unchanged from the plan's "Out of scope"):** near-term
charge types beyond the current allowlist (marketing/processing/subscription/sms/
whatsapp/net-from-settlement/reversal) — owner: Muhannad. Also out of scope per the
BRD: real-time charge notifications, customer-facing breakdown, shop-configurable
charge types.

## 2026-07-26 — NVG-BEA-010 COMPLETE: Specialist Wages & Commission (W0–W4, uncommitted)

Plan: `ai_specs/05_PLANS/NVG_BEA_010_WAGES_COMMISSION_PLAN.md` (status COMPLETE, full
per-wave tracker there). Waves: W0 audit → W1 payroll month summary (frontend) ∥ W2
commission-basis alignment (common) ∥ W3 specialist wallet endpoint (api, ADDITIVE,
flagged per the shared-mobile-API rule) → W4 bug fix + translations + tests + smoke.

- **★ W4 headline — commission-zeroing bug FIXED (was task chip `task_575c9f88`):**
  `WagePayrollService` baked INT ids into the specialist/booking arrays and passed int
  shopId into `WageEngineService::commissionInWindow()`/`buildCapture()`, whose docblock
  contract declares STRING ids and whose filters compare with strict `!==`. PHP coerces
  the int shopId ARGUMENT at the `string $shopId` param boundary, but int VALUES inside
  arrays stay ints ⇒ `int !== string` always mismatched ⇒ **every commission through the
  real reconcile/live-due path silently computed 0** (fixed salary unaffected; W1's
  month summary was unaffected because it built string ids locally). Fix at both
  levels: (a) string-normalized ids at the `WagePayrollService`
  boundary (`specialistArray()`/`bookingArray()` + `(string)` shopId at the
  `buildCapture`/`totalDueFor` call sites) per the engine contract; (b) type-tolerant
  `(string)` casts on BOTH sides of the engine's id comparisons — which also repairs
  the api `WalletController::actionWages` (bakes int ids too) with zero api/ edits.
  Regression tests: `WageEngineServiceTest::testCommissionIsComputedWithIntegerIdInputs`
  + `WagePayrollServiceTest::testReconcileComputesCommissionThroughRealIdPlumbing`.
  CLI proof (dev DB): seeded SAR 230 booking → `php console/yii wages/reconcile` stamped
  1 capture (base 200.00, pct 10, commission **20.00** — the BRD AC, booking id counted);
  second run 0 created (idempotent); seeded rows + profile fully restored.
- **Basis alignment end-state:** wage captures (W2) AND the shop Monthly summary (fixed
  in W4 after the new test exposed the 23-vs-20 mismatch) both compute commission on the
  pre-VAT, post-discount base — line-item sum of
  `booking_service.price_excl_vat_after_discount` preferred, `stripVat(total_amount)`
  fallback (CommercialConfig.vat_pct → legacy Settings.taxes; only when shop
  `is_taxable`). Existing `wage_capture` rows immutable (capture_key gate) — no
  historical migration.
- **API (additive, mobile-team flag):** `GET /agent/wallet/wages` (W3) returns
  {wage_type, fixed_salary, service_commission_earned, tips_earned, total_earnings};
  docs in `ai_specs/03_API/API_CONTRACTS.md` §3.3; unauth → 401 verified. **Known
  deferred:** it still feeds VAT-inclusive `total_amount` as bookingValue (api/ was
  frozen this wave) — commission there is basis-misaligned with captures until a
  follow-up api wave applies the same pre-VAT resolution; mobile team not yet consuming.
- **Translations:** the 12 missing backend-category keys (7 W1 month-summary + 5 W3
  wallet) added to `common/messages/{en,ar}/backend.php` with real Arabic and verbatim
  placeholders (`{cycle}`); parity 0-missing across backend/frontend/common × en/ar
  (checker script + the repo i18n hook both clean).
- **Tests:** 5 new — engine int-id regression, full-path reconcile regression, and
  `common/tests/unit/controllers/AgentsControllerMonthSummaryTest.php` (3 tests via the
  AdminChargeControllerTest reflection pattern: month-window in/out counting + BRD
  columns + pre-VAT basis; non-monthly fixed excluded-and-footnoted; invalid month
  fallback). Full suite: **401 tests / 931 assertions, 6 errors / 15 failures** —
  identical to the documented pre-existing baseline (396 + 5 new; failures all
  Aurora/CalendarFormat/SmsLog/TokenExpiration, none wage-related).
- **Smoke:** shop-owner login → `/agents/index?tab=payroll` 200 (Monthly summary +
  Fixed Salary/Commission Earned/Tips Earned/Total Earnings, EN + AR strings render);
  backend `/user/people?tab=specialists` 200 (نوع الأجر column, ثابت/عمولة badges off
  `user_profile.wage_type`); api unauth `/agent/wallet/wages` → 401.
- **Two-tip-subsystems note (unchanged, documented):** M3 `booking.specialist_tip`
  (+`specialist_tip_settled`) is authoritative for ALL payroll/wallet tip figures; the
  legacy AgentWithdrawal/Transaction tipping remains only as transfer-request history.
  Payroll stays OFF-RAIL (wage_* tables only; shopBalanceView byte-identical — pinned
  by `testPayrollRunIsOffRail`).
- **Deferred:** none beyond the BRD's out-of-scope list (payment processing,
  per-service rates, export/accounting integrations) + the api basis alignment above.

## NVG-BEA-008 W3 — Subscription Package purchase + fees (api, additive) — 2026-07-26

Full detail in `ai_specs/05_PLANS/NVG_BEA_008_SERVICE_BUNDLES_SUBSCRIPTION_PACKAGES_PLAN.md`
(W3 tracker line). Summary: new `common/components/PackagePurchaseService.php`
(`createEntitlementFromPaidPurchase`, transactional + tran_ref-idempotent); additive
`FinanceLedgerService::buildPackagePurchaseCharges()` appended at end of class
(booking_id=NULL, meta.entitlement_id-tagged, mirrors the existing TYPE_SUBSCRIPTION
convention); new `api/controllers/SubscriptionPackageController.php`
(`POST /subscription-package/purchase`, `GET /my-packages`) + routes in
`_CustomerUrls.php`; one additive `subscription_packages` key on
`ShopsController::actionView`. `git diff --stat api/` = only `_CustomerUrls.php` +
`ShopsController.php` (+ new untracked controller). Verified: php -l clean, phpcs
0 errors, functional smoke (rolled-back transaction) all assertions passed, unauth
curl 401/401, `GET /shops/1` 200 with the new key present. `FinanceLedgerServiceTest`
43/43 + `PromoCodeServiceTest` 37/37 green, 0 regressions. No frontend/backend edits
(other agent owned W2 concurrently). API_CONTRACTS.md §3.5 added.

## 2026-07-26 — NVG-BEA-008 COMPLETE: Service Bundles (rename) & Subscription Packages (W1–W6)

Plan: `ai_specs/05_PLANS/NVG_BEA_008_SERVICE_BUNDLES_SUBSCRIPTION_PACKAGES_PLAN.md`
(status COMPLETE, full per-wave tracker there — this is the executive summary). The
largest BRD of the program: a net-new customer-facing pre-paid session-block purchase
+ redemption feature, plus a cosmetic rename of the OLD, unrelated bundle-booked-once
`Package` system to "Service Bundle" (Part A of the BRD — do not conflate the two
systems; see the plan's "Two systems" section).

**Waves:**
- **W1 (schema, additive):** extended `subscription_package` (`image`, `name_ar`,
  `active_days` JSON, `start_date`/`end_date`) + new junctions
  `subscription_package_service`/`subscription_package_specialist` + new
  `package_entitlement` table (the customer's purchase/session-balance record:
  sessions_total/remaining, expiry_date, price snapshot incl. `per_session_price`,
  status). Models + relations + instance mutators (`decrementSession()`/
  `reinstateSession()`). Schema + models only, no i18n/frontend/api edits (that
  gap is exactly what W6 closed).
- **W2 (shop portal):** Part A rename (Package → "Service Bundle" across
  `ShopNav.php`/`Menu.php`/`views/package/*`, the OLD unrelated bundle system —
  untouched otherwise). Part B: the 6 missing subscription-package form fields
  (image, name_ar, included services, eligible specialists, active days, date
  range) added to the create/edit form with shop-scoped IDOR whitelisting.
- **W3 (purchase + fees, api additive):** `PackagePurchaseService` (entitlement
  minting, tran_ref-idempotent) + `FinanceLedgerService::
  buildPackagePurchaseCharges()` (processing fee always + marketing fee on the FULL
  price when navagoo_sourced) + new `api/controllers/SubscriptionPackageController`
  (`POST /subscription-package/purchase`, `GET /my-packages`) + one additive
  `subscription_packages` key on `ShopsController::actionView`.
- **W4 (redemption + cancel-reinstate, api additive):** new `booking.
  package_entitlement_id` column; `GET /subscription-package/redeemable` + `POST
  /subscription-package/redeem` (skip-Paymob booking creation, session decrement,
  redemption marketing-fee-only charge, all one transaction); customer
  cancel-in-window reinstates the session via ONE additive branch in
  `BookingController::actionUpdateStatus()`.
- **W5 (expiry lifecycle, console):** `console/controllers/PackageExpiryController`
  — `forfeit` (past-expiry active → expired, no refund/proration) + `notify` (T-7d/
  T-1d in-app pings) + `run` (daily cron entrypoint, wired into
  `console/config/schedule.php`). Admin visibility deferred (see below).
- **W6 (translations + tests + smoke + docs):** full i18n sweep + acceptance-criteria
  test suite + smoke matrix + this write-up. Detail below.

**Two schema decisions (load-bearing, worth restating here):**
1. `SubscriptionPackage.name_ar` is a **plain bilingual text column**, not the
   `webvimark\modules\MultiLanguage` behavior every other translatable model in this
   codebase uses — a deliberate W1 choice (see plan) to avoid pulling in that
   behavior's PHP 8.2 `E_DEPRECATED` dynamic-property trip for a single new field.
2. `booking.package_entitlement_id` is a **brand-new column**, deliberately NOT
   reusing the existing `booking.package_id` — that column belongs to the OLD,
   unrelated `Package`/`UserPackage` bundle-booked-once system and is read in
   several api/common call sites as an id into the `package` table; stamping an
   entitlement id into it would have silently corrupted those reads.

**Open question flagged → Muhannad (documented decision, not yet ratified):** the BRD
main text is ambiguous on the marketing-fee basis for a multi-service package
redemption. This build uses **price ÷ sessions_total** (`per_session_price`,
snapshotted at purchase time) as the redemption-time marketing-fee basis — implemented
in `FinanceLedgerService::buildPackageRedemptionCharges()` and covered by
`SubscriptionPackageTest::testRedeemDecrementsSessionAndStampsOnlyAMarketingFeeOnThePerSessionBasis`.
Revisit if Muhannad rules otherwise — it's one line of basis math to change, but the
snapshot happens at purchase time (`PackagePurchaseService::
createEntitlementFromPaidPurchase()`) so a rule change would only apply forward.

**api-additive / shared-mobile-contract flag:** per the project's cross-tier rule
(api/ is shared with the mobile app), every api/ change this program made was
ADDITIVE ONLY — new routes/new controller, zero existing action/response shape
touched:
- `POST /subscription-package/purchase` (W3)
- `GET /my-packages` (W3)
- `GET /subscription-package/redeemable` (W4)
- `POST /subscription-package/redeem` (W4)
- `ShopsController::actionView` gained ONE additive `subscription_packages` key
  (active + in-window packages + included services) — every pre-existing key in
  that response is byte-identical (W3).
- `BookingResource`'s `payment_message` field gained ONE new branch for
  `PAYMENT_MODE_PACKAGE` bookings ("Paid using a package session.") — every other
  branch byte-identical (W4).
- `BookingController::actionUpdateStatus()`'s existing customer-cancel branch gained
  ONE additive best-effort call (`reinstatePackageRedemption()`) that never blocks
  or alters the cancel's own response on failure (W4).

Mobile team is not yet consuming any of the new endpoints; documented in
`ai_specs/03_API/API_CONTRACTS.md` §3.5/§3.5.1.

**Deferred (documented, not a gap in this build's scope):** admin visibility into
`package_entitlement` was explicitly scoped as "optional, low-risk" in the BRD
("no direct management") and was SKIPPED in W5 rather than force-fit into the
existing `ShopSubscription` (shop→Navagoo SaaS billing — a different concept) tab
family. Natural home is a future small wave that stamps entitlement info onto the
booking/charge admin records the redemption already produces.

**W6 (this wave) in detail:**
- **Translations:** full-repo grep of every `Yii::t()` call across all BEA-008 files
  (PHP tokenizer, not regex — correctly handles both `Yii::t(` and `\Yii::t(`), cross-
  checked against `common/messages/en/{backend,frontend,common,shop}.php`. 23 keys
  were genuinely missing — 18 `backend` (8 `PackageEntitlement` Gii attribute labels
  W1 never i18n'd on principle + 10 `SubscriptionPackageController` error strings
  from W3/W4) and 5 `frontend` (`BookingResource`'s package payment-message branch +
  4 `PackageExpiryController` notification strings from W5). Real Arabic added to
  both `ar/` files; `i18n-check` hook enforced pairing; final parity backend
  3413/3413, frontend 1952/1952 (en/ar), `php -l` clean on all 4 files.
- **Tests:** new `common/tests/unit/components/SubscriptionPackageTest.php` — 9
  tests / 81 assertions, DB-backed (real dev-DB shop/service/customer/agent
  fixtures, outer-transaction-rolled-back), covering all 5 BRD acceptance criteria
  through the real production code: AC1 purchase (sessions/expiry + tran_ref replay
  idempotency), purchase-charge fee math (processing always, marketing on full price
  gated navagoo_sourced, idempotent), AC2/AC3 redeem (session decrement, marketing-
  fee-only charge on the per-session basis, no processing fee ever, exhaust →
  EXHAUSTED status), AC4 expiry forfeit (calls `PackageExpiryController::
  actionForfeit()` directly — it's public, no reflection needed — past-expiry
  active → expired with sessions untouched; future-expiry entitlement left alone),
  AC5 cancel-in-window reinstate (`reinstateSession()` + `reversePackageRedemptionCharge()`,
  mirrors `BookingController::reinstatePackageRedemption()`'s exact orchestration
  without touching api/ code — sibling booking's charge confirmed untouched,
  re-reversal confirmed a no-op). Full suite: 447 tests / 1125 assertions, 6 errors /
  15 failures — identical pre-existing baseline (OTP/Aurora/CalendarFormat/SmsLog/
  TokenExpiration, unrelated to BEA-008), 0 regressions.
- **Smoke:** console `package-expiry/run --dry=1` clean; api unauth 401 on all 4 new
  customer-app endpoints (`/my-packages`, `/subscription-package/{redeemable,redeem,
  purchase}`), `GET /shops/1` 200 with `subscription_packages`+`active_deals` keys
  present; shop-owner frontend (curl + cookie-jar login) `/package` and `/package/
  subscription-create` both 200, renamed strings + all 6 new form fields present, no
  PHP errors/warnings in either response.
- **No production code changes** — the full W1–W5 implementation was exercised
  as-built and no bug was found requiring a fix.

**BRD out-of-scope (explicit, unchanged):** gifting/transfer of a package · auto-
renew on expiry · cross-shop redemption · automated partial refund (admin-manual
only, per BRD).

---

## 2026-07-28 (late) — Admin "الشكل مش هو هو" ROOT-CAUSE pass (look & feel, not content)

User rejected the rebuilt admin AGAIN on look ("الخطوط والبادينج والمارجن والباكجراوند
والهوفر"). Measured demo↔portal side-by-side in Chrome (computed styles, not eyeball)
and found the deltas were SYSTEMIC, not per-screen:

1. **Arabic font was the wrong family entirely** — portal ships Noto Kufi Arabic; the
   demo (M9) renders Arabic via **Alexandria variable** falling through from Google Sans
   Text in ONE stack (Latin stays GST even in RTL). Swapped: Alexandria-var.ttf copied,
   @font-face + single demo stack in layouts/tailwind.php + sign-in/login.php +
   tailwind.admin.config.js (2c0e467). Frontend copy staged, NOT wired (shop portal
   still Kufi — follow-up).
2. **Base font-size** — demo body = 14px/1.5; portal inherited the browser 16px. Pinned
   in the layout (742df63).
3. **Content gutters** — demo wrapper `max-w-[1600px] px-5 lg:px-8 pt-6 pb-10`; portal
   `.ng-page` was `max-w-[1400px] px-8 py-7` → centered with ~35px float on BOTH sides
   (the exact "مسافات يمين وشمال" complaint). One layout edit fixes every page (742df63).
   +17 h1s normalized to `text-2xl font-extrabold tracking-tight text-ink` (view pages
   were text-xl / slate-900).
4. **Numbers/dates were Western digits under ar** — the container's ICU 76 renders the
   bare `ar` locale with LATIN digits (host ICU differs!). Pinned
   `Yii::$app->formatter->locale = ar-SA|en-US` in BackendController::init (demo BCP47
   map) + MoneyHelper extended into a format.ts twin (moneyKpi compact ألف/مليون,
   ⃁ placement AR-suffix/EN-prefix, pct/growthLabel/localeDigits, dateShort/dateDay/
   time12) + dashboard/home/ledger wired + Chart.js `locale` + font family (axis days
   now "١ يوليو", ⃁ in canvas tooltips) (fbe2c65).
5. **ar 'Update' was 'حدث'** on every Update button + matrix verbs → 'تحديث' (55ee6ef).
   ("كُحّث" sighting was Kufi rendering of the correct 'حُدّث'.)
6. **People customers tab rendered ~200 blank rows** (latest-200 by id = no-name test
   signups) → filtered + mobile fallback (a262b40).
7. **Users & Roles content parity** (b6112b7): ONE table = platform staff + shop
   logins; per-row role Select (new assign-role endpoint, self/shop-guarded); Add-user
   became an on-page MODAL (demo AddUserModal skeleton + our real credential fields);
   matrix rows = demo enabled style (locks/dimming removed), custom roles toggle LIVE
   (toggle-permission endpoint, RBAC verified round-trip), built-ins revert+toast;
   custom-role delete; rail drops العميل + lock only on built-ins.
8. **Shop-managers screens** committed in demo grammar (2d1d5be).

Verified: admin smoke 10/10 with SMOKE_USER=qa_admin; side-by-side Home/Dashboard/
Users&Roles screenshots near-pixel; RBAC toggle round-trip checked in rbac_auth_item_child.

**Open follow-ups:** shop portal (frontend) still on Kufi + un-pinned formatter locale —
same 4-line treatment when asked; full 'SAR ' sweep across remaining ~15 admin views
(finance detail/PDF views still prefix SAR); bilingual custom-role labels (single label
col today); demo rename-role modal not ported (delete is).

**Same night — SHOP portal got the identical treatment (473b74c):** Alexandria variable
in the ONE demo stack (tailwind.src.css @font-face + body 14px/1.5 · navagoo-tokens.css
--ng-font-en/--ng-font-ar both = demo stack, Kufi faces left declared but unreferenced ·
tailwind.config.js sans/ar) + formatter pinned ar-SA/en-US in FrontEndController::init
(SignIn/Setup inherit it) + Chart NG_LOC on shop-analytics (3× toLocaleString) & home
tooltip + earnings/_charges last raw 'SAR ' → MoneyHelper::moneyPlain (agents-wallet ×5
already delegate to Aurora::money→MoneyHelper — their "SAR" mentions are stale comments).
The RTL-gated font rules in navagoo-settings/booking css read var(--ng-font-ar) → flipped
automatically. login-page's verified 1rem reset intentionally untouched. Verified in-browser
as the dev owner (Alexandria loaded, home/earnings/promo all Arabic-Indic + ⃁) and smoke
30/30. Leftovers (cosmetic): topbar JS clock digits + a couple of literal latin digits
inside translated strings (e.g. "٢ من 2").

---

## 2026-08-18 — Checkout promo rows: `applicable_services` + `shop` parity fix (API)

**Symptom (reported):** a promotion authored in the shop portal WITH specific services +
an owning shop still came back with `applicable_services: null` and `shop: null` in the API.

**Root cause (traced the whole cycle):**
- Write path (portal) OK — `service_scope` (JsonExpression) + `shop_id` (pinned to
  `activeShop`) persist correctly; the edit form re-renders the saved services.
- `DealResource` (`/shops/deals`, `shop.active_deals`, `GET /shops/{id}/applicable-deals`)
  OK — already exposes `applicable_services` (`serviceScopeIds()`) + a `shop` object.
- **`BookingForm::resolveAndApplyPromotions()` built each `resolved_promotions` row by hand
  and omitted BOTH** — only a bare `shop_id`, no service scope, no shop object. That list is
  what `PreparingBookingResource.promotions[]` (the checkout "Choose a deal" surface) returns,
  so the customer-facing checkout is exactly where the nulls showed.

**Fix:** `api/models/BookingForm.php` — added `applicable_services` (`$promo->serviceScopeIds()`,
NULL = all services) and `shop` (`{shop_id, shop_name, image}`, NULL for platform-wide) to each
`promotions[]` row, mirroring `DealResource`'s shape. **Purely additive** to the opt-in
`resolve_promotions=1` response — backward-compatible, cannot break an existing mobile client.
Doc updated: `ai_specs/03_API/PROMOTIONS_BOOKING_DELIVERY_TO_MOBILE.md` (example payload + field
table + additive note). `php -l` clean.

**Live-verified** (`POST /booking/preparing-booking`, customer 62, shop 16): seeded an automatic
15%-off promo scoped to services [63,65]; the `promotions[]` row now returns
`"applicable_services": [63, 65]` and `"shop": {shop_id:16, shop_name:"بوي سكواد | Boy Squad",
image:…}` alongside the existing keys (previously only a bare `shop_id`). Seed promo + its
user_promo_code cleaned up; preparing-booking is preview-only so no stray booking was created.
Uncommitted.
