toga-ai 1.0.467 → 1.0.469

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.
@@ -9,7 +9,7 @@
9
9
  | [Elite Freshservice Sync (library)](features/elite-freshservice-sync.md) | `App_Api_Toga2` in `library/app/api/toga2.php` orchestrates bidirectional sync between TOGA 2 and TOGaDesk. | library/app/api/toga2.php |
10
10
  | [Branded HTML Email Templates (App_Email_Template)](features/email-templates.md) | `App_Email_Template` (`app/email/template.php`) is the base class for branded HTML emails in the 1.0 (`App_`) framework. | library/app/email/template.php, library/app/email/agilant.php |
11
11
  | [1.0 MVC Page Pattern & New-App Skeleton](features/mvc-page-pattern-and-app-skeleton.md) | This is the **reusable recipe for standing up a new 1.0 (`App_`) application** and for adding pages to one — the folder-based MVC routing, the page lifecycle, t | library/app/framework.php, library/app/frameworkindex.php, library/app/mvc.php, library/app/database.php, library/app/model.php, library/app/config.php |
12
- | [isFulfillable from NetSuite during Item Sync (Phase 1)](features/netsuite-item-isfulfillable-sync.md) | This is the **1.0 (Phase 1)** half of the `isFulfillable` feature: reading the NetSuite `isfulfillable` flag during item sync and stamping it onto the **Agilant | library/app/netsuite.php, library/app/api/toga2.php, worker/crons/toga2/netsuite/backfill_isfulfillable_jul5.php |
12
+ | [isFulfillable from NetSuite during Item Sync (Phase 1)](features/netsuite-item-isfulfillable-sync.md) | This is the **1.0 (Phase 1)** half of the `isFulfillable` feature: reading the NetSuite `isfulfillable` flag during item sync and stamping it onto the **Agilant | library/app/netsuite.php, library/app/api/toga2.php, worker/crons/toga2/netsuite/common_sync_togasupply.php, worker/crons/toga2/netsuite/backfill_isfulfillable_jul5.php |
13
13
  | [NetSuite SuiteQL/REST API Reference](features/netsuite-suiteql-api-reference.md) | General working reference for the Agilant NetSuite integration: how to authenticate, how SuiteQL behaves, and the confirmed schema of the tables/columns/codes w | library/app/api/netsuite/rest.php, library/ssl/netsuite_ec_key.pem, test/@dave/Junk Drawer/nsq.php |
14
14
  | [NetSuite SuiteQL/REST Shim — Field Semantics](features/netsuite-suiteql-rest-shim.md) | `App_Api_Netsuite_Rest` is the REST/SuiteQL replacement for the deprecated NetSuite SOAP toolkit. | library/app/api/netsuite/rest.php |
15
15
  | [NetSuite Sync Alert Monitor (App_SystemMonitor_NetSuiteIntegration)](features/netsuite-sync-alert-monitor.md) | `App_SystemMonitor_NetSuiteIntegration` (`library/app/systemmonitor/netsuiteintegration.php`, title **"NetSuite Sync Alert"**) is a 1.0 system monitor that watc | library/app/systemmonitor/netsuiteintegration.php, worker/crons/infrastructure/system_monitors.php |
@@ -6,15 +6,17 @@ project: Library
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-20
9
+ updated: 2026-07-28
10
10
  owners: [bala]
11
11
  files:
12
12
  - library/app/netsuite.php
13
13
  - library/app/api/toga2.php
14
+ - worker/crons/toga2/netsuite/common_sync_togasupply.php
14
15
  - worker/crons/toga2/netsuite/backfill_isfulfillable_jul5.php
15
16
  related:
16
17
  - toga2-api-client-and-bridge.md
17
18
  - ../../worker/features/netsuite-togasupply-per-client-sync.md
19
+ - ../../worker/workflows/isfulfillable-multi-client-backfill.md
18
20
  - ../../../2.0/apps/_underscore/features/fulfillable-item-propagation.md
19
21
  ---
20
22
 
@@ -26,24 +28,43 @@ item** in the 2.0 platform. The client-facing copy is reached separately by the
26
28
  which walks the supply chain up from the source item. Only the source item is stamped here.
27
29
 
28
30
  ## Key files / entry points
29
- - `library/app/netsuite.php` — `App_NetSuite::getItemIsFulfillable($itemInternalId): bool|null`.
30
- A SOAP `ItemSearchBasic` by internal id; returns the boolean (or `null` when unknown). Mirrors
31
- the existing `getItemIsSerialized` exactly — the only prior item flag the sync read.
32
- - `library/app/api/toga2.php` — `getCreateItem` (~line 4364). During item create/update the sync
33
- now reads `$isFulfillableItem = App_NetSuite::getItemIsFulfillable($nsItemInternalId)` and, when
34
- **non-null**, stamps `$payload['isFulfillable']` on the create/update PUT|POST to api2. The
35
- NetSuite internal id is resolved from the order line or from the part number
36
- (`getItemInternalIdFromPartNumber` / `getItemGroupInternalIdFromPartNumber`). Stores
37
- `c_netsuiteInternalItemId` on the item.
38
- - `worker/crons/toga2/netsuite/backfill_isfulfillable_jul5.php` — one-time backfill (see below).
31
+ - `library/app/netsuite.php`
32
+ - `App_NetSuite::getItemIsFulfillable($itemInternalId): bool|null` — a SOAP `ItemSearchBasic` by
33
+ internal id; returns the boolean (or `null` when unknown). Mirrors `getItemIsSerialized`.
34
+ - `App_NetSuite::getItemSerializedAndFulfillable($itemInternalId)` — **combined lookup** returning
35
+ **both** flags from **one** `ItemSearchBasic` (serialized via `instanceof SerializedInventoryItem`,
36
+ fulfillable via the record's `isFulfillable`). `getCreateItem` now calls this once instead of the
37
+ two separate SOAP searches (`getItemIsSerialized` + `getItemIsFulfillable`) — halves the per-line
38
+ NetSuite round-trips now that the sync reads fulfillability for every line (see refresh below).
39
+ - `library/app/api/toga2.php` — three item-creation paths now stamp `isFulfillable`:
40
+ - `getCreateItem` (~line 4364) — reads both flags via `getItemSerializedAndFulfillable` and, when
41
+ fulfillable is **non-null**, stamps `$payload['isFulfillable']` on the create/update PUT|POST. It
42
+ now also **refreshes the flag on EXISTING items** when NetSuite's value differs from the stored
43
+ value (not only on create), mirroring how the serialized flag works. Because the PUT is
44
+ diff-only, a re-sync with an unchanged value is a no-op. NetSuite internal id is resolved from the
45
+ order line or part number (`getItemInternalIdFromPartNumber` / `getItemGroupInternalIdFromPartNumber`);
46
+ stores `c_netsuiteInternalItemId`.
47
+ - `syncItemFulfillmentFromNetsuite` (~line 3562) and `syncInventoryAdjustmentFromNetsuite`
48
+ (~line 3882) — the two **other** direct `/items` creation paths now also stamp `isFulfillable` on
49
+ create. Previously only `getCreateItem` did, so items first created via these flows stayed **NULL**.
50
+ - `worker/crons/toga2/netsuite/common_sync_togasupply.php` — `isFulfillable` was added to the bulk
51
+ `/items` GET field list so the existing-item refresh has the stored value to compare NetSuite's
52
+ against (without it, the refresh has nothing to diff).
53
+ - `worker/crons/toga2/netsuite/backfill_isfulfillable_jul5.php` — one-time Compass backfill (see below).
54
+ The later **multi-client** catch-up lives in its own worker workflow doc
55
+ ([isFulfillable multi-client backfill](../../worker/workflows/isfulfillable-multi-client-backfill.md)).
39
56
 
40
57
  ## How it works
41
58
  1. Item sync resolves the NetSuite internal id for the item (order line, else part-number lookup).
42
- 2. `getItemIsFulfillable` SOAP-searches the item and returns the flag.
43
- 3. If non-null, `getCreateItem` adds `isFulfillable` to the item PUT/POST payload to api2. api2's
44
- Items interceptor then propagates it up-chain (Phase 2).
45
- 4. Going forward the **daily sync** handles new items automatically; existing items are caught up
46
- by the one-time backfill.
59
+ 2. `getItemSerializedAndFulfillable` SOAP-searches the item **once** and returns both the serialized
60
+ and fulfillable flags.
61
+ 3. If fulfillable is non-null, `getCreateItem` adds `isFulfillable` to the item PUT/POST payload to
62
+ api2 — on **create**, and on **update** whenever the stored value differs from NetSuite's (the
63
+ existing-item refresh). api2's Items interceptor then propagates it up-chain (Phase 2). The two
64
+ other creation paths (`syncItemFulfillmentFromNetsuite`, `syncInventoryAdjustmentFromNetsuite`)
65
+ also stamp it on create.
66
+ 4. Going forward the **daily sync** handles new items and keeps existing items current (the refresh);
67
+ items that predate the feature are caught up by the one-time backfills.
47
68
 
48
69
  ### One-time backfill (July 5+ orders)
49
70
  `backfill_isfulfillable_jul5.php` catches up Agilant items on SalesOrders since **2026-07-05**:
@@ -85,6 +106,14 @@ nearly everything resolves to `fulfillable = 1`.**
85
106
  in `Client_Compass.Apis` (name `Agilant`) — never reproduce the secret value.
86
107
 
87
108
  ## Change history
109
+ - 2026-07-28 — Code-review hardening: (1) `getCreateItem` now **refreshes `isFulfillable` on
110
+ existing items** when NetSuite's value differs (not create-only), mirroring the serialized flag;
111
+ diff-only PUT keeps re-syncs no-op. (2) The two other `/items` creation paths
112
+ (`syncItemFulfillmentFromNetsuite`, `syncInventoryAdjustmentFromNetsuite`) now also stamp the flag
113
+ on create — they previously left it NULL. (3) Added `isFulfillable` to the worker sync's bulk
114
+ `/items` GET field list so the refresh has a stored value to diff. (4) New combined
115
+ `App_NetSuite::getItemSerializedAndFulfillable` returns both flags from one `ItemSearchBasic`, so
116
+ `getCreateItem` no longer makes two SOAP searches per line. (bala)
88
117
  - 2026-07-20 — Added `App_NetSuite::getItemIsFulfillable` (SOAP `ItemSearchBasic`, mirrors
89
118
  `getItemIsSerialized`) and wired `getCreateItem` to stamp `isFulfillable` on the item PUT/POST to
90
119
  api2 when non-null (internal id from order line or part-number lookup; stores
@@ -100,3 +129,5 @@ nearly everything resolves to `fulfillable = 1`.**
100
129
  - [NetSuite → TOGa Supply Per-Client Sync](../../worker/features/netsuite-togasupply-per-client-sync.md)
101
130
  — the sync engine this item flag rides in.
102
131
  - [Phase 2 — isFulfillable propagation up the SO↔PO chain (2.0)](../../../2.0/apps/_underscore/features/fulfillable-item-propagation.md).
132
+ - [isFulfillable multi-client backfill (worker)](../../worker/workflows/isfulfillable-multi-client-backfill.md)
133
+ — the cross-client catch-up cron and its client-DB / catalog-matching gotchas.
@@ -10,4 +10,5 @@
10
10
  | [NetSuite → TOGa Supply Per-Client Sync (thin wrappers)](features/netsuite-togasupply-per-client-sync.md) | Syncs NetSuite transactions (sales orders, purchase orders, invoices, item receipts, item fulfillments, inventory adjustments) into each TOGa Supply (2.0) clien | worker/crons/toga2/netsuite/common_sync_togasupply.php, worker/crons/toga2/netsuite/sync_togasupply_canon.php, worker/schedules/cron.worker.sync.json, dbchanges2/_modules/netsuite/2026-04-01 - Parameters.sql, library/app/api/netsuite/rest.php, library/app/systemmonitor/netsuiteintegration.php |
11
11
  | [OneUptime external uptime monitoring for 1.0 workers](features/oneuptime-worker-uptime-monitoring.md) | Every 1.0 worker box self-reports its liveness to an external OneUptime monitor once per minute by curl-POSTing to a per-worker "Incoming Request" heartbeat URL | library/app/worker.php, worker/crons/worker/worker_heartbeat.php |
12
12
  | [Prudential: Send Shipments for the Day report (daily cron)](features/send-shipments-for-the-day.md) | Daily cron (9:00 PM) that emails Prudential and Dell stakeholders an Excel report of all devices shipped that day, including tracking number, serial number, emp | worker/crons/notifications/reports/send_shipments_for_the_day.php |
13
+ | [isFulfillable Multi-Client Backfill (all togasupply clients)](workflows/isfulfillable-multi-client-backfill.md) | One-time backfill that catches up `Items.isFulfillable` on **existing** items across **all 17 togasupply clients** (AIG, Broward Sheriff, Canon, Endeavor Health | worker/crons/toga2/netsuite/backfill_isfulfillable_all_clients.php, library/app/api/toga2.php |
13
14
  | [Onboarding a Client to the NetSuite TOGa Supply Sync](workflows/onboarding-client-to-netsuite-togasupply-sync.md) | How to add a new TOGa 2 client to the per-client NetSuite → TOGa Supply importer (`worker/crons/toga2/netsuite/`). | worker/crons/toga2/netsuite/sync_togasupply.php, worker/crons/toga2/netsuite/common_sync_togasupply.php, worker/schedules/cron.worker.sync.json, dbchanges2/_modules/netsuite/2026-04-01 - Parameters.sql |
@@ -0,0 +1,90 @@
1
+ ---
2
+ title: isFulfillable Multi-Client Backfill (all togasupply clients)
3
+ framework: "1.0"
4
+ repo: worker
5
+ project: Worker
6
+ client: shared
7
+ type: workflow
8
+ status: active
9
+ updated: 2026-07-28
10
+ owners: [bala]
11
+ files:
12
+ - worker/crons/toga2/netsuite/backfill_isfulfillable_all_clients.php
13
+ - library/app/api/toga2.php
14
+ related:
15
+ - ../features/netsuite-togasupply-per-client-sync.md
16
+ - ../../library/features/netsuite-item-isfulfillable-sync.md
17
+ - ../../../2.0/apps/_underscore/features/fulfillable-item-propagation.md
18
+ - ../../../clients/compass-usa/profile.md
19
+ ---
20
+
21
+ ## Summary
22
+ One-time backfill that catches up `Items.isFulfillable` on **existing** items across **all 17
23
+ togasupply clients** (AIG, Broward Sheriff, Canon, Endeavor Health, ERAU, GroWrk, NYC Health +
24
+ Hospitals, Masonite, Miami-Dade, NCCI, Prudential, Quad, SBA, SHRSS, S&P Global, Trividia Health,
25
+ and Compass). It is the multi-client successor to the Compass-only `backfill_isfulfillable_jul5.php`
26
+ (see the [Phase 1 library doc](../../library/features/netsuite-item-isfulfillable-sync.md)). Like that
27
+ one, it re-PUTs each Agilant **source** item to api2 `/items` so the **api2 interceptor does the
28
+ recursive up-chain propagation** — it never writes the DB directly and never walks the chain itself.
29
+
30
+ ## Steps (how the cron runs)
31
+ 1. **Scope each client's Agilant-catalog items directly from that client's DB** — NOT via api2 —
32
+ keyed by `c_netsuiteInternalItemId`. Direct DB is required because that field is not an api2 GET
33
+ field (see gotchas). The Agilant catalog is matched by **`Catalogs.name = 'Agilant'`**, not by id.
34
+ 2. **Read `isFulfillable` from NetSuite by internal id** via SuiteQL `WHERE id IN (...)`, **cached
35
+ across clients** so a shared internal id is fetched once for the whole run.
36
+ 3. **PUT each source item to api2 `/items`** (`App_Api_Toga2::send`). The Items interceptor then
37
+ propagates the value up the SO↔PO chain onto every client-facing item (Phase 2).
38
+ 4. **Per-client scoping rule:**
39
+ - **Compass** — limited to orders since a cutoff (~**2026-06-20**); it was already partly
40
+ backfilled and only orders after that had correct chain linkage to propagate up to the client
41
+ items.
42
+ - **Every other client** — takes **ALL** its Agilant items (never previously backfilled).
43
+ 5. **Resilience:** clients whose DB read fails are **skipped and reported**, not fatal. Supports
44
+ `DRY_RUN`, `DB_HOST_PREFERENCE` (`localhost` / `writer.client` / `sandbox-dev`), and
45
+ `API_ENDPOINT_OVERRIDE` (the **9th arg** of `App_Api_Toga2::send`) so the run can target prod or a
46
+ beta/sandbox endpoint.
47
+
48
+ ## Gotchas / known issues
49
+ - **Agilant catalog is matched by NAME, not id.** `Catalogs.name = 'Agilant'` is stable across every
50
+ client, but the `catalogId` **differs per client** — **Quad = 2, all other clients = 1** (verified
51
+ across all 17 client DBs). Hardcoding `catalogId = 2` would silently hit only Quad and miss the
52
+ other 16. Always resolve the catalog by name.
53
+ - **`c_netsuiteInternalItemId` is NOT an api2 GET field** — there is no `Core.RecordField` for it, so
54
+ it can only be read by direct DB query. That is why the backfill scopes items via direct DB reads
55
+ rather than an api2 `/items` GET.
56
+ - **Client DB names don't always follow the client identifier.** Compass's identifier is
57
+ `Compass_Usa` but its database is **`Client_Compass`**. All client DBs live on **one cluster**
58
+ (prod `prod-client`), so a single host/user/pass with a swapped **database name** reaches them all.
59
+ - **⚠ Environment must be fully deployed, or the backfill can't propagate.** PUTting `isFulfillable`
60
+ to `api.beta.togahub.com` returns **EV-9 "no write permission"** — the field exists but the write
61
+ ACL was never deployed there (only `dev-sandbox` had field + interceptor + ACL). The Core
62
+ RecordField, the `ApiPayloadInterceptors` rows, **and** the client column + write ACL must all be
63
+ present on the **same** environment, and the run must scope from that same cluster. Deploy order:
64
+ dbchanges (field/ACL/interceptors) before running the backfill. See the
65
+ [Phase 2 deploy gotchas](../../../2.0/apps/_underscore/features/fulfillable-item-propagation.md).
66
+ - **🔐 Credentials.** The cron reuses the per-client api uuids/secrets already embedded in the
67
+ `sync_togasupply_*` crons; the api secrets live in each `Client_<client>.Apis` (name `Agilant`) —
68
+ never reproduce a secret value in code or docs.
69
+
70
+ ## Run status
71
+ - 2026-07-28 — Ran across all NetSuite/togasupply clients. Compass scoped to orders after
72
+ ~2026-06-20 (only those had correct chain linkage to propagate up to the client items); every other
73
+ client backfilled from all its Agilant items.
74
+
75
+ ## Change history
76
+ - 2026-07-28 — Built and ran the multi-client `backfill_isfulfillable_all_clients.php`: direct
77
+ client-DB scoping of Agilant-catalog items (matched by `Catalogs.name`, not id) keyed by
78
+ `c_netsuiteInternalItemId`, NetSuite reads by internal id (cached across clients), re-PUT to api2 so
79
+ the interceptor recurses. Per-client scope (Compass ~post-2026-06-20, all others full), DRY_RUN /
80
+ DB_HOST_PREFERENCE / API_ENDPOINT_OVERRIDE knobs, failed-DB clients skipped + reported. Recorded the
81
+ catalog-by-name (Quad catalogId=2), `c_netsuiteInternalItemId`-not-a-GET-field, Compass DB-name, and
82
+ beta EV-9 deploy-gap gotchas. (bala)
83
+
84
+ ## Related docs
85
+ - [isFulfillable from NetSuite during item sync (Phase 1, library)](../../library/features/netsuite-item-isfulfillable-sync.md)
86
+ — where the value is sourced and the earlier Compass-only backfill.
87
+ - [isFulfillable propagation up the SO↔PO chain (Phase 2, _underscore)](../../../2.0/apps/_underscore/features/fulfillable-item-propagation.md)
88
+ — the api2 interceptor this backfill relies on to do the recursion.
89
+ - [NetSuite → TOGa Supply Per-Client Sync](../features/netsuite-togasupply-per-client-sync.md)
90
+ — the steady-state sync whose credentials/config this backfill reuses.
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-20
9
+ updated: 2026-07-28
10
10
  owners: [bala]
11
11
  files:
12
12
  - _underscore/Model/Client/Item.php
@@ -50,7 +50,14 @@ same bridge topology, walked for a different payload (a scalar flag rather than
50
50
  one-time backfill) send that field, so day-to-day item edits skip the walk entirely.
51
51
  2. **Load the source item** by uuid → `id` + `isFulfillable`. If the value is **NULL**, return
52
52
  (nothing authoritative to propagate).
53
- 3. **Walk the chain UP** with a `WITH RECURSIVE ChainSalesOrderItems` CTE:
53
+ - **Writer-routed (read-your-writes).** Both this source read **and** the chain-walk CTE below
54
+ call `->setisReadHostEnabled(false)` so they execute on the **writer**, not a read replica.
55
+ `_Query` routes SELECTs to a read replica by default (`Query.php` ~line 273) — a separate
56
+ connection that cannot see this request's still-open write transaction and can lag replication,
57
+ so a replica read could return the *pre-write* value (or NULL for a brand-new item) and
58
+ propagate the wrong value or skip. The UPDATE was already writer-routed; now read + update share
59
+ the one request transaction.
60
+ 3. **Walk the chain UP** with a `WITH RECURSIVE ChainSalesOrderItems` CTE (also writer-routed, see above):
54
61
  - **Anchor:** `SalesOrderItems WHERE itemId = <sourceItemId>`.
55
62
  - **Recursive step:** join `PurchaseOrderItems_SalesOrderItems` (PSO) then
56
63
  `SalesOrderItems_PurchaseOrderItems` (SPO) to climb one tier
@@ -93,6 +100,20 @@ uniformly `1` (see the Phase-1 gotcha: NetSuite flags nearly everything fulfilla
93
100
  - Engine itself is shared `_Model_Client_Item` logic; any client with the schema present gets it.
94
101
 
95
102
  ## Gotchas / known issues
103
+ - **⚠ Propagation reads MUST be writer-routed.** `_Query` SELECTs default to a read replica
104
+ (`Query.php` ~line 273), a separate connection that can't see the current request's open write
105
+ transaction and may lag replication. The interceptor runs inside the write request, so a
106
+ replica-routed read here can see the **pre-write** value (or NULL on a just-created item) and
107
+ propagate wrongly or skip. Both SELECTs in `propagateFulfillableAcrossChain` therefore set
108
+ `setisReadHostEnabled(false)`. Any future read added to an interceptor that must see the same
109
+ request's writes needs the same treatment.
110
+ - **⚠ Beta deploy gap — EV-9 "no write permission" (write ACL not deployed).** PUTting
111
+ `isFulfillable` to `api.beta.togahub.com` returns **EV-9**: the field *exists* there but its write
112
+ ACL was never deployed (only `dev-sandbox` had the full field + interceptor + ACL from earlier
113
+ work). The three pieces — Core RecordField, `ApiPayloadInterceptors` rows, **and** the client
114
+ column + write ACL — must all be present on the **same** environment (and a backfill run must scope
115
+ from that same cluster) or the write is rejected and nothing propagates. Reinforces the deploy-order
116
+ dependency: dbchanges (field + ACL + interceptors) before the code/backfill that writes the field.
96
117
  - **⚠ Interceptors are DB-driven.** The `postPost`/`postPut` PHP does nothing without the
97
118
  `ApiPayloadInterceptors` rows in the **target env's Core DB**. Missing rows → the hook never
98
119
  fires **and** api2 EV-8's the unknown `isFulfillable` field, **403-ing the whole request** (not
@@ -108,6 +129,12 @@ uniformly `1` (see the Phase-1 gotcha: NetSuite flags nearly everything fulfilla
108
129
  vs. using `itemtype` to hide services — is **pending product confirmation**.
109
130
 
110
131
  ## Change history
132
+ - 2026-07-28 — Fixed a **read-replica routing** bug: `propagateFulfillableAcrossChain`'s source-item
133
+ read and the recursive chain-walk CTE now `setisReadHostEnabled(false)` so they run on the writer
134
+ and see the request's own open write transaction (read-your-writes) — a replica read could return
135
+ the pre-write value or NULL and propagate wrongly/skip. Recorded the **beta EV-9 write-ACL deploy
136
+ gap** (field present but write ACL undeployed on `api.beta.togahub.com`; all three deploy pieces
137
+ must land on the same env). (bala)
111
138
  - 2026-07-20 — Built the isFulfillable up-chain propagation engine:
112
139
  `propagateFulfillableAcrossChain` + the payload-gated `postPost`/`postPut` interceptor on Items
113
140
  (recursive SOI↔POI CTE, diff-only `UPDATE`), plus the Compass `parent::` fix so the price-override
@@ -3,6 +3,7 @@
3
3
  | Doc | Summary | Files |
4
4
  |-----|---------|-------|
5
5
  | [TOGa View Frontend (toga2-view) Architecture](architecture.md) | `toga2-view` is the **React/TypeScript single-page frontend** for the TOGa 2.0 platform — the customer-facing web app (home warranty / tech-support portals). | toga2-view/src/main.tsx, toga2-view/src/App.tsx, toga2-view/src/routes.tsx, toga2-view/src/api/axiosInstance.ts, toga2-view/src/api/apiFunctions.ts, toga2-view/src/utils/queryHelpers.ts, toga2-view/src/contexts/AuthContext.tsx, toga2-view/src/contexts/useUserStore.ts, toga2-view/src/hooks/useAuthenticationFlow.ts, toga2-view/vite.config.ts, toga2-view/package.json |
6
+ | [Get Support — Subscription Details & Cancel Subscription Gating](features/get-support-cancel-subscription.md) | The Get Support page (`/get-support?itemUuid=…`) shows a **subscription details card** (plan · price · renewal date) for the service the user clicked, and — **o | toga2-view/src/pages/GetSupport/viewModels/getSupportPageViewModel.ts, toga2-view/src/pages/GetSupport/view/GetSupportPage.tsx, toga2-view/src/pages/GetSupport/viewModels/DUMMYFIELDS/SUPPORTDUMMYFIELDS.json, toga2-view/src/constants/featureFlags.ts, toga2-view/src/hooks/useBundleServices.ts |
6
7
  | [Mobile Nav Header](features/mobile-nav-header.md) | The `MobileNavToggle` component renders the fixed 72 px header (hamburger + Rate logo) and the slide-in drawer nav. | toga2-view/src/components/MobileNav/MobileNav.tsx, toga2-view/public/assets/Rate_Logo.svg |
7
8
  | [/v2 Query-String Builder (assembleOptions / where coercion)](features/query-string-builder.md) | `src/utils/queryHelpers.ts` converts a structured JS options object (`fields`, `where`, `join`/`ojoin`, `sort`, …) into the `api2` `/v2` query string. | toga2-view/src/utils/queryHelpers.ts, toga2-view/src/utils/queryHelpers.test.ts |
8
9
  | [Service Card Component](features/service-card.md) | The `ServiceCard` component renders a single service subscription (tech support or home warranty) on both the Home and Services pages. | toga2-view/src/components/ServiceCard/ServiceCard.tsx, toga2-view/src/pages/Services/view/ServicesPage.tsx, toga2-view/src/pages/Home/view/HomePage.tsx, toga2-view/src/constants/bundleConstants.ts, toga2-view/src/pages/Services/viewModels/DUMMYFIELDS/SERVICESDUMMYFIELDS.json |
@@ -0,0 +1,145 @@
1
+ ---
2
+ title: "Get Support — Subscription Details & Cancel Subscription Gating"
3
+ framework: "2.0"
4
+ repo: toga2-view
5
+ project: TOGa View Frontend
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-07-28
10
+ owners: ["tcox"]
11
+ files:
12
+ - toga2-view/src/pages/GetSupport/viewModels/getSupportPageViewModel.ts
13
+ - toga2-view/src/pages/GetSupport/view/GetSupportPage.tsx
14
+ - toga2-view/src/pages/GetSupport/viewModels/DUMMYFIELDS/SUPPORTDUMMYFIELDS.json
15
+ - toga2-view/src/constants/featureFlags.ts
16
+ - toga2-view/src/hooks/useBundleServices.ts
17
+ related:
18
+ - ../architecture.md
19
+ - ../../../../clients/rate/features/service-card-entitlements.md
20
+ - ../../../../clients/rate/profile.md
21
+ ---
22
+
23
+ ## Summary
24
+
25
+ The Get Support page (`/get-support?itemUuid=…`) shows a **subscription details card**
26
+ (plan · price · renewal date) for the service the user clicked, and — **only for tech-support
27
+ services** — a **Cancel Subscription** button plus a confirmation modal. Whole Home (WH) warranty
28
+ services and unclassified services get the subscription details card **without** any cancel path.
29
+
30
+ Two things about this flow are easy to get wrong and are the reason it is documented:
31
+
32
+ - The cancel affordance exists **only on this page** — `ServiceCard` has none.
33
+ - **There is no backend cancellation endpoint yet.** The viewModel's `cancelSubscription()` is a
34
+ stub that always rejects, so confirming surfaces an error instead of faking success.
35
+
36
+ ## Key files / entry points
37
+
38
+ - `pages/GetSupport/viewModels/getSupportPageViewModel.ts` — resolves `activeService`, builds the
39
+ `subscription` object, exposes `canCancelSubscription` and the `cancelSubscription()` stub
40
+ - `pages/GetSupport/view/GetSupportPage.tsx` — the **only** place a cancel affordance is rendered;
41
+ computes `showCancelUi` and owns the confirm-modal state (`showCancelModal`, `cancelError`)
42
+ - `constants/featureFlags.ts` — `CANCEL_SUBSCRIPTION_ENABLED` (currently `true`); flipping it to
43
+ `false` hides the whole cancel path in one place
44
+ - `viewModels/DUMMYFIELDS/SUPPORTDUMMYFIELDS.json` — copy: `renewsPrefix`, `cancelNote`
45
+ ("You can cancel anytime. Coverage continues until the end of your current period."),
46
+ `confirm.*` (including `confirm.errorMessage`)
47
+ - `hooks/useBundleServices.ts` — `detectServiceType()`, the classification the gate reuses
48
+
49
+ ## How it works
50
+
51
+ 1. **`activeService` resolution.** The viewModel picks the bundle service whose `uuid` matches the
52
+ URL's `itemUuid`, falls back to the first service with `isActive`, else `null`:
53
+ ```ts
54
+ const activeService =
55
+ bundleServices?.find((service) => service.uuid === itemUuid) ??
56
+ bundleServices?.find((service) => service.isActive) ??
57
+ null;
58
+ ```
59
+ The subscription card **and** the cancel gate both read this same `activeService`, so older links
60
+ without a resolvable `itemUuid` behave consistently across both.
61
+
62
+ 2. **Subscription details.** `subscription` = `{ plan, price, renewalDate, entitlementUuid }` from
63
+ `activeService`, else `null`. The card renders whenever `subscription` exists — **including for
64
+ warranty services**. Gating cancel does not hide the plan/price/renewal information.
65
+
66
+ 3. **The gate (positive, not negative).**
67
+ ```ts
68
+ const canCancelSubscription = activeService?.serviceType === "tech";
69
+ ```
70
+ `'warranty'` **and** `'other'` therefore both hide it — an unclassified service fails closed.
71
+ `serviceType` comes from `detectServiceType(bundleName || itemTitle)` in `useBundleServices`
72
+ (title-substring classification: contains "warranty"/"whole home" and not "tech" → `'warranty'`;
73
+ contains "tech"/"support" → `'tech'`; else `'other'`). This is the same signal that switches
74
+ `ServiceCard`'s second data row between **Price** (tech) and **Address** (warranty), so the cancel
75
+ gate and the service cards agree by construction.
76
+
77
+ 4. **The view gates three things with one boolean.**
78
+ ```ts
79
+ const showCancelUi = CANCEL_SUBSCRIPTION_ENABLED && canCancelSubscription;
80
+ ```
81
+ - the Cancel Subscription button
82
+ - the confirm modal (`showCancelUi && showCancelModal`)
83
+ - the `cancelNote` sentence appended to the `subscriptionDetails` string
84
+
85
+ The note is gated deliberately: leaving *"You can cancel anytime…"* on a card with no cancel
86
+ button is self-contradictory copy.
87
+
88
+ 5. **Confirm flow.** `handleConfirmCancel()` awaits `cancelSubscription()`; it closes the modal
89
+ **only** on a genuine resolve, and on rejection keeps the modal open and renders
90
+ `subscriptionFields.confirm.errorMessage`. Since the stub always throws, today's real-world
91
+ outcome is always the error state — intentional, so nothing reports a cancellation that did not
92
+ happen.
93
+
94
+ ## Decision — gate on `serviceType`, not on `isRateWarranty`
95
+
96
+ The page already carries a separate warranty signal: `isRateWarranty = bundleUuid ===
97
+ HOME_WARRANTY_BUNDLE_UUID`, used to route the **TICKETS** support method to the claims-style
98
+ create-ticket link. It was deliberately **not** reused for the cancel gate:
99
+
100
+ - `serviceType` is a *product classification*, so any service classified `'tech'` gets cancel —
101
+ it does not need a bundle uuid allowlist maintained per product.
102
+ - It keeps this page consistent with the service cards, which already branch on `serviceType`.
103
+ - A bundle-uuid check would silently fail open for any new warranty bundle uuid.
104
+
105
+ **Why WH is excluded at all:** Whole Home warranty contracts (the AIG-backed warranty product) are
106
+ not self-service cancellable from the portal; only tech-support subscriptions are.
107
+
108
+ ## Gotchas / known issues
109
+
110
+ - **The cancel UI lives only on `GetSupportPage.tsx`.** `ServiceCard` has no cancel affordance —
111
+ do not go looking for one in `components/`, and do not add a second entry point without
112
+ reproducing the same `showCancelUi` gate.
113
+ - **No backend endpoint exists.** `cancelSubscription()` throws
114
+ `"Subscription cancellation is not available yet."` by design (reject, never resolve). Swap the
115
+ body for the real API call when the endpoint ships; do not "fix" it by resolving.
116
+ - **`detectServiceType` now has a second consumer with a policy consequence.** Widening its
117
+ `'tech'` keywords does not just change a card row any more — it **exposes the cancel button** for
118
+ whatever newly matches. Check both call sites before touching the classifier.
119
+ - **The gate fails closed on `'other'`.** If a legitimately cancellable service stops matching
120
+ "tech"/"support" in its bundle/item title, its cancel button silently disappears. The title is the
121
+ only input.
122
+ - **`cancelNote` is not standalone copy** — it is concatenated into `subscriptionDetails` and gated
123
+ by `showCancelUi`. Any new surface rendering that string must gate it the same way.
124
+ - **`CANCEL_SUBSCRIPTION_ENABLED = false` hides button + modal + note but keeps the subscription
125
+ details card** — that is the intended kill-switch behavior.
126
+ - **Never close the confirm modal on failure.** Closing it reads to the user as a successful
127
+ cancellation.
128
+
129
+ ## Client variations
130
+
131
+ Rate is the consumer of this page today. Its product mix — Whole Home Warranty (AIG-backed) plus
132
+ tech support — is what makes the gate load-bearing: both products appear as active services for the
133
+ same user, so the page must distinguish them per service rather than per account. See
134
+ [Rate profile](../../../../clients/rate/profile.md) and
135
+ [Service Card Entitlement Display](../../../../clients/rate/features/service-card-entitlements.md).
136
+
137
+ ## Change history
138
+
139
+ - 2026-07-28 — Cancel Subscription UI gated by service type: viewModel exposes
140
+ `canCancelSubscription` (`activeService?.serviceType === "tech"`), and the view's `showCancelUi`
141
+ now gates the button, the confirm modal, and the `cancelNote` sentence. WH warranty and
142
+ unclassified services keep the subscription details card with no cancel path. Also first capture of
143
+ the pre-existing flow: cancel exists only on `GetSupportPage`, is flagged by
144
+ `CANCEL_SUBSCRIPTION_ENABLED`, and `cancelSubscription()` is a stub that always rejects until the
145
+ backend endpoint ships. (tcox)
@@ -5,7 +5,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
5
5
  ## 1.0 framework
6
6
 
7
7
  - **library** (Library) _(framework core)_ — 14 doc(s) → [1.0/apps/library/INDEX.md](1.0/apps/library/INDEX.md)
8
- - **worker** (Worker) — 16 doc(s) → [1.0/apps/worker/INDEX.md](1.0/apps/worker/INDEX.md)
8
+ - **worker** (Worker) — 17 doc(s) → [1.0/apps/worker/INDEX.md](1.0/apps/worker/INDEX.md)
9
9
  - **worker1.5** (Worker 1.5) — 0 doc(s) → [1.0/apps/worker1.5/INDEX.md](1.0/apps/worker1.5/INDEX.md)
10
10
  - **togadesk** (TOGa Desk) — 10 doc(s) → [1.0/apps/togadesk/INDEX.md](1.0/apps/togadesk/INDEX.md)
11
11
  - **togaview** (TOGa View) — 6 doc(s) → [1.0/apps/togaview/INDEX.md](1.0/apps/togaview/INDEX.md)
@@ -23,7 +23,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
23
23
  - **dbchanges2** (Database Changes) _(framework core)_ — 3 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
24
24
  - **toga2-supply** (TOGa Supply) — 3 doc(s) → [2.0/apps/toga2-supply/INDEX.md](2.0/apps/toga2-supply/INDEX.md)
25
25
  - **saml** (SAML SSO Gateway) — 3 doc(s) → [2.0/apps/saml/INDEX.md](2.0/apps/saml/INDEX.md)
26
- - **toga2-view** (TOGa View Frontend) — 6 doc(s) → [2.0/apps/toga2-view/INDEX.md](2.0/apps/toga2-view/INDEX.md)
26
+ - **toga2-view** (TOGa View Frontend) — 7 doc(s) → [2.0/apps/toga2-view/INDEX.md](2.0/apps/toga2-view/INDEX.md)
27
27
  - **toga2-hub** (TOGa Hub) — 2 doc(s) → [2.0/apps/toga2-hub/INDEX.md](2.0/apps/toga2-hub/INDEX.md)
28
28
  - **talos** (TOGa IQ) — 7 doc(s) → [2.0/apps/talos/INDEX.md](2.0/apps/talos/INDEX.md)
29
29
  - **voice-to-voice** (TOGa Voice) — 4 doc(s) → [2.0/apps/voice-to-voice/INDEX.md](2.0/apps/voice-to-voice/INDEX.md)
@@ -6,7 +6,7 @@ project: TOGa View Frontend
6
6
  client: rate
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-07-27
9
+ updated: 2026-07-28
10
10
  owners: ["bala", "tcox", "mhammontree"]
11
11
  files:
12
12
  - src/components/ServiceCard/ServiceCard.tsx
@@ -24,6 +24,7 @@ files:
24
24
  related:
25
25
  - clients/rate/profile.md
26
26
  - whole-home-warranty-purchase-guard.md
27
+ - 2.0/apps/toga2-view/features/get-support-cancel-subscription.md
27
28
  ---
28
29
 
29
30
  ## Summary
@@ -101,6 +102,11 @@ This feature is Rate-specific. The warranty card showing Address instead of Pric
101
102
  by Rate's product mix (Whole Home Warranty + Tech Support). Other clients with different
102
103
  product types would need `detectServiceType` extended or overridden.
103
104
 
105
+ **`detectServiceType` is no longer display-only.** As of 2026-07-28 it also gates the
106
+ **Cancel Subscription** UI on the Get Support page (`serviceType === 'tech'` — WH warranty and
107
+ unclassified services get no cancel path). See
108
+ [Get Support — Subscription Details & Cancel Subscription Gating](../../../2.0/apps/toga2-view/features/get-support-cancel-subscription.md).
109
+
104
110
  ## Gotchas / known issues
105
111
 
106
112
  - **`ojoin` is required for address** — using INNER JOIN (`join`) drops every entitlement
@@ -115,6 +121,10 @@ product types would need `detectServiceType` extended or overridden.
115
121
  `AddSubscriptionSheet`) replaces the old "Get Started" inactive bundle cards.
116
122
  - **`useBundleServices` is shared** — both `useHomePageViewModel` and `useServicePageViewModel`
117
123
  call it. Changes to the hook affect both pages.
124
+ - **Widening `detectServiceType`'s `'tech'` keywords now changes entitlement *permissions*, not just
125
+ a card row.** The Get Support page gates its Cancel Subscription button on
126
+ `serviceType === 'tech'`, so anything newly classified as tech becomes self-service cancellable.
127
+ Review both consumers (`ServiceCard` row 2 and the cancel gate) before editing the classifier.
118
128
  - **Show the entitlement's OWN pin only — never the contact's shared address.** The removed
119
129
  `contact.primaryContactAddress` fallback made every unpinned card display the same (latest)
120
130
  address because that field is one shared, purchase-overwritten value per contact. An unpinned
@@ -137,6 +147,9 @@ product types would need `detectServiceType` extended or overridden.
137
147
 
138
148
  ## Change history
139
149
 
150
+ - 2026-07-28 — `detectServiceType` gained a second, non-display consumer: the Get Support page gates
151
+ its Cancel Subscription UI on `serviceType === 'tech'`, so the classifier now decides which
152
+ entitlements are self-service cancellable. (tcox)
140
153
  - 2026-07-27 — TRUE-79533: recorded that `fetchAddressesByIds`
141
154
  (`GET /v2/addresses?fields=id,line1,...`) returned **403 `EZ-2`** because the **`Addresses.id`
142
155
  field** had no read ACL grant in `Client_Rate` — the numeric `id` is ACL-gated like any field. Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.467",
3
+ "version": "1.0.469",
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",