toga-ai 1.0.845 → 1.0.847

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@ project: Library
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-16
9
+ updated: 2026-09-18
10
10
  owners: ["jcardinal", "mhammontree"]
11
11
  files:
12
12
  - library/app/error/capture.php
@@ -116,6 +116,7 @@ Two things are specifically 1.0:
116
116
 
117
117
  ## Gotchas
118
118
 
119
+ - **Looking for a 1.0 cron fatal? Query `Logs.Issue` on environment `prod`, ordered by `dtLastOccurred`.** The legacy `Logs.Errors` and `Vision_Log.ErrorLog` tables do **not** carry 1.0 cron fatals — searching them wastes time (2026-09-18, DOE outbound-cron outage). Uncaught exceptions reach here via `App_Error::handleException` → `captureException()`, which returns the short quotable reference. **A `catch` BYPASSES capture** — if you catch per record, call `App_Error_Capture::captureException($e)` yourself, and put only the returned reference (never `$e->getMessage()`, which can carry a raw vendor response body) into an email or log.
119
120
  - **A `*/` inside PHP docblock prose silently closes the comment early.** Writing something like `list*/fetch*` in a `/** … */` block ends the docblock at the `*/`, and `php -l` then reports a confusing `syntax error, unexpected token` on a later line. Hit while documenting the NetSuite throw sites. Avoid the literal `*/` in docblock text.
120
121
  - **⚠ A grouped `Logs.Issue` row can show a STALE error id — read the live api2 transaction log for the current error.** Two facts combine to mislead: (1) `Event.errorMessage` is **`varchar(255)`**, so a long API response is **truncated** — often before the trailing error id; and (2) `Logs.Issue.subject` / `Issue.errorMessage` hold the **first-occurrence** text, so a long-lived issue keeps showing the error id from whenever it was first seen even after the per-call error has changed. Observed 2026-08-17: issue `3H` still displayed `EO-1` / `3G-6` (the original undefined-method fatal) while the current per-call failure was actually `EV-11` (an FK 1451 on a DELETE). **To see what is failing *now*, read the live api2 transaction log (`Logs_<client>.Api.responsePayload`) for the specific call — not the grouped `Issue` row.**
121
122
  - **Any PHP warning inside capture code would kill the request.** 1.0's `App_Error::handleError()` routes **every** PHP warning into `handleException()`, which renders the red box and calls `exit()` — so a single warning raised while capturing (an unreachable IMDS endpoint is the everyday case: `file_get_contents` warns before returning `false`) terminates the request or cron mid-run while reporting an unrelated error. 2.0's equivalent handler *throws*, which a `catch` absorbs. `captureException()` therefore sets `App_Error::setThrowExceptionsEnabled(false)` for its own duration and restores it in `finally`. This disables the **handler**, not real throws — `App_Query` raises a plain `throw new Exception` on DB errors, so genuine failures still reach the catch. **This is a general 1.0 trap for any code that must not escalate**, not just error capture.
@@ -6,10 +6,12 @@ project: Worker
6
6
  client: shared
7
7
  type: workflow
8
8
  status: active
9
- updated: 2026-09-16
10
- owners: ["bala", "kyalamarthi", "rgirish"]
9
+ updated: 2026-09-18
10
+ owners: ["bala", "kyalamarthi", "rgirish", "mhammontree"]
11
11
  files:
12
12
  - worker/ebs/cron.worker.php
13
+ - library/app/error.php
14
+ - library/app/error/capture.php
13
15
  - worker/schedules/cron.worker.sync.json
14
16
  - worker/schedules/cron.worker.infrastructure.json
15
17
  - worker/.ebextensions/009_setup_phpini.config
@@ -78,6 +80,16 @@ Query the legacy env's `Common.CronJobExecutions` filtering `job LIKE` the scrip
78
80
 
79
81
  **The overlap guard is a `ps -ef` substring match — anything can block it.** `App_Framework::isProcessRunning()` returns true for **any** `ps -ef` line containing the script path (it only excludes `/bin/sh` lines and its own pid). So a developer's `tail -f`, an editor, or any shell holding that path in its command line **blocks the cron indefinitely** while looking like a legitimate overlap. Check `ps -ef` for what is actually holding the name before assuming a long-running instance of the job itself.
80
82
 
83
+ ### Step 4b — a cron that DIED mid-run: the fatal is in the shared 2.0 `Logs.Issue`, not the legacy log tables
84
+ A `dtCheckIn` with **no `dtCheckOut`** (Step 4) means it crashed. Find out why here:
85
+
86
+ - 1.0 uncaught exceptions route through `App_Error::handleException` → `App_Error_Capture::captureException()`, which records the error in the **shared 2.0 `Logs.Issue`** table with a short quotable reference (e.g. `X8`) plus `errorMessage` and trace.
87
+ - Query `Logs.Issue` on **environment `prod`** (**not** `legacy`), ordered by `dtLastOccurred`.
88
+ - **The legacy `Logs.Errors` and `Vision_Log.ErrorLog` tables do NOT carry 1.0 cron fatals** — searching them wastes time. Worked example: a 2026-09-18 DOE outbound-cron outage was found this way only after both legacy tables came back empty.
89
+ - Mechanics, opt-in config and gotchas: [Error capture in 1.0](../../library/features/error-capture-1-0.md).
90
+
91
+ > Remember the notice trap: any PHP notice/warning **terminates** a 1.0 cron (see the 1.0 back-end standard), so "crashed mid-run" often means a notice, not a thrown exception.
92
+
81
93
  ### Step 5 — if runs overlap, look at what the job does before its real work
82
94
  A job whose own runtime exceeds its schedule interval silently loses most of its slots to the guard. Measure the phases, not the total: in the ODP case an instrumented run showed the S3 listing alone returning **138,472 objects and taking 32 seconds** before any PO was touched, with each PO then costing roughly 40 seconds — comfortably past a 5-minute schedule. Prefixes the job skips while processing are still listed, so accumulated `SENT/` and `OUTBOX/` objects inflate every run.
83
95
 
@@ -13,12 +13,13 @@
13
13
  | [Client Fields — per-tenant / language / role content & config](features/client-fields.md) | Per-tenant/language/role JSON config that drives almost all commerce UI text, fields, and login fetches (Layer A global + Layer B FIELDS registry); open before |
14
14
  | [Config-Driven Expedited Shipping Gating (Cart)](features/expedited-shipping-gating.md) | Expedited shipping options are config-gated to show only when the cart holds a computer kit; open when touching cart shipping-method options or the gate config. |
15
15
  | [Filter / Search-Results Page & the Two Search Entry Points](features/filter-search-results-page.md) | Two search entry points (Home in-place vs Header→/filter) render the same card via different code paths and FIELDS files; open for any search-results or kit-car |
16
- | [Header "Order for <Name>" dropdown + the pending-pick hand-off to the cart](features/header-order-for-dropdown.md) | Header pill that lets a buyer pick who they are ordering for from any page, and the in-memory store that hands that pick to the cart's order-for cascade; open b |
16
+ | [Header "Order for <Name>" dropdown + the persisted pick handed to the cart](features/header-order-for-dropdown.md) | Header pill that lets a buyer pick who they are ordering for from any page, and the persisted store that hands that pick to the cart's order-for cascade; open b |
17
17
  | [Inactive-item purchase gating (standalone lines only — kits are exempt by design)](features/inactive-item-purchase-gating.md) | `Items.isActive = 0` blocks standalone purchase but means something different on kit components, so the gate keys on purchase CONTEXT not the flag; open when to |
18
18
  | [Multi-Tenant Resolution & Theming](features/multi-tenant-theming.md) | How the hostname resolves the tenant and fans out to API base URL, theme (CSS variables), and fields; open when adding a tenant or debugging wrong-theme/wrong-A |
19
19
  | [Order-submit sync sequencing (useSubmitOrder) — why these calls must not run in parallel](features/order-submit-sync-sequencing.md) | The item sync must finish before the header sync on order submit, or concurrent line deletes race a depth-3 response into an uncaught api2 500; open when touchi |
20
20
  | [Config-Driven Shipping Cost Waiver (Standard Ground free for computer kits)](features/shipping-cost-waiver-gating.md) | Config-driven cost waiver making Standard Ground free for a bare computer kit (Compass USA); open when touching shipping cost gates or the Compass free-freight |
21
21
  | [Storefront persona gating — the Home page's kit / category / item counts, and the persona switcher](features/storefront-persona-gating-and-home-counts.md) | What a storefront user sees on the Home page is decided **in the front end**, from `user._personaIds`, not by the API's ACL. |
22
+ | [View-as another user — parked admin tokens & return-to-self](features/view-as-session-tokens.md) | How an admin impersonates another user and gets back: the delegated token pair, the parked admin pair in localStorage, and the return path. |
22
23
  | [AWS Amplify Build & Deploy (non-prod environments)](workflows/amplify-build-and-deploy.md) | How toga2-commerce builds/deploys on AWS Amplify (one dynamic amplify.yml, branch-per-env, per-env blox channel); open to deploy, stand up an env, resolve a blo |
23
24
  | [Cart e2e — Cypress conventions & harness (toga2-commerce)](workflows/cypress-testing.md) | Cypress e2e conventions + the Cart harness (cartV2.cy.ts) for toga2-commerce; read before adding any spec here, and note it doubles as the config-cart refactor |
24
25
  | [Diagnosing ERR_HTTP2_PROTOCOL_ERROR (one client fails, everyone else is fine)](workflows/http2-protocol-error-diagnosis.md) | Diagnose ERR_HTTP2_PROTOCOL_ERROR when one client/network fails a togacommerce tenant but everyone else works (usually the client's TLS-inspection proxy, not ou |
@@ -6,7 +6,7 @@ project: TOGa Commerce
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-09-17
9
+ updated: 2026-09-21
10
10
  owners: ["apeterson", "tcox"]
11
11
  files:
12
12
  - src/main.tsx
@@ -15,6 +15,7 @@ files:
15
15
  - src/contexts/AuthContext.tsx
16
16
  - src/contexts/helpers/getLoginSettings.ts
17
17
  - src/api/axiosInstance.ts
18
+ - src/api/sessionTokens.ts
18
19
  - src/stores/
19
20
  - src/themeConfig/ThemeContext.tsx
20
21
  - src/fieldsConfig/index.ts
@@ -27,6 +28,7 @@ related:
27
28
  - 2.0/apps/toga2-commerce/workflows/amplify-build-and-deploy.md
28
29
  - 2.0/apps/api2/architecture.md
29
30
  - 2.0/apps/toga2-commerce/features/filter-search-results-page.md
31
+ - 2.0/apps/toga2-commerce/features/view-as-session-tokens.md
30
32
  ---
31
33
 
32
34
  Whole-repo map of the toga2-commerce React storefront (bootstrap, routing, MVVM pages, state, API, multi-tenant resolution); open when working anywhere in this repo.
@@ -124,7 +126,7 @@ Most use the `persist` middleware (localStorage). Verified files:
124
126
  | `useEditOrderZu` | "Edit existing order" mode flag + original snapshot |
125
127
  | `useEditOrderUuidStore` | UUID of the order being edited |
126
128
  | `useSelectedUserZu` | "Order for" user selected in cart |
127
- | `useOrderForZu` | Header "order for" **pending pick** handed to the cart cascade — **in memory only, never `persist`ed** |
129
+ | `useOrderForZu` | Header "order for" pick — **persisted** (`order-for-zu`), owner-scoped: always read via `getOrderForUserFor(pick, ownerUuid)` (see header-order-for-dropdown) |
128
130
  | `useReturnOriginalUserFromViewAsStore` | "View as another user" return-to-self tracking |
129
131
  | `useEmailOptionsStore` | Notification CC email options (see cart-notification-emails) |
130
132
  | `useBundleBuilderZu` | Bundle-builder wizard selections |
@@ -146,7 +148,8 @@ Most use the `persist` middleware (localStorage). Verified files:
146
148
  - **Transaction id:** a request interceptor stamps every call with `params.transactionId = uuidv4()`.
147
149
  - **Auth token:** a second request interceptor attaches `Authorization: Bearer <token>` — using the stored `accessToken` if a `user` exists in localStorage, otherwise fetching a **public token** via `POST {baseURL}/auth/public`.
148
150
  - **1 ms delay interceptor:** a third request interceptor `await delay(1)` before sending.
149
- - **401 handling:** response interceptor attempts `POST {baseURL}/auth/refresh` (Bearer refresh token) once (`_retry`), retries the original request on success, else calls `performLogout()` (clears all auth + cart + `commerce` cache keys and redirects to `/`).
151
+ - **401 handling:** response interceptor attempts `POST {baseURL}/auth/refresh` (Bearer refresh token) once (`_retry`), retries the original request on success, else calls `performLogout()` (clears all auth + cart + `commerce` cache keys and redirects to `/`). The refresh is **single-flight** (one module-level promise) and **identity-bound** — it returns `null` if `localStorage.refreshToken` changed while in flight, so it cannot overwrite a just-minted delegated pair.
152
+ - **Token storage lives in `src/api/sessionTokens.ts`** (host from `src/api/apiBaseUrl.ts`). Besides `accessToken` / `refreshToken` it owns **`originalAuthTokens`** — the admin's own pair parked during view-as and restored on return; `/auth/encrypted` is never called to re-mint. See [view-as-session-tokens](features/view-as-session-tokens.md).
150
153
  - Failures are reported to **Sentry** with `errorType: "API"` tags.
151
154
 
152
155
  The backend (`api2`) returns the standard 2.0 envelope (`success`/`data`/`errors`; see api2 arch); generic CRUD helpers live in `src/api/genericApi.ts` (paginated `getData`, `saveData`, `updateData`, `deleteData`, optional `?depth=-1` for nested responses).
@@ -1,17 +1,16 @@
1
1
  ---
2
- title: Header "Order for <Name>" dropdown + the pending-pick hand-off to the cart
2
+ title: Header "Order for <Name>" dropdown + the persisted pick handed to the cart
3
3
  framework: "2.0"
4
4
  repo: toga2-commerce
5
5
  project: TOGa Commerce
6
6
  client: shared
7
7
  type: feature
8
- status: draft
8
+ status: active
9
9
  updated: 2026-09-17
10
10
  owners: [tcox]
11
11
  files:
12
12
  - src/components/OrderForDropdown/OrderForDropdown.tsx
13
13
  - src/components/OrderForDropdown/useOrderForDropdownViewModel.ts
14
- - src/components/OrderForDropdown/types.ts
15
14
  - src/stores/useOrderForZu.ts
16
15
  - src/components/Header/Header.tsx
17
16
  - src/pages/Cart/view/cartForm/EditCart.tsx
@@ -25,77 +24,81 @@ related:
25
24
  - 2.0/apps/toga2-commerce/features/client-fields.md
26
25
  - 2.0/apps/toga2-commerce/features/cart-notification-emails.md
27
26
  - 2.0/apps/toga2-commerce/features/cart-page-config-architecture.md
27
+ - 2.0/apps/toga2-commerce/features/view-as-session-tokens.md
28
28
  - 2.0/apps/toga2-commerce/workflows/cypress-testing.md
29
29
  ---
30
30
 
31
- Header pill that lets a buyer pick who they are ordering for from any page, and the in-memory store that hands that pick to the cart's order-for cascade; open before touching the header pill, `useOrderForZu`, or the cart `orderFor` field.
31
+ Header pill that lets a buyer pick who they are ordering for from any page, and the persisted store that hands that pick to the cart's order-for cascade; open before touching the header pill, `useOrderForZu`, or the cart `orderFor` field.
32
32
 
33
33
  ## Summary
34
34
 
35
- TRUE-81851. Before this, "order for someone else" existed **only** on the cart form. The header now renders an **"Order for <Name>"** pill (next to the logo on desktop, inside the mobile `SlideMenu`) that sets the same pick from any page. The header does **not** run the order-for cascade — it parks the pick in a new in-memory Zustand store and the **cart stays the single owner** of the cascade.
35
+ TRUE-81851 (pill) + TRUE-81852 (persistence). Before this, "order for someone else" existed **only** on the cart form. The header now renders an **"Order for <Name>"** pill (next to the logo on desktop, inside the mobile `SlideMenu`) that sets the same pick from any page. The header does **not** run the order-for cascade — it stores the pick and the **cart stays the single owner** of the cascade.
36
36
 
37
- **Status: built on branch `TRUE-81851` (cut from `_production`), staged, not committed or merged.** The Cypress spec type-checks but was never run.
37
+ **The pick is sticky.** Developer requirement: *"Order for needs to be persistent once chosen until the user changes it back to themself or logs out."* The store is persisted to localStorage and survives order submit, edit-order exit, view-as return, and page reload.
38
38
 
39
- **Per-role, per-tenant gated.** Copy and the on/off switch live in `HEADERFIELDS.json` under a new `orderFor` key, Compass USA only: `isEnabled` true for ADMIN / MANAGER / SUPERUSER, false for USER. Compass Canada and Quad have no key, so the component renders nothing (see gotchas).
39
+ **Per-role, per-tenant gated.** Copy and the on/off switch live in `HEADERFIELDS.json` under an `orderFor` key, Compass USA only: `isEnabled` true for ADMIN / MANAGER / SUPERUSER, false for USER. Compass Canada and Quad have no key, so the component renders nothing (see gotchas).
40
40
 
41
41
  ## How it works
42
42
 
43
43
  ### 1. The pill (`src/components/OrderForDropdown/`)
44
44
 
45
- MVVM split: `OrderForDropdown.tsx` (view), `useOrderForDropdownViewModel.ts` (state + queries), `types.ts`, `index.ts`. Rendered from `src/components/Header/Header.tsx`; hidden in edit-order mode.
45
+ MVVM split: `OrderForDropdown.tsx` (view), `useOrderForDropdownViewModel.ts` (state + queries), `types.ts`, `index.ts`. Rendered from `Header.tsx`; hidden in edit-order mode.
46
46
 
47
47
  - Trigger label: **"Order for Myself"** when ordering for yourself, else **"Order for <Name>"**.
48
- - Styling matches the Figma export: 18px/12px padding, radius 8, white, **no border in the base state**; a teal `#00B9A8` 1px outline + inset glow `rgba(64,203,190,0.20)` on hover, when open, and when someone other than the signed-in user is picked (`data-active="yes"`).
49
- - Icons are the **exact Figma SVG paths inlined** (24x24 user, 12.64x6.82 chevron) — not FontAwesome.
48
+ - Teal `#00B9A8` outline + inset glow on hover, when open, and when someone other than the signed-in user is picked (`data-active="yes"`). Icons are the inlined Figma SVG paths, not FontAwesome.
50
49
  - Menu: search box → yourself first as **"<Name> (Myself)"** → the user list → **Load more** past 100. Check mark on the current pick. `aria` listbox/option roles, Escape closes and returns focus to the trigger, outside click closes.
51
50
  - Copy keys under `orderFor`: `isEnabled`, `label`, `searchPlaceholder`, `myselfLabel`, `loadingMessage`, `noOptionsFoundMessage`, `loadMoreLabel` — in all four COMPASS/ENGLISH role files.
52
51
 
53
52
  ### 2. There is no "delegation" API — it is the cart's own user list
54
53
 
55
- Verified 2026-09-17 across `toga2-commerce`, `api2`, `_underscore`, `dbchanges2` and the client-sandbox + prod databases (`%elegat%`): **no delegation endpoint or table exists.** api2's `/auth/delegator` is the view-as token, unrelated.
54
+ Verified 2026-09-17 across `toga2-commerce`, `api2`, `_underscore`, `dbchanges2` and the client-sandbox + prod databases: **no delegation endpoint or table exists.** api2's `/auth/delegator` is the view-as token, unrelated (see [view-as-session-tokens](view-as-session-tokens.md)).
56
55
 
57
- The list is the same `GET /users` the cart already uses, via `getUserOptions()` / `getSearchValuesFromAdvancedInput()` in `src/pages/Cart/api/CartApi.ts`:
56
+ The list is the same `GET /users` the cart uses, via `getUserOptions()` / `getSearchValuesFromAdvancedInput()` in `src/pages/Cart/api/CartApi.ts`:
58
57
 
59
58
  - **Admins** get all active users; server-side search when `CARTPAGE settings.isSearchAnyoneEnabled`.
60
59
  - **Supervisors** get their direct reports through the `Users@supervisor` join, filtered locally.
61
- - Field lists come from the per-tenant `CARTPAGE.ts` (`getUserOptionsFields`, `getSearchValuesFromAdvancedInputFields`), read through `useAssignClientFields(fieldKey, language, user).fields.CARTPAGEFIELDS`.
60
+ - Field lists come from the per-tenant `CARTPAGE.ts`, read through `useAssignClientFields(fieldKey, language, user).fields.CARTPAGEFIELDS`.
62
61
 
63
62
  Hook queries: `useInfiniteQuery(["orderForDelegationList", user.uuid, fieldKey])` in pages of 100, and `useQuery(["orderForDelegationSearch", user.uuid, fieldKey, text])` debounced 400 ms with a 3-character minimum.
64
63
 
65
- ### 3. The hand-off — `useOrderForZu` (decided, CTO-reviewed)
64
+ ### 3. `useOrderForZu` — persisted, owner-scoped
66
65
 
67
- The cart owns the cascade: `EditCart.tsx` watches the react-hook-form field `orderFor`, fetches the full user, then sets the sales-quote contact, manager name, cost center and notification emails, and stores the full user in `useSelectedUserZu`. The header **must not duplicate that** (~100 lines) and **must not write `useSelectedUserZu` directly** — the cascade needs the *previous* value and a *full* user object.
66
+ `src/stores/useOrderForZu.ts` uses Zustand `persist` with localStorage key **`order-for-zu`**. Stored shape `{ uuid, name, email, ownerUuid }`, where `ownerUuid` is the **signed-in user's** uuid at pick time. Picking yourself is stored too, so the cart knows to reset.
68
67
 
69
- `src/stores/useOrderForZu.ts` instead holds a hand-off only:
68
+ - `getOrderForUserFor(pick, ownerUuid)` returns the pick **only** when `pick.ownerUuid === ownerUuid`. A different login, or a view-as target, never inherits it. A stale pick (ownerUuid mismatch) is cleared on cart mount.
69
+ - **Cleared only by:** picking yourself in the header · the cart's clear-user action (`CartPage.handleClearUser` → `clearOrderForUser()`) · logout (`AuthContext.logout`).
70
+ - **Kept across:** order submit (`useSubmitOrder.ts`, `createNewSalesOrderSubmit.ts`), edit-order exit (`exitEditOrderModeGlobalSyncReset.ts`), view-as return (`AuthLayout.returnToYourViewOnClick`), and full page reload. The clears that used to live in those four places were removed.
70
71
 
71
- - `pendingOrderForUser: { uuid, name, email, ownerUuid } | null`, **in memory — deliberately no `persist`.** Every flow between a pick and the cart (view-as, login, 401 refresh failure, submit) reloads the page, and the pick must not outlive that.
72
- - `ownerUuid` is the signed-in user who made the pick. `getPendingOrderForUserFor(pending, ownerUuid)` returns `null` for anyone else, so a view-as switch or a new login can never inherit someone else's pick.
73
- - `EditCart.tsx` applies the pick to `orderFor` as **"Name (email)"** — the exact label shape the cart's own input produces — then clears the store.
74
- - An `appliedHeaderPickRef` is set **before** `setValue` and checked **both before and after** the mount seed's `await fetchUserByContactUuid(...)`, so a pick made while that fetch is in flight is not overwritten by the saved contact.
75
- - Header display order: pending pick (newest) → `useSelectedUserZu.selectedUser` → self.
72
+ ### 4. Header ↔ cart sync
76
73
 
77
- **Cleared on:** logout (`src/contexts/AuthContext.tsx`), order submit (`src/pages/OrderDetails/hooks/useSubmitOrder.ts` `clearAllCartStores` and `helpers/createNewSalesOrderSubmit.ts`), cart "clear user" (`CartPage.tsx` `handleClearUser`), leaving view-as (`src/components/AuthLayout/AuthLayout.tsx` `returnToYourViewOnClick`), and leaving edit-order mode (`src/utils/exitEditOrderModeGlobalSyncReset.ts`).
74
+ The cart owns the cascade: `EditCart.tsx` watches the react-hook-form field `orderFor`, fetches the full user, then sets the sales-quote contact, manager name, cost center and notification emails, and stores the full user in `useSelectedUserZu`. The header must **not** duplicate that (~100 lines) and must **not** write `useSelectedUserZu` directly — the cascade needs the *previous* value and a *full* user object.
78
75
 
79
- Rejected: header writes `useSelectedUserZu` directly; header runs its own copy of the cascade.
80
-
81
- ### 4. Failed user load now clears the cart, not just Sentry
76
+ - `EditCart.tsx` applies the saved pick as **"Name (email)"** (the exact label shape the cart's own input produces) whenever the form's `orderFor` uuid differs from the stored pick. `appliedHeaderPickRef` is set **before** `setValue` and checked **both before and after** the mount seed's `await fetchUserByContactUuid(...)`, so the saved contact cannot overwrite a pick applied while that fetch is in flight.
77
+ - The cart's user-load cascade writes back: `onSuccess` calls `setOrderForUser({ uuid, name: _name, email, ownerUuid: user.uuid })` so header and cart never drift.
78
+ - `onError` calls `handleClearUser()` (required `EditCartTypes` prop passed from `CartPage.tsx`) and raises an error toast from the `CARTPAGE.ts` key `orderFor.settings.loadUserErrorMessage`, with the same string hardcoded as a fallback for tenants without the key.
79
+ - Header display order: stored pick → `useSelectedUserZu.selectedUser` → self.
82
80
 
83
- `singleUsersData.mutate` `onError` in `EditCart.tsx` used to report to Sentry and leave a half-applied order-for. It now calls CartPage's full `handleClearUser()` (new **required** prop `handleClearUser` on `EditCartTypes` in `src/pages/Cart/types.ts`, passed from `CartPage.tsx`) and raises an error toast. Toast copy is the new `CARTPAGE.ts` key `orderFor.settings.loadUserErrorMessage` ("We could not load that user. Please choose the user again.") in the four COMPASS/ENGLISH role files, with the same string hardcoded as a fallback for tenants without the key.
81
+ Rejected: header writes `useSelectedUserZu` directly; header runs its own copy of the cascade.
84
82
 
85
- ### 5. e2e spec (written, not run)
83
+ ### 5. e2e spec
86
84
 
87
- `cypress/e2e/homePage/orderForDropdown.cy.ts`, Compass tenant only, `compassAdminLogin` + the existing `users?recordsPerPage=**` `bulkUsers` stub. Covers: default trigger shows "Myself"; open → list → pick sets `data-active="yes"`; the pick survives a route change to `/get-support` but **not** a reload (proving the store is in-memory); admin server search stubbed empty asserts "No options found"; picking yourself turns active off; Escape returns focus to the trigger; outside click closes.
85
+ `cypress/e2e/homePage/orderForDropdown.cy.ts` — 4 tests, Compass tenant, **4/4 green**. Asserts the pick survives a route change (via the `account-icon` router Link) and `cy.reload()`, and reads `localStorage['order-for-zu'].state.orderForUser.uuid` / `.ownerUuid` directly.
88
86
 
89
87
  Test ids: `order-for-dropdown`, `order-for-trigger`, `order-for-current-name`, `order-for-menu`, `order-for-search`, `order-for-option`, `order-for-load-more` (mobile variants add `-mobile`).
90
88
 
91
89
  ## Gotchas
92
90
 
93
- - **Never `persist` `useOrderForZu`.** A persisted pick survives login, view-as and submit reloads and silently orders for the wrong person. The `ownerUuid` guard is the second line of defence, not a licence to persist.
91
+ - **The `ownerUuid` guard is what makes persistence safe — never drop it.** A persisted pick with no owner check is inherited by the next login or by a view-as target and silently orders for the wrong person. Read the store through `getOrderForUserFor(pick, ownerUuid)`, never raw.
94
92
  - **Do not set `useSelectedUserZu` from the header.** The cascade reads the *previous* selected user out of the store (see [cart-notification-emails](cart-notification-emails.md)); writing it early makes the previous-user email cleanup a no-op and strands the old person's notification emails on the order.
95
- - **A missing `orderFor` key renders nothing, by design.** `useAssignClientFields` has no fallback for a missing key, so Compass Canada and Quad get `undefined` and the component returns `null` — a safe default-off, not a bug. Adding the tenant means adding the key to every role file for that tenant.
93
+ - **A missing `orderFor` key renders nothing, by design.** `useAssignClientFields` has no fallback for a missing key, so Compass Canada and Quad get `undefined` and the component returns `null` — safe default-off. Adding a tenant means adding the key to every role file for that tenant.
96
94
  - **`appliedHeaderPickRef` must be checked after the awaited mount fetch too.** Checking only before the `await fetchUserByContactUuid(...)` lets the saved contact overwrite a pick made while the fetch was in flight.
97
- - **`_production` and `_sandbox-client` have diverged in `EditCart.tsx`** — `_production` lacks the sandbox delegate-manager code, so a stashed patch will not apply cleanly to that file. To move uncommitted work between branches (GitHub Desktop auto-stashes on branch switch as `!!GitHub_Desktop<branch>`): `git diff stash@{0}^1 stash@{0} > patch`, dry-run with `git apply --check --3way patch`, then `git apply --3way --exclude=<conflicting file> patch` and hand-port the conflicting file. Keep the stash until the work is committed.
98
- - **Tailwind: `max-h-300px` is not a token** (300px exists only on the height/width scales) — use `max-h-[300px]`. `w-345px`, `h-300px`, `border-1` and `shadow-subMenu` do exist.
95
+ - **`_production` and `_sandbox-client` have diverged in `EditCart.tsx`** — `_production` lacks the sandbox delegate-manager code, so a stashed patch will not apply cleanly to that file. To move uncommitted work between branches: `git diff stash@{0}^1 stash@{0} > patch`, dry-run with `git apply --check --3way patch`, then `git apply --3way --exclude=<conflicting file> patch` and hand-port the conflicting file. Keep the stash until the work is committed.
96
+ - **Tailwind: `max-h-300px` is not a token** — use `max-h-[300px]`. `w-345px`, `h-300px`, `border-1` and `shadow-subMenu` do exist.
97
+
98
+ ## Change history
99
+
100
+ - 2026-09-17 — Pick is now persisted to `localStorage["order-for-zu"]` with an `ownerUuid` guard (TRUE-81852); the submit / edit-order-exit / view-as-return / reload clears were removed, and the cart writes the loaded user back to the store. Spec now 4/4 green. (tcox)
101
+ - 2026-09-17 — Header order-for pill added (TRUE-81851), in-memory hand-off store, cart clears on failed user load. (tcox)
99
102
 
100
103
  ## Related
101
104
 
@@ -103,4 +106,5 @@ Test ids: `order-for-dropdown`, `order-for-trigger`, `order-for-current-name`, `
103
106
  - [client-fields](client-fields.md)
104
107
  - [cart-notification-emails](cart-notification-emails.md)
105
108
  - [cart-page-config-architecture](cart-page-config-architecture.md)
109
+ - [view-as-session-tokens](view-as-session-tokens.md)
106
110
  - [cypress-testing](../workflows/cypress-testing.md)
@@ -0,0 +1,90 @@
1
+ ---
2
+ title: View-as another user — parked admin tokens & return-to-self
3
+ framework: "2.0"
4
+ repo: toga2-commerce
5
+ project: TOGa Commerce
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-09-17
10
+ owners: [tcox]
11
+ files:
12
+ - src/api/sessionTokens.ts
13
+ - src/api/apiBaseUrl.ts
14
+ - src/api/axiosInstance.ts
15
+ - src/contexts/AuthContext.tsx
16
+ - src/components/AuthLayout/AuthLayout.tsx
17
+ - src/components/NavIcons/NavIconList.tsx
18
+ - cypress/e2e/viewAs/viewAsUser.cy.ts
19
+ related:
20
+ - 2.0/apps/toga2-commerce/architecture.md
21
+ - 2.0/apps/toga2-commerce/features/header-order-for-dropdown.md
22
+ - 2.0/apps/toga2-commerce/workflows/cypress-testing.md
23
+ - 2.0/apps/api2/architecture.md
24
+ ---
25
+
26
+ How an admin impersonates another user and gets back: the delegated token pair, the parked admin pair in localStorage, and the return path. Open before touching auth tokens, the axios refresh, or the view-as button.
27
+
28
+ ## Summary
29
+
30
+ TRUE-81852 (commit `a8c3a537`). An admin can browse the storefront **as** another user. Previously, returning to yourself re-minted the admin's tokens through the privileged `/auth/encrypted` call. Now the admin's **own** token pair is parked in localStorage when delegation starts and put back on return — `/auth/encrypted` is no longer called on return at all.
31
+
32
+ All token plumbing lives in **`src/api/sessionTokens.ts`** (new). `src/api/apiBaseUrl.ts` (new) exports `apiBaseUrl` so both the axios instance and the token helpers resolve the same host. `src/utils/auth.ts` and `handleClientAuthentication` / `UserData` in `Login/api/LoginApi.tsx` were dead and were deleted.
33
+
34
+ ## How it works
35
+
36
+ ### Entering view-as (`NavIconList.tsx` onSuccess)
37
+
38
+ 1. `fetchUserTokens(uuid, "/auth/delegator")` mints the delegated pair for the target user.
39
+ 2. `beginDelegation(delegatedPair, adminUuid)` parks the admin's current pair under localStorage key **`originalAuthTokens`** as `{ v: 1, access, refresh, userUuid, apiHost, savedAt }`, then writes the delegated pair into `accessToken` / `refreshToken`.
40
+ 3. It **never overwrites an existing parked pair** — view-as is one level deep. `isViewAsEnabled` also hides the button when `hasSavedOriginalTokens()` is true.
41
+ 4. On any error entering view-as, it rolls back: `restoreOriginalTokens` if a pair is parked, plus `clearOriginalUser()`.
42
+
43
+ ### Returning (`AuthLayout.returnToYourViewOnClick`)
44
+
45
+ `restoreOriginalTokens(adminUuid)`:
46
+
47
+ 1. Checks `userUuid` **and** `apiHost` match, and age ≤ `PARKED_PAIR_MAX_AGE_MS` (**12h**). It **always removes** the parked key, pass or fail.
48
+ 2. On success: puts the admin pair back; if the access JWT is expired (`isAccessTokenExpired` decodes `exp`), calls `refreshAuthTokens()` once **before** the cache clear.
49
+ 3. On failure: logs a Sentry warning, clears the view-as store, logs out, navigates to `/`. There is **no** `/auth/encrypted` fallback.
50
+ 4. Removes `localStorage["commerce"]` (the persisted react-query cache) so the impersonated user's rows cannot rehydrate under the admin.
51
+
52
+ ### Where else the parked pair is cleared
53
+
54
+ `login()` clears it (and the view-as store) first; `fetchPublicToken` and `performLogout` clear it; module load sweeps it when there is no `accessToken`.
55
+
56
+ ### Refresh safety (`axiosInstance.ts`)
57
+
58
+ The 401 refresh is **single-flight** (one module-level promise shared by concurrent 401s) and **identity-bound**: `requestRefreshedTokens` returns `null` if `localStorage.refreshToken` changed while the refresh was in flight. That stops an in-flight admin refresh from overwriting a just-minted delegated pair (or the reverse).
59
+
60
+ ### Decisions (CSO review — SHIP WITH FIXES)
61
+
62
+ Identity-bound refresh · one-level view-as · 12h max parked age · no `/auth/encrypted` fallback (log out instead) · never put error objects in `JSON.parse` failure logs · match on `apiHost` only (clientId dropped).
63
+
64
+ ### e2e (`cypress/e2e/viewAs/viewAsUser.cy.ts`)
65
+
66
+ Seeds a long-lived `ADMIN_ACCESS_JWT`; stubs `/auth/delegator` → `mock-delegated-access-token` / `-refresh`, and `**/v2/pages/meta?page=home-page&app=commerce*`; the fetchUser stub matches on a RegExp over the `?fields=uuid,_name,...` list. Asserts: delegated pair active + parked pair present; after return, `accessToken === ADMIN_ACCESS_JWT`, parked key null, and **zero** calls to `/auth/encrypted`. 1/1 green.
67
+
68
+ ## Gotchas
69
+
70
+ - **Never re-mint the admin's tokens on return.** The old `/auth/encrypted` path was a privileged call made from the browser. Restore the parked pair, or log out — those are the only two outcomes.
71
+ - **Always remove `originalAuthTokens`, even on a failed restore.** Leaving a stale parked pair blocks the next view-as (`beginDelegation` refuses to overwrite) and keeps an old admin refresh token on disk.
72
+ - **Clear `localStorage["commerce"]` on return.** The persisted query cache is shared; without the clear, the impersonated user's data rehydrates under the admin identity (see the 24h persisted-cache gotcha in [architecture](../architecture.md)).
73
+ - **Keep the refresh identity-bound.** A refresh that resolves after the identity changed must return `null`, not write tokens.
74
+ - **A view-as test must stub `/v2/pages/meta?page=home-page&app=commerce`.** Unstubbed, the real call fails and triggers the enter-view-as rollback, so the spec sees a return-to-admin it did not ask for.
75
+
76
+ ## Open items (out of ticket, not fixed)
77
+
78
+ - `SENTRY_AUTH_TOKEN` is committed in 25 tracked `.env*` files — treat it as compromised and rotate it. (Never store the value in this KB.)
79
+ - Confirm api2 authorizes `/auth/delegator` **server-side** as admin-only; the client-side gate is not a control.
80
+
81
+ ## Change history
82
+
83
+ - 2026-09-17 — Created. Admin pair parked in `originalAuthTokens` on delegation and restored on return; `/auth/encrypted` re-mint removed; single-flight identity-bound refresh (TRUE-81852, commit a8c3a537). (tcox)
84
+
85
+ ## Related
86
+
87
+ - [architecture](../architecture.md)
88
+ - [header-order-for-dropdown](header-order-for-dropdown.md)
89
+ - [cypress-testing](../workflows/cypress-testing.md)
90
+ - [api2 architecture](../../api2/architecture.md)
@@ -11,12 +11,12 @@ owners: [tcox]
11
11
  files:
12
12
  - toga2-commerce/cypress/e2e/cartPage/cartV2.cy.ts
13
13
  - toga2-commerce/cypress/e2e/homePage/orderForDropdown.cy.ts
14
+ - toga2-commerce/cypress/e2e/viewAs/viewAsUser.cy.ts
14
15
  - toga2-commerce/cypress/fixtures/cart/fetchUserShippingMethods.json
15
16
  - toga2-commerce/cypress/support/commands.ts
16
17
  - toga2-commerce/cypress/support/e2e.ts
17
18
  - toga2-commerce/src/pages/Cart/CartPage.tsx
18
19
  - toga2-commerce/src/pages/Cart/view/cartForm/CartForm.tsx
19
- - toga2-commerce/src/pages/Cart/view/cartForm/CartFormSection.tsx
20
20
  - toga2-commerce/src/pages/Cart/view/cartTable/CartContentsTable.tsx
21
21
  - toga2-commerce/src/pages/Cart/view/cartTable/CartTableItem.tsx
22
22
  - toga2-commerce/src/components/Inputs/AdvancedInput.tsx
@@ -27,6 +27,7 @@ related:
27
27
  - 2.0/apps/toga2-commerce/features/expedited-shipping-gating.md
28
28
  - 2.0/apps/toga2-commerce/features/cart-bundle-submission-and-identity.md
29
29
  - 2.0/apps/toga2-commerce/features/header-order-for-dropdown.md
30
+ - 2.0/apps/toga2-commerce/features/view-as-session-tokens.md
30
31
  - 2.0/apps/toga25-supply/workflows/cypress-testing.md
31
32
  ---
32
33
 
@@ -100,7 +101,12 @@ The cart UI was instrumented with `data-testid` attributes so specs anchor on **
100
101
 
101
102
  ### Other active specs
102
103
 
103
- - `cypress/e2e/homePage/orderForDropdown.cy.ts` (2026-09-17, branch `TRUE-81851`) — the header order-for pill; COMPASS only, `compassAdminLogin` + the existing `users?recordsPerPage=**` `bulkUsers` stub. **Written and type-checked but never executed** — run it before trusting it. Ids `order-for-*` (see [header-order-for-dropdown](../features/header-order-for-dropdown.md)).
104
+ - `cypress/e2e/homePage/orderForDropdown.cy.ts` — the header order-for pill, COMPASS only, 4 tests, **4/4 green**. Asserts the pick survives a route change and `cy.reload()` by reading `localStorage['order-for-zu']`. Ids `order-for-*` (see [header-order-for-dropdown](../features/header-order-for-dropdown.md)).
105
+ - `cypress/e2e/viewAs/viewAsUser.cy.ts` — admin view-as enter/return, **1/1 green**. Seeds a long-lived admin JWT, stubs `/auth/delegator` and `**/v2/pages/meta?page=home-page&app=commerce*` (see [view-as-session-tokens](../features/view-as-session-tokens.md)).
106
+
107
+ ### Known-red specs (pre-existing, not yours)
108
+
109
+ On prod base `aedd1f7a`, Compass tenant: **13 failures** — `getSupportV2` (4), `homePageContentV2` (7), `firstTimeLoggingIn` (2). Reproduced identically on an untouched `git archive aedd1f7a` copy, so they are **not** caused by feature work. Likely cause: the login command's `/users/{uuid}` stub field list no longer matches the app's request, so the call falls through to the real API and 401s. Full Compass suite on the TRUE-81852 feature branch: **54 pass / 13 fail** — the same 13.
104
110
 
105
111
  **Not yet covered — slice 2 work-list:**
106
112
  - **Bundle scenarios** — blocked on the stale bundle seeds (rebuild `seedCartBundle` / `seedSalesQuoteBundle` first, above).
@@ -114,13 +120,24 @@ The cart UI was instrumented with `data-testid` attributes so specs anchor on **
114
120
  2. Start the **dev server separately** first: `npm run compass` | `npm run compasscanada` | `npm run quad`.
115
121
  3. **Windows invocation:** `$env:TENANT="compass"; npm run cypress` — `cross-env` is **not** installed, so set the env var in the shell.
116
122
  4. Requires **hosts-file entries** `127.0.0.1 compass|compasscanada|quad.togacommerce` (present on this machine but **undocumented in the README** — a new dev must add them).
123
+ 5. Headless run (works despite there being no npm script): `Remove-Item Env:ELECTRON_RUN_AS_NODE` first (see gotcha), then `$env:TENANT="compass"; npx cypress run --env TENANT=compass` with vite dev serving `http://compass.togacommerce:5173`.
117
124
 
118
125
  ## Gotchas
119
126
 
127
+ - **`ELECTRON_RUN_AS_NODE=1` breaks every spec in a VS Code terminal.** VS Code's integrated terminals set it, and `npx cypress run` then fails verification on every spec with `Cypress.exe ... bad option: --smoke-test`. Fix: `Remove-Item Env:ELECTRON_RUN_AS_NODE` in that shell before running. Reinstalling the Cypress binary does **not** help.
128
+ - **The users API URL-encodes hyphens as `%2D`.** A glob like `**/v2/users?*no-such*` never matches — use a hyphen-free search term in the test, or match with a RegExp.
129
+ - **"Get Support" in the header is a plain `<a>`** (full page reload). To test that state survives a *route change*, click `account-icon` — that one is a router `Link`.
130
+ - **The `EnvironmentBadge` covers the header corner.** For outside-click tests, click `header-search-input` instead of the corner.
131
+ - **A later `cy.intercept` wins over an earlier one for the same URL** — define the narrow override after the general stub, not before.
120
132
  - **`BaseButton` never sets the native `disabled` attribute (test-critical).** `src/components/BaseButton/BaseButton.tsx` sets `aria-disabled="true"|"false"` and suppresses `onClick` inside `handleClick` — it deliberately leaves the element enabled so a disabled button (e.g. **Proceed to Checkout**) still receives mouse events and its `BaseTooltip` (*"Please complete all required fields to proceed."*) works. Cypress assertions **MUST** use `.should('have.attr', 'aria-disabled', 'true'|'false')`, **never** `.should('be.disabled')` — `be.disabled` times out even though the button is visually/functionally disabled. (This cost the only 2 first-run failures.)
121
133
  - **Seed → then `cy.visit('/cart')`.** The persist stores rehydrate on load only; seeding `localStorage` and navigating in-app leaves the stores stale (see seeding section).
122
134
  - **Bundle seeds are on the legacy shape** — do not write a bundle spec against them until they are rebuilt to the `bundleZuCartUuid` + `bundleProgressContents` contract.
123
- - **ESLint fails repo-wide in a fresh checkout** because `.eslintrc.cjs` references `eslint-plugin-react-compiler`, which is not installed; `npm install --save-dev eslint-plugin-react-compiler` fixes it. To lint without touching the checkout, run `eslint --no-eslintrc` with the `react-hooks` and `no-unused-vars` rules instead. This is a local-environment issue, not a spec problem.
135
+ - **ESLint fails repo-wide in a fresh checkout** because `.eslintrc.cjs` references `eslint-plugin-react-compiler`, which is not installed; `npm install --save-dev eslint-plugin-react-compiler` fixes it. To lint without touching the checkout, run `npx eslint --no-eslintrc --plugin react-hooks --plugin @typescript-eslint <files>`. This is a local-environment issue, not a spec problem.
136
+
137
+ ## Change history
138
+
139
+ - 2026-09-17 — Added `viewAsUser.cy.ts`; orderForDropdown now green; recorded the `ELECTRON_RUN_AS_NODE` failure, the intercept pitfalls, and the 13 pre-existing red specs on base `aedd1f7a`. (tcox)
140
+ - 2026-09-17 — Created with the V2 convention set and the `cartV2.cy.ts` cart harness (slice 1). (tcox)
124
141
 
125
142
  ## Related
126
143
 
@@ -128,4 +145,6 @@ The cart UI was instrumented with `data-testid` attributes so specs anchor on **
128
145
  - [cart-notification-emails](../features/cart-notification-emails.md)
129
146
  - [expedited-shipping-gating](../features/expedited-shipping-gating.md)
130
147
  - [cart-bundle-submission-and-identity](../features/cart-bundle-submission-and-identity.md)
148
+ - [header-order-for-dropdown](../features/header-order-for-dropdown.md)
149
+ - [view-as-session-tokens](../features/view-as-session-tokens.md)
131
150
  - [toga25-supply Cypress harness](../../toga25-supply/workflows/cypress-testing.md)
@@ -6,11 +6,12 @@ project: TOGa View Frontend
6
6
  client: rate
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-16
10
- owners: ["bala"]
9
+ updated: 2026-09-18
10
+ owners: ["bala", "mhammontree"]
11
11
  files:
12
12
  - toga2-view/src/components/MobileNav/MobileNav.tsx
13
13
  - toga2-view/public/assets/Rate_Logo.svg
14
+ - toga2-view/src/context/AuthContext.tsx
14
15
  related:
15
16
  - clients/rate/profile.md
16
17
  - 2.0/apps/toga2-view/features/service-card.md
@@ -47,6 +48,8 @@ Files:
47
48
 
48
49
  - **Use `Rate_Logo.svg`, not `rateDark1.png` in the header.** The PNG has a 1.625:1 aspect ratio; at `h-[24px]` it renders only 39 px wide. The Figma Logos component is 58.5 × 24 px — only the SVG export matches.
49
50
  - **`EnvironmentBadge` is intentionally kept** in `App.tsx` — a dev/QA tool, must not be removed.
51
+ - **⚠ OPEN DEFECT — Logout does nothing, and never has (found 2026-09-18, TRUE-82118, NOT fixed).** `MobileNav.tsx:29` sets the Logout nav item's action to `() => console.log("Logging out...")` — a stub. The button (~line 162) calls `item.action?.()` then closes the drawer, so it logs one line and leaves the user signed in. **Two working implementations exist and NEITHER is called anywhere:** `AuthContext.tsx:84` `logout()` (clears accessToken/refreshToken/user, resets state) and `src/utils/auth.ts` `logout()` (clears tokens, redirects to `/login`) — that whole file is imported by nothing.
52
+ - **Testers: clearing local tokens ends OUR session, not the SAML IdP's.** The next load can silently sign the same user straight back in. To come back as a different test user, use a private window or a separate browser profile.
50
53
  - **Page content top offset:** pages behind the fixed header need `mt-[72px]` to avoid overlap. The `allowedPaths` list in `MobileNavToggle` controls which routes get the nav shell.
51
54
 
52
55
  ## Related
@@ -31,7 +31,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
31
31
  - **talos-backend** (TOGa IQ Backend) — 1 doc(s) → [2.0/apps/talos-backend/INDEX.md](2.0/apps/talos-backend/INDEX.md)
32
32
  - **voice-to-voice** (TOGa Voice) — 4 doc(s) → [2.0/apps/voice-to-voice/INDEX.md](2.0/apps/voice-to-voice/INDEX.md)
33
33
  - **ai-bdr** (AI-BDR) — 13 doc(s) → [2.0/apps/ai-bdr/INDEX.md](2.0/apps/ai-bdr/INDEX.md)
34
- - **toga2-commerce** (TOGa Commerce) — 23 doc(s) → [2.0/apps/toga2-commerce/INDEX.md](2.0/apps/toga2-commerce/INDEX.md)
34
+ - **toga2-commerce** (TOGa Commerce) — 24 doc(s) → [2.0/apps/toga2-commerce/INDEX.md](2.0/apps/toga2-commerce/INDEX.md)
35
35
  - **toga25-supply** (TOGa 2.5 Supply) — 21 doc(s) → [2.0/apps/toga25-supply/INDEX.md](2.0/apps/toga25-supply/INDEX.md)
36
36
  - **toga-blox** (TOGa Blox) — 14 doc(s) → [2.0/apps/toga-blox/INDEX.md](2.0/apps/toga-blox/INDEX.md)
37
37
  - **bdr** (BDR) — 0 doc(s) → [2.0/apps/bdr/INDEX.md](2.0/apps/bdr/INDEX.md)
@@ -90,6 +90,30 @@ Current selection query:
90
90
  - The standalone assignment-group block was **deleted** — the main payload already diffs `assignment_group`, so it was a duplicate GET, a second code path, and the source of the lockout stamp.
91
91
  - Debug pin already in the file: the commented `#AND NYCDOETickets.ticketNumber = '...'` line scopes a pass to one ticket.
92
92
 
93
+ ### Batch loop, run caps & per-ticket isolation (TRUE-82129 current shape)
94
+ Before this the cron ran the selection query **once per ticket under `LIMIT 1`** with a `sleep(1)` after every ticket — one ticket per 6-minute run, ~10 days for a full pass over the queue.
95
+ - **One `LIMIT 250` (`BATCH_SIZE`) read into a PHP array, consumed with `array_shift`, refilled only when empty.** The query is `type=ALL`, ~35,021 rows, `Using temporary; Using filesort` (it drives off `managed_service_orders`, so `Common.NYCDOETickets.idx_dtLastChecked` is unusable at this join order). A filesort already sorts every surviving row before returning the top N, **so a bigger LIMIT costs nothing extra**.
96
+ - **`BATCH_SIZE` throttles NOTHING** — it caps rows per SELECT only, because the loop refills. Lowering it is **not** a rate-limit control; a reviewer proposed exactly that and it would not have worked. The rate cap is **`MAX_TICKETS_PER_RUN` (300)**.
97
+ - **The per-ticket `sleep(1)` was removed** — it burned ~70% of the 340s run window against a 0.35s API call. **The `sleep(1)` after the Assigned→In Progress PATCH was KEPT** — it waits for ServiceNow to apply the change before the re-read. Do not delete that one.
98
+ - **Per-ticket `try/catch (Throwable)`** so one bad ticket cannot strand the rest, plus **`MAX_CONSECUTIVE_FAILURES` (10)**: an unbroken failure run stops the loop and **throws after the alert emails**, so a systemic break still ends as a visibly failed run.
99
+ - Measured in production 2026-09-18: **300 tickets in 171s (0.57s/ticket), zero non-200**; eligible queue 2,406 → 1,845 over two runs.
100
+
101
+ > **DECISION (Mark, 2026-09-18) — narrow, reasoned exception to `1.0/standards/backend-php.md` § *Fail-loud is the default for import/sync/cron code*.** That rule exists because a watermark can advance PAST a lost record. Here `dtSynced` advances **only after a successful PATCH**, so a thrown ticket advances nothing, loses nothing, and is retried next run; `MAX_CONSECUTIVE_FAILURES` preserves the fail-loud guarantee. **Do not re-propose removing the per-ticket catch without also removing this reasoning.**
102
+
103
+ > **FOLLOW-UP (proposed, NOT done — own ticket):** rewrite the selection to drive off `Common.NYCDOETickets` (subquery on that table alone, then join and re-filter) so `idx_dtLastChecked` is usable. Needs an over-fetch factor sized from real data. `sql-reviewer` recommended keeping it out of TRUE-82129.
104
+
105
+ ### Alert-email safety (TRUE-82129, from cso review)
106
+ - **Never put `$e->getMessage()` into an email or a log on this cron.** Exceptions from `App_Api_NYCDOEV2` / `App_ApiTransaction` embed the **raw ServiceNow response body** — DOE requester PII, and on an auth failure the body of a request carrying the OAuth secret.
107
+ - The catch calls **`App_Error_Capture::captureException($e)` explicitly** (catching normally bypasses that capture) and the email carries **only the issue reference**. See [Error capture in 1.0](../../../1.0/apps/library/features/error-capture-1-0.md).
108
+ - `htmlspecialchars` on every interpolated value in both alert emails; failure list capped at `MAX_FAILURES_LISTED` (25) with the exact count in the subject.
109
+ - The unmapped-assignment-group email now groups **by assignment group** with an exact count and up to `MAX_TICKETS_LISTED_PER_GROUP` (10) ticket numbers each.
110
+
111
+ ### No output from this cron (team rule)
112
+ All three `error_log()` calls were removed (the per-ticket technician sys_id message, TRUE-82027's "alerted on N failures" line, and one added in TRUE-82129). **1.0 worker crons must produce no output** — they run unattended under CRON and `error_log()` goes to stderr on CLI. Failures surface only in `Logs.Issue` and the throttled alert email. General 1.0 worker rule, not DOE-specific (Mark, 2026-09-18).
113
+
114
+ ### Missing technician `referenceId` is the NORMAL state, not an incident
115
+ **265 of 389 (68%) TOGaDesk technicians have no `people.referenceId`** (ServiceNow sys_id), so `u_technician` is left **unchanged** on those pushes — deliberate: blanking it would wipe a technician ServiceNow may legitimately hold. State, hold, ETA and assignment_group still sync. This is why the per-ticket log line was removed. Reconcile with `worker/crons/sync/nycdoe/report_technician_identities.php` / `backfill_technician_identities.php` (needs its own ticket).
116
+
93
117
  ### Failure alerting instead of a throw (TRUE-82027)
94
118
  **DECISION (Mark, 2026-09-17): no circuit breaker** — 1.0 is transitioning to 2.0 under a separate task, so the cheap path was chosen.
95
119
  - A failed ServiceNow PATCH **no longer throws**; it records the incident and the run continues, then all failures are emailed **once per run** to sking@togatech.com / mhammontree@togatech.com.
@@ -155,12 +179,20 @@ The SNOW round-trip note reconciliation (delete-and-reinsert in `process_tickets
155
179
  - **A merged PR is not a deployed change — TRUE-81044 is merged to `_production` but NOT deployed** (merge commit worker `2f4dcfe9e`). Production payloads on 2026-09-15 still write `u_status_task` and still never send `u_other_reason_on_hold` (**0 occurrences across 9,914 PATCHes since 2026-08-20**). Read the live payload in `Logs.API` before concluding a fix is live.
156
180
  - Holds are **never auto-released** — if a SNOW user manually changes status, the outbound cron correctly re-asserts On Hold; that is intended, not a bug. Clear the hold in TOGaDesk.
157
181
  - **Diagnosing which writer dropped a hold:** `repair_order_history` (`repairid, userid, dtStamp, note`) records *user-driven* status changes. A hold reverting with **no** history row between the hold and the revert = a raw-`UPDATE` writer (`updateStatus`/`qqStatus`), **not** the worker inbound cron (`process_tickets.php` contains no reference to `ORDER_ASSIGNED_AWAITING_SCHEDULING`). Note that **since TRUE-80060** a *status-changing* `class.repair.php::addNotes` now **calls** `updateStatus()` (so it too can move the cache via that raw `UPDATE`); a **pure comment** still writes nothing to `repair_orders.status`. This history-gap technique is how TRUE-80060 was pinned to `qqStatus()` rather than the SNOW round-trip.
158
- - **KNOWN REMAINING GAP (needs a follow-up ticket) — the invalid-assignment-group email reports only the LAST ticket of a run.** `$invalidAssignmentGroups` is declared **inside** the outer `do/while`, so it resets every ticket; the email body now says so explicitly. Agreed fix: declare it before the loop (as `$failedPatches` already is) **and** group output by assignment group with a capped ticket list — one unmapped group can cover 60+ tickets. Rare in practice; the default group is TOGA's own, historically named "ASI Systems".
182
+ - **⚠ OUTAGE (TRUE-82129, 2026-09-18) — `send_ticket_updates.php` died on its FIRST ticket of EVERY run, for a whole day.** `App_Database::fetchOne()` (like `fetchRow()` / `numRows()`) takes its result **by reference** (`&$res`, `library/app/database.php` L336/L352/L362). TRUE-82027 (commit `b63fb0136`) passed `App_Database::query(...)` into it **inline**, which raises the notice *"Only variables should be passed by reference"* — and 1.0's error handler **terminates the run on a notice**. Live from the 2026-09-18 10:18 deploy until fixed. **Fix:** assign the `query()` result to a variable first. Symptom set worth recognizing: zero ServiceNow calls after the deploy, `Common.CronJobExecutions.dtCheckOut` NULL on every run, and exactly **one** `NYCDOETickets.dtLastChecked` stamp per 6-minute run. General 1.0 trap — see the 1.0 back-end standard.
183
+ - **⚠ "The outbound cron re-asserts On Hold every run" (business rule 2) was only true in theory between 2026-09-18 10:18 and the TRUE-82129 fix** — the cron was dying on its first ticket. Check the cron actually completes before trusting that guarantee.
184
+ - **OPEN QUESTION — `dtSynced` is not a reliable outbound-only signal.** The 12 reported incidents (INC2298009, INC2298110, INC2297667, INC2299269-77) had `Common.NYCDOETickets.dtSynced` advance from ~2026-09-17/18 05:00–09:46 to ~08:57–09:00 on 2026-09-18 **while the outbound cron was not running**. Suspected `process_tickets.php` (inbound also writes `dtSynced`) but **not confirmed**. Confirm before reading `dtSynced` as proof of an outbound push.
185
+ - **⚠ SECURITY — hardcoded live ServiceNow credentials in `library/app/api/nycdoev2.php` / `nycdoe.php`.** Found 2026-09-18, not fixed; detail in [ServiceNow / ASN Integration](servicenow-integration.md).
159
186
  - **KNOWN REMAINING GAP (needs a follow-up ticket):** both `qqStatus()` hold gates live inside the `type IN (ONSITE_REPAIR, ONSITE_SERVICE)` branch. The `DEPOT_REPAIR` branch (`repairorder.php` from ~L799) has **no hold gate at all**, so a `DEPOT_REPAIR` order set to any hold status is still silently recomputed out of hold by `updateStatus()` — same class of bug, untouched by TRUE-80060.
160
187
  - **KNOWN REMAINING GAP (needs a follow-up ticket) — inbound note reconciliation is paged too small.** `process_tickets.php`'s inbound comment reconciliation fetches only `page_size=50, page_number=1` of the SNOW journal, then **deletes any local note not in that set** (the remainder-delete ~L1326–1330). An order with **>50** comments/work_notes can have local notes silently deleted. It is also inconsistent with `addNotes`, which fetches `page_size=100`.
161
188
  - **KNOWN REMAINING GAP — `buildDoubleFieldArray` collapses unsynced notes.** In `process_tickets`, `buildDoubleFieldArray` keys on `IFNULL(referenceId,0)`, so **all** NULL-`referenceId` notes collapse to key `'0'`, making the remainder-delete handle multiple unsynced notes inconsistently.
162
189
  - `qqStatus()` collapses all recognized hold sub-statuses to base `HOLD` in `repair_orders.status`; the specific hold **sub-reason persists only in `repair_order_notes.newstatus`**. Any status badge / report / filter reading `repair_orders.status` directly cannot distinguish the six hold variants after a recompute.
163
190
 
191
+ ## Change history
192
+ - 2026-09-18 — TRUE-82129: fixed the by-reference `fetchOne()` notice that killed every run; batch loop + run caps replaced LIMIT 1 + per-ticket sleep; alert emails de-PII'd and escaped; unmapped-group email now grouped per run. (mhammontree)
193
+ - 2026-09-17 — TRUE-82027: outbound push runs once per order per pass; `dtLastChecked` split from `dtSynced`; failure alerting instead of a throw. (mhammontree)
194
+ - 2026-08-20 — TRUE-81044: `state` + `hold_reason` + `u_eta` + `u_other_reason_on_hold` pushed together; stopped writing `u_status_task`. (mhammontree)
195
+
164
196
  ## Related
165
197
  - [NYCDOE ServiceNow / ASN Integration](servicenow-integration.md) — full integration map, crons, schedules, and `App_Api_NYCDOEV2` plumbing.
166
198
  - [NYC DOE client profile](../profile.md)
@@ -156,6 +156,8 @@ This probe is the client's **only 2.0 footprint**. DOE is **1.0 by default** —
156
156
 
157
157
  ## Gotchas
158
158
 
159
+ - **⚠ SECURITY (cso review 2026-09-18 — NOT fixed, needs its own ticket): live production ServiceNow credentials are hardcoded in committed PHP.** `library/app/api/nycdoev2.php` and `library/app/api/nycdoe.php` hold the ServiceNow **`clientSecret` and `refreshToken` as literals** (stage values too). Treat as compromised: move them to config/env and **rotate with DOE**. Separately, `Vision_Log.ErrorLog` rows dump `$_SERVER` including live AWS access key id / secret in plain text — its own ticket, same rotation treatment. **No credential values are recorded here or anywhere in the KB.**
160
+
159
161
  - **⚠ OPEN BUG / NAMED PATTERN — "Lenovo Off-Layout ASN File" (silent column shift + unit collapse).** Investigated 2026-08-11 (read-only; **no code fix exists**). Lenovo sends NYCDOE ASNs in **two different column layouts**, and `library/app/asnprocessor/lenovo.php` picks its column mapping by **column COUNT with no header-name validation** (`:110`, `if ($colCount >= 30`), so the off-layout file is parsed with the wrong mapping and silently mis-read.
160
162
  - **The two layouts.** The normal daily feed `DOE-Lenovo-ASN-MM-DD-YYYY.CSV` (~2 MB) has **30** columns and matches the code's mapping. A second file, `DOE-Open-Lenovo-MM-DD-YYYY.csv` (**lowercase** `.csv`, ~45 KB), has **31** columns in a **different order**: `Contact Email` moves from column 29 to column **6**, and a trailing `Full Shipment` column is appended. Everything from `Street Address` onward is therefore read **one column too early**. The "extra comma" repair heuristic at `:119-127` does not catch this (unchanged since March 2026 — commits `3442ddcf`, `6956dfbf`, `864cf073`, so not a regression).
161
163
  - **The four wrong values** (`lenovo.php:129-156`): `serialNumber` ← `$cols[22]` lands on **System Quantity**, a constant `"1"` on every row (the real serial ends up in `assetTag`); `orderQuantity` ← `$cols[21]` lands on a blank `Quantity Ordered`; `partNumber` ← `$cols[17]` lands on the long **Product Description** instead of the OEM SKU.
@@ -7,7 +7,7 @@
7
7
  | [Rate home-warranty Terms & Conditions content — verbatim transcript policy](features/home-warranty-terms-content.md) | 2.0 | Why `TERMS_HW.ts` is a byte-verbatim transcript of Rate's legal form and must never be hand-edited; open before touching the home-warranty T&C content or regene |
8
8
  | [Rate Monthly Reconciliation Report](features/monthly-reconciliation-report.md) | 1.0 | Monthly cron that emails an Excel reconciliation of Rate subscription orders + PayPal payments; open when changing the report columns, filters, or recipients. |
9
9
  | [Rate SalesOrder → NetSuite CashSale Export (postPost)](features/netsuite-cashsale-export.md) | 2.0 | How a Rate SalesOrder postPost writes a NetSuite CashSale (ship-to address + line-item internalId resolution); open when touching Rate's NetSuite export. |
10
- | [Rate PayPal Subscription Purchase & Webhook Pipeline](features/paypal-subscription-purchase-webhook.md) | 2.0 | How Rate PayPal subscription purchases are created (browser-only) and how the worker2 webhook handles lifecycle events; open before touching Rate purchase/webho |
10
+ | [Rate PayPal Subscription Purchase & Webhook Pipeline](features/paypal-subscription-purchase-webhook.md) | 2.0 | How Rate PayPal subscription purchases are created (browser primary, webhook fallback) and how the worker2 webhook handles lifecycle events; open before touchin |
11
11
  | [Rate SAML SSO](features/saml-sso.md) | 2.0 | How Rate users sign in via Azure AD SAML through the TOGa gateway, plus assertion attributes and the loan-officer upsert; open when debugging Rate login or user |
12
12
  | [Service Card Entitlement Display](features/service-card-entitlements.md) | 2.0 | How Rate's home/services pages render one service card per purchased entitlement (per-entitlement, own pinned address for warranty); open when touching service |
13
13
  | [Rate Service-Purchase Confirmation Emails (Tech / Warranty)](features/service-purchase-emails.md) | 2.0 | How Rate purchase-confirmation emails (tech vs warranty) are triggered from the entitlement postPost and sent off-thread from DB-stored templates; open when edi |
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: rate
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-09-16
9
+ updated: 2026-09-18
10
10
  owners: [mhammontree, tcox, rgirish]
11
11
  files:
12
12
  - _underscore/Model/Rate/Entitlement.php
@@ -150,6 +150,11 @@ Because the interceptor swallows failures, detection rides the **committed outbo
150
150
  - **Ordering: TRUE-81049 must land before/with TRUE-80575.** TRUE-80575's worker2 handler re-reads the entitlement and alerts when `c_aigContractId` is empty — until identifiers persist, that check false-alarms on **every** purchase. TRUE-80575 (1 `_underscore` commit + 3 `worker2` commits) sat un-PR'd from 2026-08-11, never deployed; PRs raised 2026-08-18.
151
151
  - **No persistent failure flag** — the only durable evidence of a failed contract creation is the outbound API log row, not the entitlement.
152
152
  - **Address-gated** — `postPost` does nothing when the payload lacks a primary contact address, so such an entitlement never attempts contract creation (and never logs a `/contract` call).
153
+ - **⚠ OPEN DEFECT (found 2026-09-18, TRUE-82118, NOT fixed) — a TECH purchase attempts an AIG WARRANTY contract.** `_underscore/Model/Rate/Entitlement.php` ~line 491 gates the whole contract-creation block on *"does the payload have an address"* (`!empty($payload->contact->primaryContactAddress->address)`), **not** on whether the product is a warranty. `$isWarranty` is only used for reporting. So any tech purchase by a contact that happens to have an address tries to create an AIG contract. Fix direction: gate that block on `$isWarranty` **and** the address. Deferred because `_underscore` deploys were blocked until 2026-09-24 (Rohan) and TRUE-82118 had to ship 2026-09-18.
154
+ - **⚠ The address comes from the RESPONSE payload, not the request.** api2 builds it from the contact's saved address, so **this cannot be prevented from worker2 or toga2-view — only in `_underscore`.** Proof: dev-sandbox `Logs_Rate.Api` id 62300 — `requestPayload` has no `primaryContactAddress`, `responsePayload` does.
155
+ - Observed twice on dev-sandbox (`Logs_Rate.Api` 62254, 62299): OUT `POST /wishservices/api/authentication/login`, `responseCode` 0 — no egress, so it stopped at auth.
156
+ - **Exposure: only contacts WITH an address** — 11 of 666 production Rate contacts, all existing Whole Home Warranty holders.
157
+ - Most likely outcome is AIG **rejecting** it (we send `saleItemId` as the item **title**, e.g. `"1 Year Unlimited Tech Support - Annual"`, which is not in AIG's catalog), swallowed silently — but it **would** fire the `_Worker_Monitors_RateEntitlement` alert to devteam@togatech.com and look like a broken warranty purchase. Worst case, unproven: AIG accepts, a real warranty contract is created for a tech sale, and `persistAigContractIdentifiers()` writes the identifiers onto the **tech** entitlement — it runs on the success path regardless of product.
153
158
  - **Auth-induced failures are blind to the monitor** — see the detection limitation.
154
159
  - **The AIG carrier cancel (`/contract/cancel`) is chained off the SAME `BILLING.SUBSCRIPTION.CANCELLED` event** worker2 uses to confirm a portal cancellation — so the two flows share a trigger and a failure mode: while `Rate/Webhook` jobs time out, neither the AIG cancel nor the `dateCancelled` stamp happens. See [subscription cancellation](subscription-cancellation.md). **"AIG" here is the *carrier*, not the `Client_Aig` tenant** — do not resolve it as a client slug.
155
160
  - **The AIG-success branch REASSIGNS `$payload->uuid`** (`$payload->uuid = _String::generateUuid()`, repurposed as the AIG contract number) in the same `postPost` that pins the WH service address. On the pre-redesign build the pin step ran **after** this reassignment and keyed off `$payload->uuid`, so **every successful AIG contract silently broke the WH address pin** (the pin lookup matched no row). Preserve the ordering fix — snapshot the entitlement uuid **before** this branch. See the [WH per-address purchase guard](whole-home-warranty-purchase-guard.md) "AIG-success clobbers the pin" gotcha (`_underscore` `_beta` `9c2e4b1c`).
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: rate
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-09-16
9
+ updated: 2026-09-18
10
10
  owners: ["mhammontree"]
11
11
  files:
12
12
  - worker2/Worker/Rate.php
@@ -18,6 +18,8 @@ files:
18
18
  - toga2-view/src/pages/CheckOut/viewModel/useCheckoutPageViewModel.ts
19
19
  - toga2-view/src/pages/CheckOut/api/checkoutApi.ts
20
20
  - toga2-view/src/pages/Activation/view/Activation.tsx
21
+ - toga2-view/src/constants/storageKeys.ts
22
+ - test/@Mark/Rate/replay_paypal_webhook.php
21
23
  related:
22
24
  - profile.md
23
25
  - service-card-entitlements.md
@@ -26,30 +28,38 @@ related:
26
28
  - aig-contract-creation.md
27
29
  - subscription-cancellation.md
28
30
  ---
29
- How Rate PayPal subscription purchases are created (browser-only) and how the worker2 webhook handles lifecycle events; open before touching Rate purchase/webhook, backfilling a missed purchase, or the watchdog-timeout defect.
31
+ How Rate PayPal subscription purchases are created (browser primary, webhook fallback) and how the worker2 webhook handles lifecycle events; open before touching Rate purchase/webhook, replaying a webhook, or backfilling a missed purchase.
30
32
 
31
33
  ## Summary
32
- Rate customers buy home-tech-support and home-warranty subscriptions through PayPal on `toga2-view`. The purchase is created **entirely client-side**; `_Worker_Rate::Webhook` (worker2) handles only post-purchase lifecycle events (renewal sales orders and deactivations) against an entitlement that **already exists**.
34
+ Rate customers buy home-tech-support and home-warranty subscriptions through PayPal on `toga2-view`. The browser is the **primary** creation path; `_Worker_Rate::Webhook` (worker2) is the **server-side fallback** (`BILLING.SUBSCRIPTION.ACTIVATED`) and handles post-purchase lifecycle events (renewal sales orders, deactivations).
33
35
 
34
- **⚠ STATUS (2026-08-10, TRUE-80575) — A FIXED (uncommitted), B DECIDED (not built):**
35
- - **A. Every `Rate/Webhook` worker job fails** (watchdog kill at ~322s vs a 300s watchdog; prod `Core.WorkerJobs` 560657, 563520, 563544, 563545, 574010, 2026-08-01 → 2026-08-03). **Fix written, NOT committed** — it sits in the `worker2` working tree on branch `TRUE-80575`. See *Idempotency (rebuilt)*.
36
- - **B. New purchases have no server-side creation path at all.** Only the customer's browser can create an entitlement. If the browser never completes the call, PayPal keeps the money and TOGa records nothing. **Approach decided** — add a `BILLING.SUBSCRIPTION.ACTIVATED` handler to `_Worker_Rate::Webhook` as a *fallback net*; the browser `onApprove` POST stays the PRIMARY synchronous path. **Not yet built**, and for warranty it is **blocked** by the `toga2-view` work to persist the covered property pre-payment.
37
- - **A and B are separate.** Fixing the watchdog does **not** close the money-taken-nothing-recorded hole.
36
+ **STATUS (2026-09-18, TRUE-82118):** TRUE-80575's two defects are **fixed and deployed** — the watchdog timeout (A) and the missing server-side creation path (B, the `BILLING.SUBSCRIPTION.ACTIVATED` fallback handler). TRUE-82118 then fixed a defect **in** that fallback: it rejected every Tech Support purchase. See *ACTIVATED fallback*. Committed on branch `TRUE-82118` (`worker2` 28d6569, `toga2-view` 6e4b01c), **not yet pushed**.
38
37
 
39
38
  ## How it works
40
39
 
41
- ### New purchase (browser-only)
40
+ ### New purchase (browser — primary path)
42
41
  1. `usePayPalSubscription.ts` resolves a plan id from `PAYPAL_PLAN_IDS` (`paypalService.ts`) and opens the PayPal subscription flow.
43
42
  2. On PayPal approval, the SDK's `onApprove` callback (`usePayPalSubscription.ts:151-177`) fires **in the customer's browser**.
44
43
  3. `handleActivateService` (`useCheckoutPageViewModel.ts:402-489`) builds a single nested `POST /entitlements` payload — Entitlement + Subscription + SalesOrder + SalesOrderItem + Payment — and posts it via `checkoutApi.ts:20`.
45
- 4. That browser call is the **only** thing that creates the records.
44
+ 4. That browser call normally creates the records; the ACTIVATED webhook covers it when it does not.
45
+ 5. **Safety net (TRUE-82118, `6e4b01c`).** `onApprove` writes the PayPal subscription id, user uuid, plan and amount to `sessionStorage` (keys in `src/constants/storageKeys.ts`) **before** the API call; checkout clears it only once the entitlement exists. The buyer is charged the moment `onApprove` fires, so a failed POST / closed tab / dropped connection still leaves a trace support can follow. All storage access is `try/catch`-wrapped.
46
+ 6. **`return_url` / `cancel_url` removed** from `application_context` in `createSubscription`. **This is NOT a confirmed root cause** — PayPal's App Switch docs say client-side callbacks "will continue to work". They were removed because nothing in the app reads either URL, and pointing PayPal's redirect at `/activation?success=true` is unsafe on its own: that page reports success unconditionally (10s countdown → `/home`, never reads `subscription_id`), so any flow landing there without `onApprove` having run tells a paying customer it worked while no entitlement exists.
46
47
 
47
48
  ### Lifecycle (worker2)
48
- PayPal → Lambda → SQS → `_Worker_Rate::Webhook`, which handles `PAYMENT.SALE.COMPLETED` (renewal sales order on an existing entitlement) and six deactivation events. There is **no** `BILLING.SUBSCRIPTION.ACTIVATED` handler. For a new purchase the worker dead-ends early: `Rate.php:112` throws *"Subscription not found for token"*, `Rate.php:394` throws *"Entitlement not found"*.
49
+ PayPal → Lambda → SQS → `_Worker_Rate::Webhook`, which handles `BILLING.SUBSCRIPTION.ACTIVATED` (server-side fallback creation), `PAYMENT.SALE.COMPLETED` (renewal sales order on an existing entitlement) and six deactivation events.
49
50
 
50
- ### Idempotency (rebuilt 2026-08-10 — code in the `TRUE-80575` worker2 working tree, uncommitted)
51
+ ### ACTIVATED fallback — `handleSubscriptionActivated` (`worker2/Worker/Rate.php`)
52
+ The server-side net for a purchase the browser never posted. **It is a fallback, not the primary path** — the browser `onApprove` POST still creates the records in the normal case.
53
+
54
+ **`plan_requires_aig` is a REQUIRED per-plan config entry** (`worker2/Config/production.ini`). `resolveProductForPlan()` **refuses** a plan that has no entry rather than defaulting to `false`, and tests with `array_key_exists` so an explicit `"0"` stays valid. The resulting `$requiresAig` now decides whether the AIG-only guards run at all, so a new warranty plan added without that line would sell a warranty with no address, no phone, and no AIG contract check. Both Tech Support plans declare `"0"`.
55
+
56
+ **Guards are AIG-gated, not universal (TRUE-82118 fix, `28d6569`).** Step 5's *"contact must have an address"* check was written for the AIG-backed Whole Home Warranty but ran for **every** plan, so **every Tech Support purchase was blocked** — Tech Support is not AIG-backed and has no covered property anywhere in its flow. Step 5 is now gated on `$requiresAig` (matching how step 5b already gated the phone check), and `contact.primaryContactAddress` is included in the create payload **only** when the contact has one.
57
+
58
+ **Still UNVERIFIED end to end.** The 2026-09-18 sandbox proof (below) exercised the **frontend** path only. To exercise the fallback, block the `POST /v2/entitlements` request in devtools after PayPal approval so only the webhook runs.
59
+
60
+ ### Idempotency (rebuilt 2026-08-10, TRUE-80575 — shipped)
51
61
  **Guard on the business record, not on a log or a job row.** `worker2/Worker/Rate.php`:
52
- - `isEventProcessed()` **deleted** entirely, along with its call site and the `Logs_Rate` lookup.
62
+ - `isEventProcessed()` was **deleted** entirely, along with its call site and the `Logs_Rate` lookup.
53
63
  - New `findPaymentByTransactionId()` — `GET /payments` filtered on `transactionIdentifier`, mirroring the existing `findSubscriptionByToken()`.
54
64
  - `handlePaymentCompleted()` now opens with a guard that returns **success without writing** if that PayPal sale is already recorded.
55
65
  - **`DB_LOGS_RATE` registration is deliberately KEPT** — still required by the outbound `_ApiRequest::setLogging(true)` calls. Do not remove it as "dead" when tidying.
@@ -77,22 +87,52 @@ Across 7 real events on two days (`Core.WorkerJobs` 563520/563544/563545/332143/
77
87
 
78
88
  ### Investigating this
79
89
  - **Defect A cannot be reproduced locally via a sandbox webhook.** Non-production worker2 is not SQS-driven (manually HTTP-invoked), so a PayPal sandbox webhook never traverses the Lambda→SQS→worker path and the watchdog never fires. Reproduce by timing the `isEventProcessed()` query directly against a large `Logs_Rate.Api`.
80
- - **Defect B** must be exercised via a real sandbox browser checkout.
90
+ - **The fallback handler** must be exercised via a real sandbox browser checkout with `POST /v2/entitlements` blocked in devtools.
81
91
  - **Always bound `Logs_Rate.Api` queries by `dtStamp` plus a `method`/route filter.** Unfiltered windows return hundreds of unrelated bulk contact-sync `PUT` rows and truncate.
82
92
 
93
+ ### Replaying a webhook worker2 already received (`test/@Mark/Rate/replay_paypal_webhook.php`)
94
+ Re-enqueues a stored PayPal webhook through the same `Rate/Webhook` action, replaying the exact payload from the `Core.WorkerJobs.parameters` column. Needed because when a handler refuses or defers an event the customer **has already paid**, and once PayPal's retry window closes that `WorkerJobs` row is the only surviving copy of the payload — PayPal will not re-deliver.
95
+
96
+ - Dry-run by default; `APPLY=1` to enqueue; `CONFIRM_PRODUCTION=1` on top for production. One job per run. Refuses any action that is not `Rate/Webhook`.
97
+ - **Replay passes signature checks only because `[paypal] verify_signature` is OFF** (the EB worker tier cannot reach `api-m.paypal.com`). If that is ever turned on, replay breaks.
98
+ - **Safe to re-run:** `handleSubscriptionActivated` returns early when `Subscriptions.token` exists, and `handlePaymentCompleted` returns early when the sale id is already on a payment row.
99
+ - **Order matters: ACTIVATED first, then `PAYMENT.SALE.COMPLETED`.**
100
+ - Not committed — the `test` repo is not branched.
101
+
102
+ ### Evidence (TRUE-82118) — the 2026-09-17 Rate incident
103
+ A Rate customer paid **$9.97 for Tech Support** on 2026-09-17 (PayPal subscription `I-3S346AJE68C5`) and got **no entitlement and no confirmation email**. Chain, all read-only against production:
104
+ - `Client_Rate.Contacts` 919 exists (created 2026-09-13 at SSO login), **no entitlement rows**.
105
+ - **Zero** `POST /v2/entitlements` from her browser in `Logs_Rate.Api` on either date — only GETs. So the browser path never fired and the fallback was the only remaining chance.
106
+ - `Core.WorkerJobs` 1628022 (`SUBSCRIPTION.CREATED`), 1628789 (`PAYMENT.SALE.COMPLETED`), 1628790 (`SUBSCRIPTION.ACTIVATED`) all `isSuccess=1`.
107
+ - The ACTIVATED handler **blocked on the address guard**: prod `Logs.Issue` id 798, reference `WX`, issueKey `RATE_PAYPAL_ACTIVATION_NO_SERVICE_ADDRESS`, urgency HIGH, still OPEN.
108
+ - `PAYMENT.SALE.COMPLETED` returned `deferred` because no subscription row existed.
109
+ - **No Tech Support purchase has EVER succeeded in production.** The only tech entitlements (`ET100007-9`) are from test user "andy america" in 2025; every real purchase since is warranty. That is why the universal address guard went unnoticed.
110
+
111
+ ### Verification (dev-sandbox, 2026-09-18) — frontend path only
112
+ Two browser purchases by contact 8, both correct:
113
+ - `ET100079` (id 112), Tech Support **Monthly**, $9.97, subscription 93, `dateStart` 2026-09-18, `dateEnd` 2026-10-18.
114
+ - `ET100080` (id 113), Tech Support **Annual**, $99.00, subscription 94, `dateStart` 2026-09-18, `dateEnd` **2027**-09-18 (yearly correctly added a year, not a month).
115
+ - Both: `serviceAddressId` NULL and both AIG columns NULL — **correct for tech**.
116
+ - Email: `Logs_Rate.Email` id 1, status SENT 16s after queueing, `retryCount` 0, correct TECH template, every placeholder substituted. Recipient rewritten to `devteam@goagilant.com` by the sandbox debug redirect — expected.
117
+ - **LIMIT: this proves the FRONTEND path only.** The entitlement came from the browser (`POST /v2/entitlements` 201 from a residential IP) and no `Rate/Webhook` job ran, so the worker2 fallback that was actually fixed is **still unverified**.
118
+
83
119
  ### Evidence (TRUE-80575)
84
120
  Zero `POST /v2/entitlements` rows in `Logs_Rate.Api` for all of 2026-08-01, while `Client_Rate` User 755 / Contact 773 / Customer 752 (Kwadwo "Drew" Moore) were created at 16:25:16 pre-payment and PayPal charged $89.97 at 16:32:47 (txn `0DY909616V1516622`, subscription `I-411CRRVH81U8`).
85
121
 
86
122
  ### Decisions
87
- - **Defect B approach (decided 2026-08-10):** add a `BILLING.SUBSCRIPTION.ACTIVATED` handler to `_Worker_Rate::Webhook`. The browser `onApprove` POST remains the **PRIMARY synchronous path**; the webhook is the **fallback net, not a replacement**. Not yet built.
123
+ - **`plan_requires_aig` is REQUIRED, not defaulted (2026-09-18).** A missing entry is a hard refusal. Defaulting to `false` would silently sell a warranty with none of its guards.
124
+ - **Repair procedure for the 2026-09-17 customer (decided, NOT yet executed — blocked on the worker2 deploy).** After the fix deploys, replay `Core.WorkerJobs` **1628790 (ACTIVATED) first, then 1628789 (`PAYMENT.SALE.COMPLETED`)**. PayPal will not re-deliver a 2026-09-17 webhook. The `PAYMENT.SALE.COMPLETED` replay also extends `Subscriptions.dateEnd` one billing period to 2026-10-17, matching PayPal's own `next_billing_time` in the stored payload. Resolve prod `Logs.Issue` reference `WX` once she is whole.
125
+ - **The ACTIVATED handler is the fallback net, not a replacement** for the browser `onApprove` POST.
88
126
  - **Rejected:** framing TRUE-80575 as a single "webhook broken" bug.
89
127
  - **Rejected (with evidence):** `Core.WorkerJobs` as an event ledger.
90
128
  - **Deferred to the lead developers:** a durable idempotency table. Do not write a migration.
91
129
  - **Resolved:** the "Rosetta White double charge" (recorded 2026-08-04) is closed. Both sales (`04T839079C8838430` and `10T52264HU684770N`, $89.97 each, 2026-07-10) have been **refunded**. No backfill was performed and none is needed — her covered-property address predates `validateAddress` logging and was never recoverable anyway.
92
130
 
93
131
  ### Still open (carry forward)
94
- - **`toga2-view`:** persist the covered property **pre-payment** — this **BLOCKS** the ACTIVATED handler for warranty; and add the missing `yearly_warranty` key to `PAYPAL_PLAN_IDS`.
95
- - The `BILLING.SUBSCRIPTION.ACTIVATED` handler itself.
132
+ - **Execute the 2026-09-17 repair replay** once the TRUE-82118 worker2 commit is pushed and deployed.
133
+ - **Verify the ACTIVATED fallback itself** — never exercised end to end (see *Verification*).
134
+ - **`_underscore` `Entitlement.php::postPost` attempts an AIG warranty contract for a TECH purchase** when the contact happens to have an address. Open, deferred. See [AIG contract creation](aig-contract-creation.md).
135
+ - **`toga2-view`:** persist the covered property **pre-payment**; and add the missing `yearly_warranty` key to `PAYPAL_PLAN_IDS`.
96
136
  - The webhook still does **not return HTTP 200 on failure paths**, so PayPal retries indefinitely.
97
137
  - Webhook **signature verification still disabled** — the endpoint is forgeable.
98
138
  - The PayPal `client_secret` is **still committed in plaintext** in `worker2/` and `api2/` `Config/production.ini` — rotate and move it out of the repos. (Location only; no value here.)
@@ -105,8 +145,9 @@ Zero `POST /v2/entitlements` rows in `Logs_Rate.Api` for all of 2026-08-01, whil
105
145
  - **`isSuccess = 0` on a `Rate/Webhook` job does NOT mean "no side effects."** A watchdog-killed job's PHP process can keep running and complete its API writes after the row is marked failed. Job 560657 started 07:53:03, killed 07:58:18, yet its `POST /v2/sales-orders` landed 08:00:10 and `POST /v2/entitlement-sales-orders` at 08:00:41 (worker IP 34.232.23.158). **Re-running or manually remediating a failed job risks duplicates** — always check `Logs_Rate.Api` first. This race also explains why some purchases appear to have worked.
106
146
  - **Yearly Home Warranty cannot be purchased at all (separate live bug, needs its own ticket).** `PAYPAL_PLAN_IDS` (`paypalService.ts:31-41`) defines `monthly_tech`, `yearly_tech`, `monthly_warranty` — but **no `yearly_warranty`**. `getPlanId()` builds `` `${plan}_${serviceType}` `` → `yearly_warranty` → `undefined` → `onError("Invalid plan configuration")`.
107
147
  - **Security: the webhook endpoint is unauthenticated and forgeable.** With `[paypal] verify_signature` disabled (documented in-code as a temporary tradeoff for the blocked egress), anyone who knows the endpoint URL can create a paid SalesOrder. Separately, the live PayPal `client_secret` is committed in plaintext in the `[paypal]` block of **both** `worker2/Config/production.ini` and `api2/Config/production.ini` — rotate and move it out of the repo. (Location only; value not recorded in this KB.)
108
- - **Defect A now also blocks portal cancellations, not just renewals.** As of 2026-08-04 the portal's self-service cancel flow relies on this worker consuming `BILLING.SUBSCRIPTION.CANCELLED` to stamp `Subscriptions.dateCancelled` (its reconciliation channel) — with every `Rate/Webhook` job dying, that path is dead. See [Rate tech-support subscription cancellation](subscription-cancellation.md).
109
- - **Hypothesis, NOT confirmed — the `return_url` lands on a page that creates nothing.** `usePayPalSubscription.ts:126` sets `return_url` to `${origin}/activation?success=true`. If the SDK falls back to a full-page redirect (popup blocked, mobile in-app browser), PayPal redirects there and `onApprove` never runs; `Activation.tsx` is a static "success, redirecting in 10s" page that reads no subscription id and calls no API. The DB can only prove the *absence* of the call, not why the browser didn't make it. Treat as a plausible mechanism, not a cause.
148
+ - **This worker is also the portal-cancellation channel.** The self-service cancel flow relies on it consuming `BILLING.SUBSCRIPTION.CANCELLED` to stamp `Subscriptions.dateCancelled` — any outage here kills cancellations too, not just renewals. See [subscription cancellation](subscription-cancellation.md).
149
+ - **⚠ A guard written for the warranty will silently kill the tech product.** Both products share one `handleSubscriptionActivated`, and tech has **no** covered property and **no** AIG contract. Any new check on address / phone / AIG must be gated on `$requiresAig`. This is exactly how every Tech Support purchase was blocked for months without anyone noticing — no tech purchase had ever succeeded in production, so there was no "it used to work" signal.
150
+ - **`/activation?success=true` reports success unconditionally.** `Activation.tsx` never reads `subscription_id` and calls no API — it counts down 10s and goes to `/home`. Any flow that lands there without `onApprove` having run tells a paying customer it worked while no entitlement exists. Do not point a redirect at it. (The `return_url`/`cancel_url` were removed for this reason; PayPal suppressing `onApprove` remains **unproven** — their App Switch docs say client-side callbacks keep working.)
110
151
 
111
152
  ## Related
112
153
  - [Rate profile](profile.md)
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: rate
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-09-16
9
+ updated: 2026-09-18
10
10
  owners: [mhammontree]
11
11
  files:
12
12
  - _underscore/Model/Rate/Entitlement.php
@@ -76,6 +76,8 @@ Remaining items are **optional / separate, not go-live blockers**:
76
76
  - **Shared interceptor with TRUE-79533 (WH per-address guard) — same `prePost`/`postPost`.** This workflow's `postPost` trigger + helpers live in the **same** `_Model_Rate_Entitlement` interceptors that the [Whole Home Warranty per-address guard](whole-home-warranty-purchase-guard.md) edits, and TRUE-79251 also adds the passThrough `const PASSTHROUGH_KEY_FULFILLMENT_PREFERENCE` + the contractFulfillmentPreference (EV-8) fix inside `prePost`. A bad `_production`→`_beta` merge already mis-resolved this once and fatally broke every Rate purchase save. When merging either ticket across branches, hand-merge into ONE `prePost()` keeping both features' logic — see the **shared-interceptor merge hazard** gotcha on that doc for the correct resolution and why `php -l` is not a sufficient gate.
77
77
  - **`border-radius` on a `<td>` renders SQUARE in many clients (Outlook).** Use an `<img>` for circular badges/icons (e.g. the green circle-check) instead of a CSS-rounded table cell.
78
78
  - **ASCII-only subjects (mojibake workaround).** `_Email::send()` does not set PHPMailer `CharSet=UTF-8`, so non-ASCII subjects/body (em dash, curly quotes, accents) mojibake. This workflow uses ASCII-only subjects; the proper root-cause fix is tracked on [`email-template-sending.md`](../../../2.0/apps/_underscore/features/email-template-sending.md).
79
+ - **⚠ A non-prod tenant missing its `EmailTemplates` seed looks like a code error, not a data gap.** It surfaces as a FAILED `Notification/EmailTemplate/Send` worker job naming the template **uuid**: *"Could not load `_Model_Client_EmailTemplate` object based upon these criteria: `uuid` = '2c4f8a1e-…'"* (dev-sandbox `Core.WorkerJobs` 141941, 2026-09-18). dev-sandbox `Client_Rate.EmailTemplates` held only 2 rows (Forgot Password, Talos); prod has both Rate templates as ids 3 and 4. Fix: run the existing `dbchanges2/Client_Rate/2026-06-30a - Rate purchase email templates.sql` on that environment. Check the tenant's `EmailTemplates` rows before debugging the sender.
80
+ - **⚠ OPEN — both templates render badly in email dark mode (from TRUE-79251; needs a design decision, not a code fix).** (1) The CTA button becomes an **empty dark bar** — background `#21292d` disappears into the client's dark theme and the white label is invisible. (2) The TOGA footer wordmark shows inside a **dark block**, because the image was rasterized onto the exact footer colour `#21292d`, which the client lightens in dark mode. Light mode is fine, which is why neither was caught — the *"flatten the asset onto the exact region background"* recipe above is precisely what breaks under dark mode.
79
81
  - **Worker DB registration** — see [`notification-email-template.md`](../../../2.0/apps/worker2/features/notification-email-template.md): worker actions do not auto-register `DB_CLIENT`.
80
82
 
81
83
  ## Related
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.845",
3
+ "version": "1.0.847",
4
4
  "description": "TOGA Technology Team Claude Knowledge System — shared AI coding harness with skills, knowledge base CLI, and project installer for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",