# LEDGER — WebStore Storefront (واجهة المتجر الإلكتروني)

**Feature slug:** `webstore-storefront`
**Started:** 2026-07-18
**Install:** moonui4 · branch `hazemdev4` · dev DB `moonui4_dev_be` · API `https://moonui4.elbaset.com/moon-erp-be/api/store`
**Source plan:** `/home/moonui4/public_html/his-analysis/webstore-storefront-analysis.html` (owner-approved 2026-07-18)
**KB topic:** `knowledge-base/topics/webstore-storefront.md`
**Theme:** `/home/moonui4/public_html/theme.zip` (md5 `e0bfcad7f35cbd27db0d40b30c9a541e`), extracted working copy in session scratchpad

## Owner's acceptance test

The owner opens the storefront at `/store/`, browses **real products from the database**, adds to cart, registers/logs in, completes a **real order** that appears in the ERP admin, and follows its status. The screens must look **exactly like the theme** (same orange/Tajawal design, pixel-identical markup), in **both Arabic and English**. Payment shows **cash-on-delivery only**; card/InstaPay/wallet appear disabled as «قريبًا».

## Decisions (owner-approved — do NOT re-litigate)

| # | Decision |
|---|---|
| D1 | **Angular 21 standalone app**, separate from the admin app. Theme design preserved **pixel-identical** — keep the theme's Tailwind markup, do **NOT** substitute PrimeNG visuals. |
| D2 | **Second application inside the existing FE repo** `moon-erp-angular` (`ng generate application storefront` → `projects/storefront/`). NOT a new repo — a third repo would leave the other parallel-dev installs blind to it. |
| D3 | **Scope = MVP first**: WP0 foundation + catalog + cart + checkout + auth. Account/prescriptions/loyalty/CMS come later. |
| D4 | **COD only.** Expose only payment methods whose `type === 'cod'`; render card/vodafone/instapay as disabled «قريبًا». No payment gateway exists in the backend. |
| D5 | **Arabic + English from day one.** Full i18n layer; backend already honours `Accept-Language`. |
| D6 | **Distribution via MoonStack**, built at release time and shipped pre-built, `--base-href /store/`. No `ng build` on the client (LVE PMEM limit). |

## Baseline (recorded 2026-07-18, before any code)

| Check | Result |
|---|---|
| BE `php artisan test --compact Modules/WebStore/tests` | ✅ **376 passed / 1158 assertions / 0 failed** (263s) |
| FE `npx ng build` (existing `moon-erp` app) | ✅ **green**, exit 0 |
| Pre-existing FE build warnings (NOT ours) | `jspdf`/`html2canvas` CommonJS optimization-bailout warnings — present at baseline, ignore |
| Pre-existing test failures | **none** |

## API contract facts that override the analysis (verified in code)

These were confirmed by reading the backend; the approved analysis was less precise. Implementers must follow **these**, not the HTML plan's prose.

| # | Fact | Consequence |
|---|---|---|
| A1 | Storefront base is **`/api/store`** for both public and customer routes. Admin is `/api/store/admin` — never call it. | — |
| A2 | Auth header **`Authorization: Bearer <token>`** (guard `store-customer`), with `X-Authorization` / `?token=` as host fallbacks. **Different from the admin app**, which needs `X-Authorization`. | interceptor |
| A3 | Token is a **top-level `token` key** in the response, sibling of `data` — not inside `data`. | auth service |
| A4 | **Registration issues a token immediately. There is NO OTP in the signup flow.** The code flow belongs only to forgot-password. ⚠️ **CORRECTED 2026-07-18 (WP4 prep): the route names are `verify-code` and `resend-code`, NOT `verify-otp`** as this ledger, the KB topic and the approved plan all state. Verified in `Modules/WebStore/routes/public.php`. | WP4 smaller than planned |
| A5 | `POST checkout/place-order` takes **`payment_method_id` (FK)**, not a `payment_method` string. Types come from `GET /payment-methods` → `type` ∈ `cod`\|`online`\|`card`. | D4 = filter on `type === 'cod'` |
| A6 | **No guest cart.** All cart routes require auth. | client-side localStorage cart, replayed via `POST cart/items` after login |
| A7 | **No logout endpoint.** Logout = drop the token client-side. | — |
| A8 | Money types are **inconsistent**: decimal columns serialize as **strings** (`"25.500"`), computed fields as **numbers** (`51.0`). | normalize at the HTTP layer, once |
| A9 | `CoercesNullDefaults` rewrites every null to `""` / `0` / `false`. `category_id === 0` means "none"; `average_rating` is **omitted** rather than null. ⚠️ **CORRECTED 2026-07-18 (WP2): "never null" is FALSE in two places.** `StoreOfferProductResource` does **not** apply the trait — `custom_price` is genuinely `null`. And `StorefrontProductResource` emits `category` as a **raw Eloquent model**, leaking `company_id` and real nulls. Verified against backend source *and* the live API. | falsy checks, never `=== null`; TS types without `\| null` — **except** offer `custom_price` and the nested `category` object, which must tolerate null |
| A10 | **Three different 422 body shapes**: validation `{message, errors:{field:[...]}}`, checkout `{valid:false, errors:["..."]}` (flat array), business `{message, ...}`. | error interceptor must branch on `Array.isArray(body.errors)` |
| A11 | Registration **requires `branch_id`**; `company_id` is derived server-side from the branch — never send it. Fetch `GET /branches` for the picker. | WP4 |
| A12 | Shipping = **`city.shipping_cost`** of the selected address's city, flat. `tax_amount` is hard-coded 0. `shipping_cost` is 0 when no `address_id` is passed to `calculate`. | WP5 |
| A13 | Tenancy is **server-pinned** (`WEBSTORE_DEFAULT_COMPANY_ID` → settings row → lowest company id). Frontend sends **nothing**. | — |
| A14 | `category_id` filter is **non-recursive** — a parent category returns 0 products if items hang off leaves. | WP2: surface leaf categories, or document |
| A15 | `per_page` is **ignored** on `/categories` (hard `paginate(50)`). Products cap `per_page` at 100. | WP2 |

## Theme facts (verified)

| # | Fact | Consequence |
|---|---|---|
| T1 | Lines 1–295 are **byte-identical across all 29 pages** (`md5 ae36a323`), and the tail from `<footer>` to `app.js` is byte-identical (`md5 ef28ee10`, 567 lines). | shell extraction is mechanical, zero-risk |
| T2 | 🔴 **`style-ar.css` / `style-en.css` key every rule on `body[dir=...]`, but `dir` is on `<html>`** — so ~380 lines of RTL/LTR CSS **never applied in the theme**. | Porting them faithfully would make them apply **for the first time** and change the look. **Verify each rule against the rendered theme before adopting.** Biggest pixel-fidelity trap. |
| T3 | Tailwind is the **runtime CDN** with an inline `tailwind.config` (lines 17–58, identical everywhere). Palette `primary` = orange `#FF9800` scale; `secondary` blue; fonts **Tajawal** (Arabic) + **Inter** (English); custom `shadow-soft`/`shadow-card`. | port the config verbatim; moving to a compiled Tailwind means **any dynamically-constructed class silently stops existing** — audit before switching |
| T4 | `app.js` (366 lines) has **zero** API calls, zero localStorage. All state dies on navigation. | full rewrite as Angular services/signals |
| T5 | `index.html` ships **two conflicting hero-slider implementations**; the inline one in the page tail is RTL-aware and correct, `app.js`'s is buggy legacy. | port the **inline** one |
| T6 | Theme register form = `branch, username, phone, password`. API needs `branch_id, name, mobile, password, password_confirmation`. **No confirm-password field exists in the theme.** | WP4 must add it — smallest possible visual addition, matching theme input styling |
| T7 | Wishlist counter in the header has **no class or id** (cart counter has `.cart-badge` ×2). | add a hook |
| T8 | Header has **no authenticated state at all** — login/register anchors always render. | WP1 must design an account variant in theme style |
| T9 | All product imagery is **external Unsplash** with a useless `onerror` fallback pointing at the same URL. Only local binaries are `logo.png`/`favicon.png`, **neither referenced**. | real image pipeline + working placeholder |
| T10 | Branding inconsistent: header says `صيدليتي`, but forgot/reset/verify pages say `الصيدلاني`/`صيدليات`. | pick one — see OPEN-2 |
| T11 | Language switchers (top bar + mobile drawer) are **inert** `href="#"`; `app.js`'s `.lang-dropdown` handler targets classes that exist nowhere. Zero `data-i18n` attributes. | WP1 builds a real switcher |

## Work Packages

All WPs are **FE-only** and land in the **same repo** → **strictly serialized, one writer at a time**. No migrations. No BE changes in the MVP.

| WP | Scope | Repo | Depends | Review | Migration | Status | Commits |
|----|-------|------|---------|--------|-----------|--------|---------|
| WP0 | Scaffold `projects/storefront/` · Tailwind config port · runtime `config.json` loader · HTTP client (auth header, `Accept-Language`, money normalization, 3-shape error branching, 401) · state service (auth/cart signals + localStorage) · i18n bootstrap (ar+en) · `--base-href /store/` | FE | — | Codex | no | ✅ done 2026-07-18 | `41e14035` scaffold · `9a04a6e8` review fixes |
| WP1 | Shared shell: TopBar · Header (search, cart badge ×2, **account-vs-login state**, category mega-menu from API) · Footer · MobileBottomNav · MobileMenuDrawer · Toast service · **real language switcher + `dir` handling** | FE | WP0 | Codex | no | ✅ done 2026-07-18 | `05744239` |
| WPS | **Demo catalog seeder** (dev fixture, not a feature): ~5 categories + ~20–30 bilingual products on `company_id=1` + store settings. Idempotent, no migration, not wired into any auto path. Unblocks WP2. | **BE** | — | code-reviewer | no | ✅ done 2026-07-18 | `08804f477` |
| WPS2 | **[FIN]** Checkout prerequisites + **cross-company coupon leak fix** (`CheckoutService` lines 65/109) with a Pest regression test · seed COD payment method, order/payment statuses, governorates/cities with real `shipping_cost`. Owner-approved 2026-07-18. Blocks WP5. | **BE** | WPS | **Codex** | no | ✅ done 2026-07-19 | `11e3ec2e8` fix+test · `15b4e3cf4` seeders |
| WP2 | Catalog: home (hero slider, category tiles, carousels) · products (filters/sort/pagination/search) · product-detail (gallery, tabs, reviews) · categories. Shared `ProductCard`, `ProductCarousel`, `CategoryTile`, `QuantityStepper` | FE | WP1 | Codex | no | ✅ done 2026-07-18 | `64742cfa` FE · `284175d40` BE changelog |
| WP3 | Cart: localStorage guest cart + **merge-on-login replay** · cart page (lines, steppers, coupon) · empty state · `OrderSummary`, `CouponInput` | FE | WP2 | Codex | no | ✅ done 2026-07-18 | `e4cf90df` FE · `1aef0e012` BE changelog |
| WP4 | Auth: login · register (branch picker + **added confirm-password**) · forgot-password · reset-password · verify-otp (reset flow only) · token storage · 401 handling · route guards | FE | WP1 | Codex | no | ✅ done 2026-07-18 | `f3c7b499` FE · `552ae7e7a` BE changelog |
| WP5 | **[FIN]** Checkout: address selector + governorate→city cascade · `calculate` · `place-order` · **COD-only payment (others disabled «قريبًا»)** · order-success · order-tracking | FE | WP3, WP4 | **Codex (degraded)** | no | ✅ done 2026-07-19 | `6cfc94cc` + `fac7abc9` |
| WP6 | i18n completion (full `en.json`, zero hardcoded strings) · **RTL/LTR audit incl. the T2 trap** · production Tailwind build (dynamic-class audit) · self-host fonts/icons decision | FE | WP2–WP5 | Codex | no | ✅ done 2026-07-20 (WP6a + WP6b) | |
| | 🔴 **WP6a agent DIED mid-run (2026-07-19 20:47 → silent 9h, no process alive, no stash).** Work was NOT lost — orchestrator checked before judging (the lesson from WP-IMG-1). 7 files uncommitted, build verified green, then **checkpointed as `f234634c`**. |
| | **Genuine RTL bug it found that the brief did not know about:** Google's Material Icons stylesheet sets `direction: ltr` on `.material-icons*`. Logical insets (`start-*`/`end-*`) resolve against the **element's own** direction, so icons computed physical `left` **inside an RTL page** — 3 sites (categories search, products search + sort) sat on the wrong edge while the class names claimed otherwise. Fixed **once in the shared layer** (`direction: inherit`), not per call site. Measured: icon at viewport-left 28px in BOTH languages before the fix. |
| | **Done so far:** icon-direction fix · `TOP_BAR.FREE_SHIPPING` routed through `COMMON.CURRENCY` · i18n **420/420 balanced, zero ar-only/en-only** · `I18N_VERSION` → `20260720a` · 9px mobile overflow confirmed already closed (`hidden group-hover:block`). **NOT done:** the 22-file hardcoded-string sweep, brand unification to `صيدليتي` (OPEN-2), `features/smoke/` deletion, rendered raw-key proof in both languages, RTL geometry audit report, CHANGELOG bullet. |
| 2026-07-20 | ✅ **WP-IMG-3 DONE** — unify the ADMIN product screens onto `products.image`/`product_images`. Owner chose option **(أ)** (unify) over relaxing the guard or dual-reading. FE `c6ab92ff`, BE changelog `6c45ce6d1`. code-reviewer APPROVE (0 crit/high/med; 1 LOW = object-URL not revoked on dialog Cancel/X, bounded non-leak, left per reviewer). |
| | **Root cause (orchestrator-verified):** the admin screens read images **exclusively** through `AttachmentService.list()` → `download()` → blob, and `attachments` has **0 rows** here, while `products.image` is set on **1018/1024**. `GET /api/core/products` returns a correct `image_url`, and both a demo and an imported image URL return **200** — so the API and the data were never the problem; the admin frontend simply never looks at `image_url`. |
| | 🔴 **A regression I introduced, found by the owner not by me:** WP-IMG-1's attachment guard rejects images uploaded as attachments on a Product, and the admin photo gallery uploads exactly that way (`attachmentAccept` includes `image/*`). Live repro: `POST /api/core/attachments` → **422**. I had written the behaviour-change warning into this LEDGER and then **did not go look for the workflow it would break.** Writing the warning is not the same as checking. |
| | **No backend work needed** — `ProductController::store`/`update` already accept `image` (file or path) and `images[]` via `HandlesImageUpload`, creating `product_images` rows with ascending `position`. Brief flags the multipart-`PUT` spoofing trap for the update path. |
| 2026-07-20 | **WP-IMG-3 ✅** (FE `c6ab92ff`, BE changelog `6c45ce6d1`; admin app). Display: catalog `44f1d9bb` (model `image_url`/`ProductImage`, service, catalog cmpt read `image_url`, blob pipeline removed). Upload+grid preview+gallery: `products.component.ts` + `.html` — picks are **staged** (`pendingMainImage`/`pendingGalleryImages` + `mainImageDirty`/`galleryDirty`) in add **and** edit mode and flushed via new `ProductService.saveImages()` (POST + `_method=PUT`, multipart) inside the save `forkJoin`; `buildImagesPayload()` omits untouched fields; single-gallery-delete re-sends survivor paths (no per-row endpoint — by design). **Documents untouched** (still `AttachmentService`); `attachmentAccept` narrowed to drop `image/*`. Removed dead `productMainImage(Blob)`/`galleryBlobUrls`/`loadImageAsBlob`/`getGalleryBlobUrl`/`getAttachmentUrl`/`imageUploading`/`previewBlobUrl`+`previewLoading`. **Build green.** **Live round-trip proof** against moonui4 API (product 1027, since soft-deleted): main+2 gallery via the exact FE multipart shape → 3 public-disk rows; both URLs `200 image/png`; single-delete via survivor re-send → 1 row, main untouched; **store `?per_page=1` returned that product with the uploaded image → admin→store round trip confirmed.** CHANGELOG bullet added `[Unreleased]`. Deployed to live `/app` (bundle `main-ZDLKZK5U.js`, config verified moonui4). Committed FE `c6ab92ff` + BE `6c45ce6d1`; **not pushed** (owner runs `/fullpush`). |
| 2026-07-20 | **WP6b ✅** — `66269ecc` (baseHref + deploy tooling) · `cb1cd53d` (self-hosted fonts + Inter fix). |
| | **`baseHref` pinned** in `angular.json` (storefront project only — the `moon-erp` block proven identical by structural comparison). `npx ng build storefront` with **no flags** now emits `<base href="/store/">`; before, a forgotten `--base-href` produced `<base href="/">` and a silently blank page, which had already broken one real deploy. |
| | 🔴 **Root cause found for the agent's "a clean build cannot boot" report, and it is confirmed:** `assets/config.json` is gitignored, and **the Angular asset copier skips gitignored files** — proven by copying the same file under a non-ignored name, which WAS emitted. So a clean build ships `config.json.example` and no `config.json`, and the app refuses to boot. That is *correct* security behaviour (an instance's `apiUrl` must never be baked into a portable bundle) but it means any CI or clean-room deploy serves a dead app. **Fix is procedural, not a bundling change:** `projects/storefront/deploy/deploy.sh` checks for the config *before* building, restores it after, restores `.htaccess` if missing, asserts the base href, and prints the resolved `apiUrl` so a cross-instance mistake is visible. |
| | **`.htaccess` is now in the repo** (`deploy/htaccess.template`). It existed **only** in the deploy target, in no repo at all — losing it would have broken every route under `/store/`. |
| | **Fonts and icons self-hosted**, 7 external `<link>`s removed. Google's own `@font-face` CSS with URLs rewritten to local files, so **unicode-range splitting is preserved** (an Arabic session still downloads only the Arabic subset). Weight 300 dropped — unused; the UI uses 400/500/600/700/800, measured from the compiled classes. **Measured: zero external requests in both languages; every icon family renders as a 24x24 glyph (ratio 1.0), not its literal word.** |
| | ⚠️ **Icon subsetting attempted and abandoned on evidence:** `pyftsubset` over the used ligature names cut only ~10% (125K→111K etc.), because ligature closure pulls in every icon whose name starts with the same letters. Kept the full faces. **The real saving is elsewhere: `material-icons-round` (169K) and `material-icons` (125K) serve only 11 call sites versus 210 for `outlined`. Consolidating them would remove ~294 kB — but that is a visual change to the approved theme, so it is an OWNER decision, not mine.** |
| | 🔴 **Defect the font measurement exposed (pre-existing, now fixed):** `<body>` carried a static `font-arabic` class while `LanguageService` toggles `font-arabic`/`font-english` on `<html>`. The body class is more specific for that element and always won — **computed body font-family was `Tajawal` in BOTH languages, so `Inter` was shipped and never once used.** Now `Tajawal`/ar, `Inter`/en, verified by computed style. |
| | **Production Tailwind audit: 393 literal classes extracted from templates, checked against the built CSS, `missing=0`.** My first pass reported 125 missing — that was **my broken selector escaping**, not a CSS gap; Tailwind escapes `:` `.` `/` in selectors. Corrected and re-run. |
| 2026-07-20 | **WP6a ✅** — finished by the orchestrator by hand after the agent died. Commits: `f234634c` (recovered icon-direction fix) · `e89497fd` (tab title + dead-code removal) · BE `9f3e2fb47` + `e53a10006` (changelog). |
| | 🔴 **The dead agent had ALSO committed to the BACKEND repo (`9f3e2fb47`) — I only checked the frontend at first and nearly shipped a duplicate release note.** Its bullet documents real work (English copy consistency `Log in`/`Sign in`, `section`/`category`, currency extracted from three sentences, footer email as a setting). I trimmed my own overlapping bullet to the tab title only. **Lesson: a dead agent's blast radius is every repo it could reach, not the one you dispatched it against.** |
| | **Brief premise was WRONG and I inherited the error from my own recon:** the "22 files with Arabic = a leak that grew from WP1's 2" was **almost entirely comments**. Real hardcoded Arabic in shipped code: **one** literal, `address-format.ts:28` `PART_SEPARATOR = '، '`, which already carries an explicit justification comment (the address text is Arabic in the DB in every locale). English sweep: all hits were Material-icon ligatures plus `VISA`/`Android`/`iOS` proper nouns. **The only genuine finding was `<title>Moon Store</title>`.** |
| | **OPEN-2 was already closed in code** — `auth-card.component.ts` routes all five auth pages through one component, so `صيدليتي` is used everywhere; the `الصيدلاني`/`صيدليات` split existed only in the source theme. The LEDGER was stale, not the code. **`BRAND.NAME` in English is still the unapproved transliteration `Saydaliyti`** — one i18n key, still owner's call. |
| | **Measured proof (not asserted):** raw-key audit over **11 pages x 2 languages = 22 renders → `rawKeys: []` on every one**, `dir` correct in both, tab title verified changing (`صيدليتي — صحتك أولويتنا` / `Saydaliyti — Your health is our priority`). RTL audit over **6 pages x 2 languages x 2 viewports = 24 renders → 0 horizontal overflow (the 9px bug measured closed) and 0 absolute elements that failed to mirror** (T2 trap clear). Build green, 3.058s. |

**Deliberately out of MVP scope** (do not build): account pages, wishlist, prescriptions, loyalty, notifications, CMS pages, about/contact, orders list/detail/review.

**Out of scope but must ship before public launch** (tracked as deferrals, NOT built here): closing `otp_bypass_enabled`, wiring an SMS sender, fixing the cross-company coupon leak, payment gateway.

## DAG / serialization

```
WP0 ──► WP1 ──┬──► WP2 ──► WP3 ──┐
              │                   ├──► WP5 ──► WP6
              └──► WP4 ───────────┘
```

WP2/WP3 and WP4 are logically independent, **but both write the same repo → run them one at a time**. Order: WP2 → WP3 → WP4 → WP5 → WP6.

## Open decisions (surfaced to owner — do not silently decide)

| # | Question | Recommendation | Status |
|---|---|---|---|
| OPEN-1 | Registration needs a `branch_id`. The theme hard-codes 4 fake branches. Show a real branch picker (extra UX friction), or auto-select when the install has exactly one branch? | **Auto-select when exactly one branch exists; show the picker only when >1.** | 🔶 **still owner's call, but proceeding on the recommendation** — `GET /branches` returns exactly one branch (`id:1, Main Branch / الفرع الرئيسي`), so the picker would be a one-option dropdown. WP4 builds both paths; reversible. |
| OPEN-2 | Branding: `صيدليتي` (header) vs `الصيدلاني`/`صيدليات` (auth pages). Also the store name is not in the backend `settings`. | **Use `صيدليتي` everywhere**; make it an i18n key so it is swappable per client. | ✅ **RESOLVED 2026-07-20** — owner directed the store be **generic**, not pharmacy-branded. Shop name now comes from the **company row** (`name_ar`/`name_en`) and slogan from a new `store.slogan_*` setting, both via `GET /store/settings`; the `BRAND.*` i18n keys became **neutral** fallbacks (`المتجر`/`Store`), so the unapproved `Saydaliyti` is gone. All pharmacy wording/icons genericized. FE `8eefd855` / BE `dad9b4201`. |
| OPEN-3 | Product images are all external Unsplash placeholders; real installs will have `image_url` from the API. What renders when a product has no image (`image: ""`)? | **Local neutral placeholder** shipped with the app. | ⬜ awaiting owner |
| OPEN-4 | Theme checkout offers a paid «توصيل سريع +25 ج.م» option. The backend has **no express-shipping concept** — shipping is flat per city. | **Hide the express option** for the MVP (same honesty rule as D4). | ⬜ awaiting owner |
| OPEN-5 | ✅ **RESOLVED 2026-07-18 — owner approved the demo seeder** (option 1). Dispatched as **WPS** on the BE. Original text: 🔴 **BLOCKS WP2.** `moonui4_dev_be` has **0 products, 0 categories**, and store settings are all empty. Verified via DB counts + live API (`meta.total: 0`). There is no product/demo seeder anywhere — only `DefaultStoreStatusesSeeder`. A catalog UI cannot be built or verified against an empty DB. Options: (1) write an idempotent demo seeder for the store, (2) point `config.json` at a backend that has data (⛔ production = real client data, cart/orders would write there — not recommended), (3) owner adds products by hand via `/app`. | **(1) idempotent demo seeder** — serves every later WP and future e2e, stays isolated from clients. Adds small BE work outside the approved MVP scope, hence an owner decision. | ⬜ awaiting owner |

## Review-tooling decision (owner, 2026-07-18)

**Codex takes the per-WP review gate; Opus keeps implementation.** Codex (`codex exec`, model `gpt-5.6-sol`, `reasoning_effort = high`) was **re-tested and works** — it read `error.interceptor.ts` and reviewed the three-shape 422 handling correctly for ~6k tokens. This restores what the `implement-plan` skill nominally specifies; the native `code-reviewer` had been a fallback only because Codex was genuinely broken earlier (looped on `git diff HEAD~1 HEAD`, exit 1).

Two corrections were made to the owner's stated premise before agreeing:
1. **Sub-agent transcripts never entered the orchestrator's context anyway** — only the final report does. So this **shifts** spend from Anthropic to OpenAI rather than eliminating it. Valid if Anthropic spend is the target; not a free win.
2. **Implementation of [FIN] / browser-verified WPs stays with Opus.** Every storefront WP so far passed its own green test run *while still broken* (inverted RTL badge, client-validation that never rendered, a cart write race). Only long real-browser verification caught them, and Codex's sandbox is untested for that.

**First trial:** WPS2 (coupon leak fix + checkout prep). Expand Codex's role only if it demonstrably catches real defects on live work.

## Deferrals (discovered mid-flight, consciously pushed out)

| Item | Why deferred | Where it must land |
|---|---|---|
| `otp_bypass_enabled=true` + bypass code `123456` (BE `Modules/WebStore/config/config.php:21-22`) | Backend security hardening, outside the MVP frontend scope | pre-launch WP5 of the original plan |
| OTP never delivered (logged only) — forgot-password unusable for real users | Needs an SMS provider decision | pre-launch |
| 🔴 **`verify-code` consumes the OTP that `reset-password` then re-verifies** — password reset is impossible for any real customer; only the bypass code `123456` works, so the bug is invisible to bypass-based QA. Verified in `StoreAuthService.php:196-222`. | One-line backend fix (don't re-verify, or don't consume) + a regression test; outside an FE work package | **backend ticket, before launch** |
| Cross-company coupon leak (`StoreCouponController::index` lacks the tenancy trait) | Backend bug, separate fix + test | pre-launch |
| No payment gateway | Owner decision D4 defers it | future WP |
| `category_id` filter non-recursive (A14) | Backend behavior; may need a recursive option | revisit in WP2 |
| 🔴 **`CartService::addItem:22` — `Product::findOrFail()` unscoped** (+ global `exists:products,id` in `AddCartItemRequest`). Company A can add Company B's product to a cart and place it in a Company A order. Codex-found, orchestrator-verified; `products` has `company_id`. **Owner chose on 2026-07-19 to proceed to WP5 and ticket this** rather than expand scope again. | Pre-existing, not introduced by WPS2; invisible on a single-company install | **backend ticket, before any multi-company install** |
| 🔴 **`ShippingCalculator:31` — `City::find()` unscoped**, and the address Form Requests accept any global `city_id`. A customer can point their own address at another company's city and pay that company's shipping price. Codex-found, orchestrator-verified; `cities` has `company_id`. Related: the code comment claims it "fails closed (0)" — **0 is fail-OPEN (free delivery)**. | Same as above | **backend ticket** |
| ⚠️ **`ShippingCalculator::calculate()` left `$customerId` optional** — any caller still passing one argument silently gets **zero shipping**. The one in-repo caller was updated; external/module consumers would break silently. | Flagged by Codex; a one-line signature tightening | **backend ticket (cheap — worth doing early)** |
| ⚠️ **WPS2 test gaps Codex identified:** the place-order coupon test never reaches the second lookup (needs the *same code* in both companies); "does not **record**" only asserts `coupon_id` is null while the raw foreign `coupon_code` IS still stored; no coverage for foreign products, foreign/null cities, or soft-deleted rows. | Test-hardening, not a live defect | with the tickets above |
| 🔴 **SECOND cross-company coupon leak — `CheckoutService::calculate`.** `StoreCoupon::where('code', …)` at **lines 65 and 109** with **no `company_id` filter** — orchestrator-verified, in a file that filters `company_id` correctly on statuses at lines 116/123/131. Distinct from the known `StoreCouponController::index` leak. `CartController::applyCoupon` filters correctly, so **apply rejects a foreign coupon while calculate would honour it** — an inconsistency that becomes reachable in WP5, which passes `coupon_code` to calculate directly. | Backend bug found during WP3; fixing it is outside an FE work package | **its own ticket, before WP5 ships to anyone** |
| 🔴 **Backend does not block adding an out-of-stock product to the cart** (`AddCartItemRequest` validates only `exists:products,id`; verified 201 for `DEMO-P012`). Today the client is the only gate — the mobile app or any direct API consumer can oversell. | Backend validation gap, outside an FE WP | backend ticket |
| 🔴 **Public catalog endpoints do not filter by `company_id` at all.** `CatalogProductController::index` and `CatalogCategoryController` filter on `status`/`is_active` only — **orchestrator-verified: zero `company_id` references in the file**. Invisible today because moonui4 has exactly one company, but on a second company **every product would leak into the public, unauthenticated storefront**. Same family as the known `StoreCouponController` leak, but on a public endpoint and therefore worse. | Backend security bug, entirely outside a dev-fixture WP; needs its own fix + regression test | **its own ticket, before any multi-company install** |
| Admin app (`moon-erp`) ships its own `src/assets/config.json` in production builds — same missing-`ignore` defect as storefront HIGH-1 | Found during WP0 review; touching the admin build target is out of WP0 scope and needs its own regression check | separate fix, owner-scheduled |
| `baseHref` not pinned in `angular.json` for `storefront` — `--base-href /store/` must be passed on every deploy build | Belongs with distribution wiring | WP6 (MoonStack integration) |
| 🔴 **[FIN] `MoneyPipe` rounds to 2dp; the backend charges `decimal(12,3)`** (`projects/storefront/src/app/shared/money.pipe.ts:22-30`, `Math.round(amount*100)/100`). A charged `10.005` displays as `10.01` — the customer is shown a number that is not the number they pay. Codex-found, orchestrator-verified. **Latent today** (no seeded product has a 3rd decimal; both real orders are 2dp-clean) but a percentage coupon reaches a 3rd decimal trivially (10% of 148.05 = 14.805). | Needs an owner decision, not a unilateral fix: show 3dp everywhere, or have the backend round to 2dp at the money boundary. Changing the pipe alone would make the storefront disagree with the ERP admin. | **owner decision + WP6, before real customers** |
| ⚠️ Currency is a hard-coded `COMMON.CURRENCY` i18n string (`"EGP"`) — the backend exposes no currency setting for the storefront | Pre-existing backend gap, not introduced here | backend ticket |

## Progress log

| When | What |
|---|---|
| 2026-07-21 | **`/fullpush` sync + 🔴 storefront gate opened on moonui4.** Both repos fast-forwarded to the other instances' work (BE `d16f4a2b3`, FE `f92876bd`). The BE merge **refactored my `StoreSettingsController`** onto `UpdateStoreSettingsRequest::WRITABLE_KEYS` + `StoreSettingsResource` — my `slogan_ar/en` keys were correctly folded in (11/11 settings tests pass). The FE merge brought a large storefront evolution (slot design system, SEO, theme, analytics) that **integrated** the identity work rather than replacing it. Two judgement calls: **(a)** the merge changed `src/assets/i18n/{ar,en}.json` **without bumping `I18N_VERSION`** → clients would render raw keys; bumped `20260721l`→`20260721m` (`f92876bd`, `[skip-changelog]`). **(b) 🔴 `/store` 404'd after deploy** — not a deploy fault: the merged `StorefrontGate` makes the storefront **opt-in**, requiring `system.enabled_modules` to contain `'store'`, and it was **NULL** on moonui4 (fails CLOSED by design). ⚠️ **That key is a WHITELIST** — `EnforceEnabledModules` 404s any module *not* listed, so writing `['store']` alone would have killed every `/app` API module. Owner approved enabling; set the **full** list on `moonui4_dev_be` company 1: `lis, accounting, inventory, sales, purchases, hrm, pos, support, core, store`, then cleared `storefront-gate:enabled` / `module-gate:1` / `owner-entitlement:modules` (300s TTL). Verified: `/store/` → 308 → **`/store/ar` 200** (merge added language-prefixed URLs), 45 products, zero JS errors, identity intact (name/slogan/logo/favicon); all `/app` modules re-verified 200 with a real token. 3 new migrations ran (`storefront_display_fields_on_product_categories`, `store_catalog_imports`, `store_analytics_outbox`), 19/19 seeders, `/app` bundle `main-CTLUKIQX.js`. |
| 2026-07-20 | **Store-identity + genericization (owner ad-hoc, post-fullpush) ✅.** Two capabilities, both live-verified on `/store` in AR+EN, code-reviewer APPROVE (0 crit/high; fixed 1 MEDIUM i18n-fallback race via a `LanguageService.i18nEpoch` signal the `BrandingService` effect reads + 1 LOW stale doc). **(1)** Logo + contact info (address/phone/email/socials + WhatsApp-from-phone) wired from `GET /store/settings` via new `StoreSettingsService`, shown in header/top-bar/footer/drawer — FE `086530ef`, BE changelog `e987e359f`. **(2)** Shop **name** from the **company row** (`name_ar`/`name_en`) + **slogan** from new `store.slogan_*` setting (public endpoint + admin ALLOWED_KEYS + 3 tests, 10/10 pass, Pint clean); new **`BrandingService`** drives the tab **title** (name+slogan) and **favicon** (logo) via an effect; header/footer/drawer/auth-card render name+slogan+logo; **all hardcoded pharmacy content genericized** (brand `صيدليتي`/`Saydaliyti`→`المتجر`/`Store`, tagline, search placeholder, about, email, `local_pharmacy`/`medication` glyphs→`storefront`, category fallback icons, static `<title>`). FE `8eefd855`, BE `dad9b4201`. Live proof: single `GET /store/settings`; tab title `مون اي ار بي التجريبي — كل اللي محتاجه في مكان واحد` (AR) / `Moon ERP Demo — Everything you need in one place` (EN); favicon = `…/demo/branding/logo.png`; only remaining "أدوية" on the page = a **demo catalog category** (store data, correctly untouched). Resolves **OPEN-2** and WP1 flag **(d)**. `I18N_VERSION`→`20260720c`. Deployed to `/store`; **not pushed** (owner runs `/fullpush`). |
| 2026-07-18 | Phase A: plan ingested, API contract + theme mapped by top-tier examiners, baseline recorded, LEDGER + briefs written. No product code yet. |
| 2026-07-18 | **WP0 ✅.** Scaffold `41e14035` (34 files, +1900/−0) — the admin app's `moon-erp` block in `angular.json` is byte-identical before/after (verified by normalized JSON compare, not eyeballing the diff). Review fixes `9a04a6e8` (8 files, +166/−47). Both commits on `hazemdev4`, **not pushed**. |
| | **Review:** ⚠️ Codex unusable (looped on `git diff HEAD~1 HEAD` → exit 1 inside its sandbox, dozens of retries). Documented fallback = native `code-reviewer` agent. Verdict: **0 CRITICAL · 1 HIGH · 4 MEDIUM · 7 LOW**, all 11 WP0 contract requirements verified with `file:line` evidence — including the two most fakeable (`Array.isArray(body.errors)` branch ordering, and the config-error panel using `textContent` not `innerHTML`). HIGH + all 4 MEDIUM fixed in `9a04a6e8`; LOWs left. |
| | **HIGH-1 (the one that mattered):** `angular.json` globbed `**/*` from the storefront assets with no `ignore`, so the production bundle shipped `assets/config.json` carrying the **developer's real apiUrl**. That silently defeats contract item 9 — a fresh client install would quietly talk to the moonui4 backend instead of failing loudly. Worse here than in the admin app because **MoonStack ships the storefront pre-built**. Fixed with per-configuration `assets`: production ignores `config.json`, development still serves it. |
| | **Test gate (re-verified by the orchestrator, not taken on the agent's word):** prod build 1.363s → `dist/storefront/browser/assets/` = `config.json.example` + `i18n/` only, `find … -name config.json` → nothing. Dev build + `ng serve` still serve it (HTTP 200). Admin `npx ng build` exit 0, only the 3 pre-existing non-ESM warnings. `git status` clean; `config.json` ignored via `.gitignore:59`, `.example` tracked. |
| | **Race proof:** headless-Chromium test forced the failure mode (first i18n request pair delayed 2500ms, later 150ms, two rapid toggles) → superseded requests `net::ERR_ABORTED`, final `html lang` correct, not stuck loading. The pre-fix counterfactual run was **not** obtained — the permission classifier denied the `git show >` redirect and the agent correctly did not work around it. |
| | **Deviations recorded:** (a) `console.error` in production code contradicts the global TS rule — kept deliberately, MEDIUM-1 required it and it matches the fail-loudly design. (b) Agent added `CART_ITEM_MONEY_KEYS`/`GUEST_CART_ITEM_MONEY_KEYS` beyond the brief, because cart money lives on line items, not the envelope — accepted. (c) Money-key constants live in `api-client.service.ts` per the brief, so `auth.state`/`cart.state` now import from an http service; no cycle, but moving them to the dependency-free `money.ts` is cleaner — noted for WP1. |
| | **No CHANGELOG bullet:** WP0 delivers no user-visible capability (skill step B5 is per capability). |
| 2026-07-19 | **WP-IMG-1 ✅ `fa14efdf2`** (BE, 7 files, +83/−21 + 2 new test files). Owner-approved root-cause fix for product images. **Discovery that reframed the whole problem: `product_images` (`product_id, image, position`) already exists, is wired to `Product::images()->orderBy('position')`, and had ZERO rows.** The "markers" needed for multi-image products were already in the schema — `products.image` = main, `product_images.position` = gallery order — so no `is_product_image` column and no migration were needed. The real defect was that images had been imported through the **generic attachments API** (private disk, internal documents, no ordering concept) instead of the correct public-disk product path. Owner confirmed the 17k historical products were machine-imported and never used by a customer, so re-importing correctly beats bridging over the mistake. |
| | **Three fixes:** (1) `ProductImageResource:13` returned the **raw relative path** while every sibling resource builds `url('storage/'.…)` — **every gallery image would have failed**, latent only because the table was empty, i.e. it would have detonated on the import it was preparing for. (2) `Product::getImageUrlAttribute()` — the attachment fallback composed the public `/storage/` prefix over a **private**-disk path: a well-formed URL that could only ever 404, which is worse than `null` because the UI treats it as valid instead of showing its placeholder; it also took the first attachment **by id with no mime check**, so a PDF datasheet could become the product photo. Removed entirely. (3) `StoreAttachmentRequest` now rejects image uploads whose attachable is a `Product`, sniffing mime from the **bytes via finfo** (not the extension, so a renamed file cannot slip through) with an extension fallback when finfo returns null. Also dropped two now-dead `with('attachments')` eager-loads in `CatalogProductController`. |
| | **Verified by the orchestrator, not taken on the agent's word:** 6/6 new tests pass; **counterfactual run — source reverted with the tests kept → 3 failed**, proving they are real regression tests and not self-confirming. Full `Modules/WebStore` suite **385 passed / 1191 assertions / 0 failed** (baseline 376/0 failed — increase is new tests, zero regressions). `pint --dirty` → `{"result":"pass"}`. Bilingual `[STORE-IMG-1]` CHANGELOG bullet added. Not pushed. |
| | ⚠️ **Behaviour change the owner must know:** uploading **any image as an attachment on a Product** is now rejected (422). If a workflow exists that attaches product photos this way (damage photos, certificates), it breaks. Judged correct because it closes the door on the root cause, but it is a real restriction, not a pure bug fix. |
| | 🔴 **Process failure — mine, recorded so it is not repeated.** I killed the implementer agent at ~46 min, concluding it was stuck from "clean working tree + zero file writes". **It had actually finished and `git stash`-ed its own work** (`wp-img-1-temp`, stashed *with* untracked files) to run a clean-baseline comparison. The evidence was real; my reading of it was wrong. Recovered in full via `git stash pop` — nothing lost. **Lesson: a clean tree is not evidence of no work; check `git stash list` before judging an agent stalled.** Its reviewer sub-agent separately mis-attributed the stash to a "concurrent process on the shared host" — also wrong; it was the implementer itself. |
| 2026-07-19 | **WP-IMG-2 ✅ `a7b896fb4`** (BE, 3 files, +836, `[skip-changelog]` — dev fixture). Imported **1000 products** from the moonui (moon 1) catalog into `moonui4_dev_be` through the **correct** image path: `products.image` (main, public disk) + `product_images.position` (gallery). New human-invoked command `Modules/WebStore/app/Console/Commands/ImportCatalog.php` (701 lines) + `export-moonui-catalog.sql` (SELECT-only, 132 lines). Not wired into any seeder, deploy script or the scheduler. |
| | **Verified by the orchestrator independently, not taken on the agent's word:** products **1024** = 24 demo + 1000 imported · rows with `company_id != 1` = **0** (the 4→1 mapping held) · 260 categories · 10 gallery rows · demo products present and unmodified, still pointing at `demo/products/p001.png` · **30/30 live image URLs returned `200`** · `php artisan test Modules/WebStore --compact` → **385 passed / 1191 assertions**, identical to the WP-IMG-1 figure, so **zero regressions**. |
| | **Source proven untouched byte-for-byte** after the run: `moonui_dev_be` still 17156 products / 8 `product_images` / 17149 attachments / 690 categories. Read-only contract honoured. |
| | 🔴 **Brief error the agent found and worked around correctly:** the brief asserted `moonui_dev_be` is "reachable directly via the local mysql client" — true for the **orchestrator's root shell**, false for the **Laravel DB user** (`ERROR 1044: Access denied`). It did not improvise a privilege change; it built a two-stage SELECT-only export → JSON manifest → importer. |
| | 🔴 **The LEDGER had no `Modules/Core` baseline** — the brief told it to compare against one that was never recorded. It refused to guess: stashed its own work, ran Core on a clean tree, and proved the **7 failures are pre-existing** (roles/permissions tests, unrelated). **Core baseline = 7 pre-existing failures, recorded here so no later WP re-derives it.** |
| | ⚠️ **Data caveat — the 10 "two-image" products are the SAME photo uploaded twice** (identical md5, same filename, 25 min apart in the source). The gallery is exercised **structurally** but not visually. Left as-is rather than inventing variety. |
| | **Two judgement calls needing owner sign-off (OPEN-6, OPEN-7):** (a) **model events suppressed during import** — `TracksQuota` fires a Moon Central HTTP call on every `created` and `MOON_LICENSE_ENABLED=true` here, so 1000 fixtures would have inflated **real licence counters**; (b) **codes namespaced `M1-`** (`M1-PRD-00001`) — makes it structurally impossible to touch a demo row, but the prefix **is visible in the storefront**. Trivial to revert to raw source codes. |
| 2026-07-19 | **WP5 [FIN] ✅ `6cfc94cc`** + **`fac7abc9`** (tracking-timeline fix), 20 files, +4242/−46, all under `projects/storefront/`. Orchestrator re-verified against the correct base (`2d5c1399..HEAD` — an earlier diff wrongly included `src/` because it spanned the main merge): **nothing under `src/` or `angular.json` touched**; both builds green; preview deployed (`/store/checkout` → 200). Not pushed. |
| | **Money verified end to end, real orders in the dev DB:** `WS-20260719-6505` total **178.000** (Nasr City, ship 30) and `WS-20260719-4043` total **198.200** (Borg El Arab, ship 65, 10% coupon) — orchestrator confirmed both rows via direct DB query, `company_id = 1`. Displayed total equalled the recorded total in both cases. Shipping proven equal to `city.shipping_cost` in **two different cities**, and both orders appear in the ERP admin. Cancel verified (200, then 422 "already cancelled"). |
| | **The agent caught a real bug in its own work by placing a real order:** `statusHistory()` is `->latest('created_at')`, so the API is **already newest-first**; its `.reverse()` would have shown **every customer their order history upside-down**. One status row can never reveal an ordering bug — only cancelling a real order exposed the second row. Fixed in `fac7abc9`. It also consolidated a thrice-duplicated `.join('، ')` into `shared/address-format.ts` per the fix-in-the-shared-layer rule. |
| | **Brief errors found:** (1) "23 cities across 10 governorates" — cities is right, but `GET /governorates` returns **42 rows across 11 countries** and only 10 have any city, so a naive cascade would offer Dubai/Riyadh/Beirut with an empty city list. (2) A10 — checkout emits **both** 422 shapes, proven on `place-order` itself (empty cart → flat array; foreign `address_id` → field map). (3) The nullable-exceptions list is incomplete: `address.city` and `address.governorate` are **genuinely null** (the resource wraps them in `CityResource`, so `CoercesNullDefaults` skips them) while `city_id` on the same object is coerced to `0`. (4) `payment_status` has **no `color`** while `status` does. |
| | **Fail-open shipping handled by refusing the precondition, not by working around it:** the governorate picker offers only governorates that have cities, city is required, and a city-less address is listed but **unselectable and labelled incomplete**. Zero shipping is never presented to a customer as free delivery, and the limitation is stated in the changelog bullet rather than hidden. |
| | ⚠️ **Declared gap — the agent never clicked a rendered page.** Deployment was prohibited for it, so every check above is at the **API layer** using the exact request shapes the code sends, plus a green compile. The two-city proof is of the *contract*, not the *rendering*. **The total-mismatch banner has never fired** (both orders matched), so that path is reasoned, not observed. Coupon apply/remove through `CouponInput` was verified only as a `calculate` pass-through. |
| | 🔴 **CODEX REVIEW GATE DEGRADED — do not read its verdict as a review of WP5.** Its sandbox failed on every command (`bwrap: Can't access /newroot/proc/sysrq-trigger`), so it could not read the local working tree and **silently fell back to the GitHub repo's `main`, which does not contain WP5** (unpushed). Its findings 2 and 3 are explicitly "cannot verify"; the rest describe the **WP3 baseline**. Its "REJECT" verdict is therefore **not** a judgement on this WP. **Lesson: a Codex review must be confirmed to have actually read the local diff before its verdict is given any weight.** |
| | 🔴 **One Codex finding IS real and orchestrator-verified — `MoneyPipe` (`shared/money.pipe.ts:22-30`) rounds every figure to 2dp** (`Math.round(amount * 100) / 100`) while the backend stores and charges **`decimal(12,3)`**. A charged `10.005` would display as `10.01` — **display ≠ charged**, which violates this WP's own money rules. **Currently latent**: zero seeded products carry a third decimal and both real order totals are 2dp-clean (`178.000`, `198.200`). But a percentage coupon on a 2dp subtotal reaches a third decimal easily (10% of 148.05 = 14.805). **Needs a decision before real customers.** |
| | Codex's other live findings, lower severity: `COMMON.CURRENCY` is a hard-coded `"EGP"` i18n string rather than server-provided (the backend has no currency setting — a known pre-existing gap); and `OrderSummary` falls back to the cart's own server `subtotal` when `calculate` fails, shown with an "estimate" notice (a WP3 decision, already documented in the component). |
| 2026-07-19 | **WPS2 ✅ `11e3ec2e8`** (coupon/address/payment scoping + `StorefrontCheckoutTenancyTest`, 6 files) **+ `15b4e3cf4`** (seeders). Ahead 2, **not pushed**. Suite **376 → 382 passed / 1180 assertions**, exactly +6, zero pre-existing failures. Pint pass. Regression test confirmed **failing on pre-fix code** (5 failed / 1 passed with sources stashed) and passing after. |
| | **The agent found TWO more holes beyond the brief, one severe:** `ShippingCalculator` did `StoreCustomerAddress::find($addressId)` with no owner filter; and worse, **`placeOrder` stored a foreign `address_id` on the order with no ownership check at all — an order could be shipped to another customer's address.** Both fixed in the Form Requests per convention (now 422 instead of silent acceptance). Left alone deliberately and correctly: `StoreCouponValidateController:24` has the same unscoped coupon lookup but is `@unauthenticated`, so there is no customer to scope to — needs an owner decision, not an improvised default. |
| | **Brief error:** the brief asserted `CountryGovernorateSeeder` "covers governorates/cities". **It seeds ZERO cities** — only countries and governorates. Cities were unbudgeted new work; without them every address has a null `city_id` and shipping is silently always free, i.e. exactly the fiction the brief warned about. Agent wrote `StoreCitySeeder`: **23 cities / 10 governorates, 30.000–65.000 EGP, zero cities at 0**. COD-only payment method seeded and verified live. Idempotency proven over 3 runs (identical counts), rows with `company_id != 1` = **0**, and the soft-delete path explicitly exercised (trash → reseed → un-trashed, no duplicates). |
| | 🔴 **FIRST CODEX REVIEW GATE — it earned its place. Verdict: "No — two exploitable cross-company lookups remain."** Both **orchestrator-verified in source**, and both are pre-existing (not introduced by this commit) but sit directly in the path WP5 will build on: |
| | • **`CartService::addItem` line 22 — `Product::findOrFail($productId)`, unscoped**, reachable because `AddCartItemRequest` validates with a global `exists:products,id`. **Company A can add Company B's product to a cart and place it in a Company A order.** Confirmed: `products` HAS a `company_id` column. |
| | • **`ShippingCalculator` line 31 — `City::find($address->city_id)`, unscoped**, and `StoreAddressRequest`/`UpdateAddressRequest` accept any global city id. **A customer can point their own address at another company's city and pay that company's shipping price.** Confirmed: `cities` HAS a `company_id` column. |
| | • **Sharp correction to our own wording:** the code comments claim shipping "fails closed (0)". Codex: returning **0 is fail-OPEN** — a null/missing/soft-deleted city silently yields **free delivery**, which is a revenue loss, not a safe default. |
| | • **Test weaker than it looks:** the place-order coupon test does **not** prove the second coupon lookup is scoped — with a foreign-only coupon, `coupon_applied` is false so the second lookup at line 129 is never reached. Proving it needs the *same code* existing in both companies. Also, the test named "does not apply or **record**" only asserts `coupon_id` is null, while the code still records the raw foreign `coupon_code` at line 159. |
| | • **Regression risk flagged:** `ShippingCalculator::calculate()` left the new `$customerId` **optional**, so any caller still passing one argument silently gets **zero shipping**. The one in-repo caller was updated; external/module consumers would break silently. |
| 2026-07-18 | **WP4 ✅ `f3c7b499`** (FE, 24 files, +2494/−15) **+ `552ae7e7a`** (BE changelog). Orchestrator re-verified: nothing under `src/` or `angular.json` touched, both trees clean, both builds green, preview redeployed (`/store/login` → 200). Not pushed. |
| | **Evidence:** real UI driven with Playwright against the live API — **29 E2E + 8 client-validation assertions passing**. Token shape proven: `POST /auth/register` → 201, response keys `["data","token"]`, `data.token === undefined` (A3 confirmed). **Merge-on-login replay verified through the real UI for the first time** — guest adds 2 → registers → server cart `item_count: 2` with both lines, localStorage emptied. **No change to `cart.store.ts` was needed**; WP3's decision to react to the auth signal rather than expose a hook paid off. Login by email *and* mobile, reload persistence, logout, `guestGuard`, `returnUrl`, and **exactly one** redirect on an invalid token (no loop). 422 field routing verified with a duplicate registration (errors landed on `mobile` and `email`). 296 i18n keys at full ar/en parity; `I18N_VERSION` → `20260718f`. |
| | 🔴 **BACKEND DEFECT — password reset is broken for every real customer.** `StoreAuthService::verifyOtp` sets `used_at` **and** filters `whereNull('used_at')`, so calling `verify-code` **consumes** the code and the subsequent `reset-password` (which re-verifies) can never match. Orchestrator-verified in source (`StoreAuthService.php:196-222`). Observed: `forgot-password` → code `631273` logged → `verify-code` → `{"verified":true}` → `reset-password` same code → `{"message":"Invalid code or account not found."}`. **Only the bypass code `123456` survives both**, because `isBypassCode()` returns before the DB is touched — so this passes any QA run that uses the bypass and fails for every real user. Mitigation in WP4: the UI carries the code through to `reset-password` (the single real verification) instead of building on `verify-code`. **Proper fix is a one-line backend change** (don't re-verify, or don't consume) — needs a ticket. |
| | **Brief errors found (all verified):** (1) **the theme's login form has no branch field** — the `<select name="branch">` is in `register.html`, and `LoginRequest` accepts only `username`/`password`; a branch control on login would be inert. (2) **`verify-code` takes `identifier`/`code`/`type`, not `username`** — only `forgot-password` uses `username`, i.e. two names for the same value inside one flow. (3) 🔴 **a failed login is 401, not 422** — the brief listed only 422 shapes, and **WP0's interceptor treated every 401 as expiry, so a mistyped password logged the user out**; fixed. (4) T10 is narrower than recorded — `login.html`/`register.html` already say صيدليتي; only the three reset pages drift. (5) `company_id` *is* in `RegisterRequest::rules()` as required but is derived in `prepareForValidation()`, so a missing branch surfaces as an orphan `company_id` error with no matching control — aliased onto `branch_id`. (6) **Disk was at 77%, not ~100%** as the dispatch note claimed. |
| | **Three bugs the agent introduced and fixed; the third is instructive:** `FieldErrorComponent` never rendered **client-side** validation. A `computed()` over an `AbstractControl` never re-runs; converting to a getter didn't fix it either, because Angular hoists the `?? []` literal so the OnPush child was never marked dirty. Now derived from `control.events`. **Server-side errors worked throughout — which is exactly why the first full E2E pass went green with this broken.** It took the code review to surface it and a DOM probe to find the cause. Also added `aria-describedby`/`aria-invalid` across all five forms. |
| | **Deliberate omission:** the theme's «تذكرني» checkbox was dropped. Tokens never expire and there is no logout endpoint, so it could only ever be inert; making it real means changing WP0's `AuthState` storage contract, which deserves its own change rather than hiding behind a checkbox. |
| 2026-07-18 | **WP3 ✅ `e4cf90df`** (FE, 16 files, +1906/−56) **+ `1aef0e012`** (BE changelog). Orchestrator re-verified: nothing under `src/` or `angular.json` touched, both builds green, both trees clean, test coupon cleaned up (`store_coupons: 0`). Preview redeployed. Not pushed. |
| | 🔴 **The brief's central rule was UNSATISFIABLE and the agent said so instead of faking it.** The brief ordered "the server owns pricing/discount/coupon math — never show a client-computed total once logged in". But **the cart resource carries no discount, tax or total — only `subtotal`**, and applying a coupon sets `coupon_code` while leaving `subtotal` untouched. The only server-side discount math lives in `POST /checkout/calculate`, which the brief's out-of-scope list fenced off as WP5. **The agent used it deliberately and flagged the scope crossing** (`address_id` is nullable; `ShippingCalculator` returns 0 for null, so it yields real totals with shipping deferred). **Orchestrator's call: accepted** — the alternative was a client-computed total presented as authoritative, which is exactly what the rule existed to prevent. |
| | **Five more verified brief errors:** (1) `PATCH /cart/items/{id}` returns **405** — PUT only. (2) 🔴 **The backend does NOT block adding an out-of-stock product** — `AddCartItemRequest` validates only `exists:products,id`; posting `DEMO-P012` (`is_in_stock:false`) returned **201 with the item in the cart**. The client check is the *only* gate, so it lives in `CartStore.add()`, not per-button. (3) Coupon errors do **not** use the flat-array 422 shape — both are `{message}`-style (`404` and `422 {message, minimum_order_value}`) with **English server strings**, so the agent maps *status* → translation key rather than displaying them. (4) `DELETE /cart/items/{id}` and `DELETE /cart` return `{message}` only, **not** the cart — callers must re-read. (5) **New A9 exception:** a cart line with no variant returns `"variant": ""`; WP0's `CartItem` model omitted the field entirely. |
| | **Two places the agent improved on the brief's design:** (a) The brief said "clear local only after the server cart is confirmed" — safe against *loss* but not *duplication*, since the server's `addItem` **increments**, so a retry after a half-finished replay doubles already-landed lines. It instead removes each guest line the instant its own POST confirms, leaving exactly the un-replayed set after a crash. (b) **The sequence guard the brief mandated was the wrong tool for writes** — code review found, and the agent confirmed, that a POST issued before the merge's final GET but answering after it gets dropped as "stale" while being the fresher fact (item in the server cart, badge never shows it). Replaced with a `concatMap` queue serializing all cart writes, which also fixes absolute-quantity PUTs overtaking each other. |
| | **Evidence:** 27/27 browser checks against the live API with a real token. Merge observed: server `[[1,5]]` + local `[[1,2],[3,1],[5,3],[999999,1]]` → server `[[1,7],[3,1],[5,3]]`, local keeps only the 422 line; badge 11 = server 11. Add-during-merge: all 9 lines present. Coupon: subtotal 90 → −9 → **81** (exact 10%, server-computed). **WP1's 9px overflow FIXED** — `scrollWidth === clientWidth` at 390px on `/`, `/products`, `/categories`, `/cart`, `/product/1`; root cause isolated in a minimal repro (`invisible` = +74px, `hidden` = 0). |
| | **Declared assumption (not verified):** that the pre-fix race would have failed the new test — established by code trace and review analysis, **not** by observing a failing run. Also: an authenticated user whose totals call fails sees subtotal-as-total without the estimate notice (documented in `order-summary.component.ts`). |
| | **Test data left in place deliberately for WP4/WP5:** customer mobile `01099030301` / `Wp3Cart@12345` (company 1, branch 1), cart cleared. Coupon `WP3TEST10` was created and deleted; table verified back to 0. |
| 2026-07-18 | **WP2 ✅ `64742cfa`** (FE, 33 files, +4481/−27) **+ `284175d40`** (BE, CHANGELOG bullet only — bilingual, `{{ar}}` split verified well-formed by the orchestrator). Orchestrator re-verified: nothing under `src/` or in `angular.json` touched; both builds green. Not pushed. Preview redeployed to `/store/` — config still points at moonui4, `.htaccess` survived the `rm` glob. |
| | **Verified at runtime against the LIVE API** (15 page visits in system Chromium, no mocks): both discount paths render (`-19%` via `custom_price`, `-20%` via the percentage rule) · home 20 cards / 5 placeholders · `?category_id=4` → 5 cards = the API total exactly · `?page=2` → 4 cards (24÷20) · no-match search → empty state · `/product/99999` → not-found · out-of-stock product measured as **1 disabled / 19 enabled** buttons · `rawKeys: []` on every page · i18n 190 keys with `en-only: []` and `ar-only: []`. |
| | **RTL bug the agent caught in its own work:** its source comment claimed the theme's discount badge sits at `top-3 left-3`. The actual theme (`index.html:588`) has `right-3` = the `-19%` badge and `left-3` = a *separate* «تخفيض» label it had dropped. It had ported the badge to the wrong edge. Fixed and re-measured after rebuild. **Its own comment was the thing that misled it** — same trap that bit WP1. |
| | 🔴 **A9 CORRECTED — see the API-facts table.** "The API never returns null" is false for offer `custom_price` and for the nested `category` object (a raw Eloquent model, which also leaks `company_id` into the public payload). Later WPs must not rely on the blanket rule. |
| | **More brief/WP0 corrections (verified in backend source + live API):** (a) **WP0's `sort` param is dead code** — the controller reads `sort_by` + `sort_dir` against an allow-list and silently ignores anything else; proved with `?sort=price_asc` returning an identical unsorted page. (b) `ProductDetail extends Product` was wrong — detail has no `category_id`/`manufacturer_id`/`orders_count`. (c) Four filters were missing from WP0's params: `tag_id`, `price_from`, `price_to`, `is_available`. (d) 🔴 **`min_sale_price` is NOT a discount** — it is the pricing-tier floor, a flat 90% of `sale_price` on all 24 products; rendering it as a "was" price would have faked a `-10%` badge across the **entire catalog**. (e) `/manufacturers`, `/tags`, `/sliders`, `/ads` all return `{"data":[]}`, so the theme's brand strip and ad banners cannot be backed; the rating filter has **no API parameter at all** and was not built. |
| | **Honest gaps the agent declared (not hidden):** it did **not** run a true pixel screenshot diff against the served theme — it compared markup and computed geometry by reading the theme HTML. That method did catch the badge bug, but it is weaker than the acceptance criterion implies. Also, **zero approved reviews exist on any seeded product**, so only the reviews *empty* state was exercised; the populated list is untested. |
| | ⚠️ **Pre-existing WP1 defect, reported not silently patched:** mobile viewport has a **9px horizontal overflow** (scrollWidth 399 vs 390) on all four pages. Isolated to WP1's language dropdown (`layout/top-bar.component.html:37`) — `invisible` (`visibility:hidden`) on an `absolute` element still occupies layout. Fix: `pointer-events-none` + a `hidden`/`group-hover:block` swap, or clip the container. **Open.** |
| | WP1's `features/smoke/` is now unrouted dead code (confirmed absent from every bundle). Left in place pending a decision. |
| 2026-07-18 | **WPS ✅ `08804f477`** (BE) — `Modules/WebStore/database/seeders/DemoCatalogSeeder.php`, **one file, 481 lines, nothing else touched**. Run by hand only: `php artisan db:seed --class='Modules\WebStore\Database\Seeders\DemoCatalogSeeder'`. Seeds 5 categories (3 roots + 2 children), 24 bilingual products, 24 stock balances, 1 offer over 2 products, 12 `store.*` settings, plus GD-generated placeholder PNGs under `storage/app/public/demo/` (gitignored). Not pushed. |
| | **Orchestrator re-verified independently** (did not take the agent's word): counts before reseed `{cat:5, prod:24, offers:1}` → ran the seeder again → **identical**, so idempotency holds. Rows with `company_id != 1` = **0**. `php artisan test Modules/WebStore --compact` → **376 passed / 1158 assertions**, exactly the Phase-A baseline. Live API returns `meta.total: 24`, bilingual names differ by `Accept-Language`, one product ships an empty image. |
| | **Review caught 1 HIGH (fixed before commit):** all three models extend `BaseModel`, which applies `SoftDeletes`. A plain `updateOrCreate` resolves **through** the soft-delete scope and cannot see a trashed row, while `unique(company_id, code)` still counts it — so reseeding after anyone soft-deleted a demo row via the admin UI would have **crashed** on products/categories and **silently duplicated** the offer. Replaced with `withTrashed()->firstOrNew()` that un-trashes. The agent reproduced the failure and confirmed the fix. This is the exact class of bug the idempotency requirement existed to prevent. |
| | **Brief corrections (LEDGER/brief were wrong):** (a) there **is** a `product_categories` table — both catalog models live in **Core** (`Modules\Core\Models\Product` / `ProductCategory`), not WebStore. (b) Store settings were not "empty strings" — **the rows did not exist**; `StoreSettingsController` coerces null to `''`, which is what the earlier probe saw. (c) Undocumented dependency: `is_in_stock` derives from `withSum('stockBalances','quantity')` on `inventory_stock_balances`, so products alone are invisible as in-stock — a **warehouse row is required**. Seeder uses the company's first warehouse and fails loudly if none exists. |
| | **Scope judgement accepted:** the brief asked only for *some* products without images; the agent generated real placeholder PNGs for the rest (verified live `HTTP 200 image/png`) plus a store logo, because fake paths would have left 18 broken `<img>` tags and a blank header — a fixture that looks like a frontend bug. Reasonable reading of the brief's own "real content instead of blanks". |
| | ⚠️ **Known behaviour:** re-running resets its own rows to fixture values, so a test order's stock decrement is rewritten. Intended as a "reset the catalog" tool; documented in the class docblock. |
| 2026-07-18 | **WP1 ✅ `05744239`** — 23 files, +1847/−10, all under `projects/storefront/`. Orchestrator re-verified: `git diff 9a04a6e8 05744239` touches **nothing** in `src/` or `angular.json` (admin app provably untouched); both builds green; `I18N_VERSION` bumped to `20260718b`; the only Arabic in `.ts`/`.html` is inside two comments (checked, not taken on trust). Not pushed. |
| | **Direction bug the agent caught on itself:** it first mirrored every inline-start/end mapping. The theme is RTL-only, so its physical `right` is the inline **start** — the opposite of how the CSS reads. Search button, both badges, notification dot, three dropdowns and the toast were all flipped. Found via **screenshot diff against the served static theme** (1440px + 390px), not by eye — exactly the T2-class trap, caught by measurement. |
| | **Memoization bug, found and fixed:** caching the category tree left the menu in the previous language after a switch (the backend localizes `name` via `Accept-Language` — verified: id 1 = `أدوية (تجريبي)` / `Demo Medicines`). `CategoryState` now refetches on language change with a request-sequence token, so a slow in-flight response cannot repopulate the menu in the language the user just left. |
| | **A14 confirmed empirically:** per-category totals 7+4+4+5+4 = **24** = the whole catalog, so nothing is double-counted → the `category_id` filter is definitively non-recursive. In **this seed** every category (parent or leaf) has products attached directly, so parent links return non-empty lists — a property of the fixture, **not a guarantee**. Documented in `category.state.ts`; WP2 must not rely on it. |
| | **Brief corrections from the agent (LEDGER was wrong):** the top bar is a **sibling `<div>` at 69–106**, before `<header>` opens at 108 — not inside the 108–295 range as recorded. Drawer is **1664–1740**, not ~1662. Footer and bottom-nav ranges were exact. |
| | **Flagged, unresolved:** (a) `BRAND.NAME` in English is the agent's own transliteration `Saydaliyti` — **nobody approved it**; one i18n key, trivially changed, but OPEN-2 is still open. (b) Category icons are generic `label` — the API has no icon field while the theme hand-picked one per category; the largest remaining fidelity gap. (c) `safe-area-pb`/`search-input` are **dead classes** defined nowhere in the theme CSS — kept verbatim as no-ops rather than inventing rules that would shift layout (WP6). (d) Footer contact details left as static literals because `GET /settings` returns `""` for all of them on moonui4 — binding now would empty the footer (WP6). (e) **Prettier is configured in `package.json` but not installed**; running it churned a WP0 file (v3 defaults `trailingComma: all`, WP0 used `es5`). Agent reverted that file and matched `es5`. Worth pinning prettier as a devDependency. |
| | **Found, not acted on:** the same missing-`ignore` pattern exists in the **admin app's** own assets block — a pre-existing workspace-wide instance of the leak. Out of WP0 scope; recorded as a deferral. Also `baseHref` is not pinned in `angular.json`, so `--base-href /store/` must be passed manually every build — one forgotten flag = a silently broken deploy. |
