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.
- package/knowledge/1.0/apps/library/INDEX.md +1 -1
- package/knowledge/1.0/apps/library/features/netsuite-item-isfulfillable-sync.md +47 -16
- package/knowledge/1.0/apps/worker/INDEX.md +1 -0
- package/knowledge/1.0/apps/worker/workflows/isfulfillable-multi-client-backfill.md +90 -0
- package/knowledge/2.0/apps/_underscore/features/fulfillable-item-propagation.md +29 -2
- package/knowledge/2.0/apps/toga2-view/INDEX.md +1 -0
- package/knowledge/2.0/apps/toga2-view/features/get-support-cancel-subscription.md +145 -0
- package/knowledge/INDEX.md +2 -2
- package/knowledge/clients/rate/features/service-card-entitlements.md +14 -1
- package/package.json +1 -1
|
@@ -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-
|
|
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`
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
- `
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
- `
|
|
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. `
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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-
|
|
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
|
-
|
|
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)
|
package/knowledge/INDEX.md
CHANGED
|
@@ -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) —
|
|
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) —
|
|
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-
|
|
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