toga-ai 1.0.520 → 1.0.522
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/2.0/apps/_underscore/INDEX.md +2 -2
- package/knowledge/2.0/apps/_underscore/features/acl-permission-chain.md +54 -1
- package/knowledge/2.0/apps/_underscore/features/component-model-namespace-registration.md +32 -2
- package/knowledge/2.0/apps/_underscore/features/model-save-vs-query-atomic-update.md +54 -2
- package/knowledge/2.0/apps/api2/features/record-scripts.md +39 -1
- package/knowledge/2.0/apps/toga2-view/INDEX.md +1 -1
- package/knowledge/2.0/apps/toga2-view/architecture.md +13 -2
- package/knowledge/2.0/apps/toga2-view/features/get-support-cancel-subscription.md +24 -6
- package/knowledge/clients/rate/INDEX.md +1 -1
- package/knowledge/clients/rate/features/aig-contract-creation.md +13 -1
- package/knowledge/clients/rate/features/paypal-subscription-purchase-webhook.md +2 -2
- package/knowledge/clients/rate/features/service-card-entitlements.md +20 -1
- package/knowledge/sessions/2026-08-04-surface-approval-details-layout-apeterson.md +69 -0
- package/package.json +1 -1
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
| [Carrier Shipping Labels (UPS/FedEx) & NetSuite Item Fulfillment](features/carrier-shipping-labels.md) | Backend mechanics behind TOGa Supply's Fulfill & Ship: buying a carrier label (UPS/FedEx), persisting it, and creating the NetSuite Item Fulfillment with tracki | _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Client/TrackingNumber.php, _underscore/Model/Client/ItemFulfillments/TrackingNumber.php, _underscore/Component/Library/LabelPdf/LabelPdf.php, _underscore/Component/Library/Carriers/ShipmentRequest/ShipmentRequest.php, _underscore/Component/Library/Carriers/Ups/Ups.php, _underscore/Component/Library/Carriers/Fedex/Fedex.php, _underscore/Trait/Netsuite/ItemFulfillment.php, _underscore/Trait/Netsuite/SalesOrder.php, _underscore/Component/Library/NetSuite/NetSuite.php, _underscore/Model/Client/TrackingNumber.php, _underscore/Model/Client/ShippingMethod.php, _underscore/Model.php, _underscore/Cloud.php |
|
|
14
14
|
| [Running 2.0 code from a bare CLI script (bootstrap + transactions)](features/cli-script-bootstrap.md) | A throwaway CLI script (a data check, a backfill dry-run, a render harness) that wants the real 2.0 framework — `_Model`, `_Query`, `_Database` — is **not** the | _underscore/Database.php, _underscore/Environment.php, api2/Initialize.php |
|
|
15
15
|
| [_Cloud S3 helpers (copy / get / delete / list)](features/cloud-s3-helpers.md) | `_Cloud` centralizes AWS SDK S3 usage for the 2.0 stack so the `S3Client` never leaks into workers or app code. | _underscore/Cloud.php |
|
|
16
|
-
| [_Component_*/_Model_* project-namespace registration (autoloader) & backslash-qualify traps](features/component-model-namespace-registration.md) | Every **project-local** `_Component_*` and `_Model_*` class in a 2.0 app **must declare the project namespace** at the top of the file: ```php namespace <NAMESP | _underscore/Loader.php, worker2/_.php, api2/_.php, worker2/Component/Forecast/Db/Db.php, worker2/Component/Forecast/SaleImport/SaleImport.php, api2/Component/Api/Netsuite/Netsuite.php |
|
|
16
|
+
| [_Component_*/_Model_* project-namespace registration (autoloader) & backslash-qualify traps](features/component-model-namespace-registration.md) | Every **project-local** `_Component_*` and `_Model_*` class in a 2.0 app **must declare the project namespace** at the top of the file: ```php namespace <NAMESP | _underscore/Loader.php, worker2/_.php, api2/_.php, worker2/Component/Forecast/Db/Db.php, worker2/Component/Forecast/SaleImport/SaleImport.php, api2/Component/Api/Netsuite/Netsuite.php, _underscore/Component/Api/Paypal/Paypal.php |
|
|
17
17
|
| [Re-pointing a DB alias mid-request (_Database::register park/restore)](features/database-alias-repointing.md) | `_Database` keys **all live per-database runtime state by the connection ALIAS** (`Client` / `_underscore::DB_CLIENT`, `ClientLogs`, `Archive`), **not** by the | _underscore/Database.php, _underscore/Query.php, api2/Component/Api/V2/V2.php, api2/Component/Api/CrossClient/CrossClient.php |
|
|
18
18
|
| [2.0 Email Send Pipeline (queue + Send worker)](features/email-send-pipeline.md) | In 2.0, `_Email::send()` **does not transmit** — it queues the message. | _underscore/Email.php, worker2/Worker/Infrastructure/Email/Send.php |
|
|
19
19
|
| [Client Email Template Sending](features/email-template-sending.md) | `_Model_Client_EmailTemplate` sends a stored, client-defined email template by UUID. | _underscore/Model/Client/EmailTemplate.php, _underscore/Model/Client/EmailTemplateOutgoingEmailAddress.php, _underscore/Email.php |
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
| [Item-Fulfillment Stage Lifecycle (picked/packed/shipped) & Order Status](features/item-fulfillment-stage-lifecycle-and-order-status.md) | Every ItemFulfillment (IF) now carries an explicit **stage** — picked → packed → shipped — resolved through `ItemFulfillmentStages → ItemFulfillmentStatuses` (m | _underscore/Model/Client/SalesOrder.php, _underscore/Model/Quad/SalesOrder.php, _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/SalesOrderStatus.php, _underscore/Model/Client/SalesOrderItem.php, _underscore/Model/Client/Item.php, _underscore/Model/Client/PurchaseOrderItem.php, library/app/api/toga2.php, dbchanges2/Client/2026-06-30a - BackfillNullStageItemFulfillmentsToShipped.sql, dbchanges2/Client/2026-06-30b - SalesOrderStatusesPickedPacked.sql, dbchanges2/Client/2026-06-30c - ItemFulfillmentStageIdNotNull.sql, dbchanges2/Client_CompassCanada/2026-06-30a - ItemFulfillmentLifecycleAndShippedBackfill.sql |
|
|
25
25
|
| [DB-free unit testing for _underscore model interceptors](features/model-interceptor-unit-testing.md) | `_underscore` shipped with **no** PHPUnit setup (no `composer.json`/`phpunit`; only vendored PhpOffice tests existed). | _underscore/Test/bootstrap.php, _underscore/Test/Prudential/ServiceRequestTest.php |
|
|
26
26
|
| [_Model magic-field access (__get without __isset)](features/model-magic-field-access.md) | `_Model` exposes DB columns as "magic" properties via `__get()`, but it defines **no** `__isset()`. | _underscore/Model/Core/Model.php |
|
|
27
|
-
| [_Model::save() vs raw _Query — no atomic conditional update](features/model-save-vs-query-atomic-update.md) | `_Model::save()` is a plain load-then-write ORM primitive and **cannot express an atomic conditional update** (an optimistic-concurrency / row-claim guard such | _underscore/Model.php, _underscore/Query.php |
|
|
27
|
+
| [_Model::save() vs raw _Query — no atomic conditional update](features/model-save-vs-query-atomic-update.md) | `_Model::save()` is a plain load-then-write ORM primitive and **cannot express an atomic conditional update** (an optimistic-concurrency / row-claim guard such | _underscore/Model.php, _underscore/Query.php, _underscore/Model/Rate/Subscription.php |
|
|
28
28
|
| [NetSuite REST Client (_Component_Api_Netsuite) — record writes & SuiteQL](features/netsuite-rest-client.md) | `_Component_Api_Netsuite` is the **2.0 `_underscore` NetSuite REST client** — the shared primitive every worker2/api2 NetSuite caller uses for record GETs, Suit | _underscore/Component/Api/Netsuite/Netsuite.php |
|
|
29
29
|
| [Per-Client Database Connections & the Local Logs Trap](features/per-client-database-connections.md) | When `_underscore` serves a request for a client it opens **three distinct per-client database connections**, not one. | _underscore/Database.php, _underscore/Query.php, _underscore/ApiRequest.php, _underscore/Model/Client/Logs/Api.php, api2/Controller/Index.php |
|
|
30
30
|
| [Persona Name Translation (PersonaTranslations sidecar)](features/persona-name-translation.md) | Serves Persona **names** in multiple languages by adding a per-language **sidecar** table `PersonaTranslations`, reusing the platform's existing metadata-driven | _underscore/Model/Client/PersonaTranslation.php, dbchanges2/Client/2026-07-22b - PersonaTranslations.sql, dbchanges2/Core/2026-07-22a - PersonaTranslationsRecord.sql, dbchanges2/Client/2026-07-22c - PersonaTranslationsAcl.sql, dbchanges2/Client_CompassCanada/2026-07-22 - PersonaTranslationsFrench.sql, toga2-commerce/src/pages/Account/view/MySettingsView.tsx |
|
|
@@ -6,7 +6,7 @@ project: _Underscore
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-08-
|
|
9
|
+
updated: 2026-08-04
|
|
10
10
|
owners: ["jcardinal", "mhammontree", "tcox"]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Component/Api/V2/V2.php
|
|
@@ -75,6 +75,47 @@ writable). Without it the field is rejected on write **and** is unreadable:
|
|
|
75
75
|
role 1 actually reads the field). Grant `isWritable = 0` for a server-written field; resolve the role
|
|
76
76
|
by name/subselect (ids differ per client DB). Beta-proven on Rate `Client_Rate` 2026-07-23.
|
|
77
77
|
|
|
78
|
+
### ⚠ An ungranted field is ABSENT, not null — and an ungranted JOINED field fails the WHOLE request
|
|
79
|
+
|
|
80
|
+
Two distinct failure shapes, and the second is a page-killer:
|
|
81
|
+
|
|
82
|
+
- **A directly-requested field with no grant is simply missing from the response object** — it is
|
|
83
|
+
not `null`, so `'field' in obj` is false and any consumer that spreads or destructures gets
|
|
84
|
+
`undefined`. Code that checks `=== null` will not detect it.
|
|
85
|
+
- **A requested JOINED field with no grant fails the ENTIRE request with `EZ-2`.** One missing grant
|
|
86
|
+
on one joined column therefore blanks every record the page asked for — not just that column.
|
|
87
|
+
|
|
88
|
+
**Two practices follow directly:**
|
|
89
|
+
|
|
90
|
+
1. **Mirror grants from a sibling field that is PROVABLY READ by every consumer you need — never
|
|
91
|
+
hardcode a role name.** A wrong role name **inserts cleanly and fails silently** (no FK error, no
|
|
92
|
+
rows matched, no signal). For Rate's cancellation columns the mirror was `Subscriptions.dateEnd`,
|
|
93
|
+
chosen because it is demonstrably read by **both** worker2 and the portal — so any role that can
|
|
94
|
+
see a subscription's end date can see its cancellation state.
|
|
95
|
+
2. **Request non-essential fields as OPTIONAL and drop only the rejected field on `EZ-2`/`EV-8`.**
|
|
96
|
+
Then a missing grant degrades one feature instead of failing the page. `toga2-view`'s
|
|
97
|
+
`src/api/apiErrors.ts` is the precedent (see
|
|
98
|
+
[service card entitlements](../../../../clients/rate/features/service-card-entitlements.md)).
|
|
99
|
+
This is also why an API is sometimes designed to take a *different* identifier: Rate's cancel
|
|
100
|
+
endpoint takes the **entitlement** uuid precisely to avoid requesting the joined
|
|
101
|
+
`subscription.uuid` and betting the whole entitlements page on one more grant.
|
|
102
|
+
|
|
103
|
+
### ⚠ Resolve `Core.RecordFields` ids by HARDCODED LITERAL in a `Client_*` migration — the subselect cannot run
|
|
104
|
+
|
|
105
|
+
This **overrides** the general "resolve by subselect, never hardcode" rule *for Core lookups from a
|
|
106
|
+
client-database file*. `Core` and `Client_*` are on **physically separate clusters**, so a
|
|
107
|
+
`SELECT … FROM Core.RecordFields` subselect inside a `Client_*` migration cannot resolve in
|
|
108
|
+
production — and locally the schema literally named `Core` is the **1.0** database, so it resolves
|
|
109
|
+
to the wrong schema there too. Either way the guarded `INSERT` matches nothing and **succeeds having
|
|
110
|
+
inserted zero rows**.
|
|
111
|
+
|
|
112
|
+
Use **hardcoded `Core.RecordFields` id literals** instead: those ids are explicitly seeded and
|
|
113
|
+
identical in every environment (the platform's only sanctioned hardcoded ids — see
|
|
114
|
+
`2.0/apps/dbchanges2/architecture.md`).
|
|
115
|
+
|
|
116
|
+
**Known-broken existing files (same root cause):** `Client_Rate/2026-07-23a` and
|
|
117
|
+
`Client/2026-07-16c`. A clean local run is never proof either worked.
|
|
118
|
+
|
|
78
119
|
### Standard field vs. custom (`c_`) field — TWO different permission tables
|
|
79
120
|
|
|
80
121
|
The permission table depends on whether the field is a platform **standard** field or a client
|
|
@@ -244,6 +285,18 @@ assumed from one.
|
|
|
244
285
|
|
|
245
286
|
## Change history
|
|
246
287
|
|
|
288
|
+
- **2026-08-04** — TRUE-80282: sharpened the ungranted-field failure shapes — a directly-requested
|
|
289
|
+
ungranted field is **absent, not null** (so `=== null` checks miss it), while an ungranted
|
|
290
|
+
**joined** field fails the **whole request** (`EZ-2`), so one grant can blank an entire page. Added
|
|
291
|
+
the two practices that follow: **mirror a sibling field provably read by every consumer you need**
|
|
292
|
+
(Rate's cancellation columns mirrored `Subscriptions.dateEnd`, read by both worker2 and the
|
|
293
|
+
portal) rather than hardcoding a role name — a wrong role name inserts cleanly and fails silently;
|
|
294
|
+
and **request non-essential fields as optional**, dropping only the rejected field on
|
|
295
|
+
`EZ-2`/`EV-8`. Also recorded the **exception to "resolve by subselect"**: a `Core.RecordFields`
|
|
296
|
+
subselect from inside a `Client_*` migration **cannot resolve** (separate clusters in prod; `Core`
|
|
297
|
+
is the 1.0 DB locally) and inserts zero rows without erroring — use hardcoded Core RecordFields id
|
|
298
|
+
literals. Flagged `Client_Rate/2026-07-23a` (and the previously known `Client/2026-07-16c`) as
|
|
299
|
+
broken by this pattern. (mhammontree)
|
|
247
300
|
- **2026-08-03** — TRUE-80494: reconfirmed the **silent-omission** read rule in a second environment
|
|
248
301
|
(`measures.measureType` ungranted in dev-sandbox `Client_Growrk` → absent from `GET /v2/measures` →
|
|
249
302
|
empty unit dropdowns, no error) and added the **verify-the-mirror-sibling** rule: a clone migration
|
|
@@ -6,8 +6,8 @@ project: _Underscore
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
10
|
-
owners: [dfranks]
|
|
9
|
+
updated: 2026-08-04
|
|
10
|
+
owners: [dfranks, mhammontree]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/Loader.php
|
|
13
13
|
- worker2/_.php
|
|
@@ -15,6 +15,7 @@ files:
|
|
|
15
15
|
- worker2/Component/Forecast/Db/Db.php
|
|
16
16
|
- worker2/Component/Forecast/SaleImport/SaleImport.php
|
|
17
17
|
- api2/Component/Api/Netsuite/Netsuite.php
|
|
18
|
+
- _underscore/Component/Api/Paypal/Paypal.php
|
|
18
19
|
related:
|
|
19
20
|
- ../architecture.md
|
|
20
21
|
- ./forecast-sale-import.md
|
|
@@ -40,6 +41,25 @@ is no config toggle. If the class is declared in the **global** namespace instea
|
|
|
40
41
|
**This is NOT caught by `php -l`** (parse-only) — it only surfaces at autoload/runtime, so
|
|
41
42
|
lint-clean code can still fatally throw on the first request that touches the class.
|
|
42
43
|
|
|
44
|
+
## ⚠ FILE LAYOUT — a `_Component_*` is a DIRECTORY, never a flat file
|
|
45
|
+
|
|
46
|
+
Before the namespace rule even applies, the autoloader has to *find* the file.
|
|
47
|
+
`_Loader::autoloader()` (`Loader.php:60-67`) **pops the last `_`-segment of the class name and
|
|
48
|
+
appends it as BOTH an extra directory AND the filename**:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
_Component_Api_Paypal → Component/Api/Paypal/Paypal.php ✅
|
|
52
|
+
→ Component/Api/Paypal.php ❌ fatal
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
A flat `Component/Api/Paypal.php` fatals with **"Could not find required file"** on first use — a
|
|
56
|
+
runtime-only failure, invisible to `php -l`, exactly like the namespace error below.
|
|
57
|
+
|
|
58
|
+
**Every existing `_Component_Api_*` is a directory** — `Toga`, `Clickup`, `Elite`, `Hubspot`,
|
|
59
|
+
`Netsuite`, `Startech`, `Wje`. Match that shape when adding a new one (first re-confirmed
|
|
60
|
+
2026-08-04 adding `_Component_Api_Paypal` for
|
|
61
|
+
[Rate subscription cancellation](../../../../clients/rate/features/subscription-cancellation.md)).
|
|
62
|
+
|
|
43
63
|
## How it works — `_underscore/Loader.php` (lines ~71-83)
|
|
44
64
|
For a project-local `_Component_*` / `_Model_*` class the autoloader:
|
|
45
65
|
1. `require`s the local file.
|
|
@@ -85,6 +105,9 @@ transactions, the symptom **presented as ~7 months of historical drift** rather
|
|
|
85
105
|
project-local `_Component_*`/`_Model_*` must still declare the project namespace.)
|
|
86
106
|
|
|
87
107
|
## Gotchas / known issues
|
|
108
|
+
- **A `_Component_*` placed as a flat file instead of `<Name>/<Name>.php` fatals with "Could not
|
|
109
|
+
find required file"** — the autoloader appends the trailing segment as both directory and
|
|
110
|
+
filename. Also runtime-only. Check the file *path* before debugging the namespace.
|
|
88
111
|
- `php -l` will **not** catch a missing/global namespace or an unqualified `catch`/`throw`/
|
|
89
112
|
`extends` — boot the class (autoload it) to verify. A wrong autoload path is likewise a
|
|
90
113
|
runtime-only failure.
|
|
@@ -92,6 +115,13 @@ project-local `_Component_*`/`_Model_*` must still declare the project namespace
|
|
|
92
115
|
unqualified `catch (Exception $e)` fails **silently** — the more insidious of the two.
|
|
93
116
|
|
|
94
117
|
## Change history
|
|
118
|
+
- 2026-08-04 — TRUE-80282: documented the **file-layout** half of `_Component_*` loading, which
|
|
119
|
+
precedes the namespace rule — `_Loader::autoloader()` (`Loader.php:60-67`) pops the trailing
|
|
120
|
+
`_`-segment and appends it as **both a directory and the filename**, so
|
|
121
|
+
`_Component_Api_Paypal` must live at `Component/Api/Paypal/Paypal.php`; a flat
|
|
122
|
+
`Component/Api/Paypal.php` fatals with "Could not find required file" on first use (runtime-only,
|
|
123
|
+
invisible to `php -l`). Confirmed every existing `_Component_Api_*` (Toga, Clickup, Elite, Hubspot,
|
|
124
|
+
Netsuite, Startech, Wje) is a directory. (mhammontree)
|
|
95
125
|
- 2026-07-08 — **Production recurrence** of this exact fatal (concrete fingerprint, no doc change
|
|
96
126
|
to the mechanism): a worker2 deploy declared `_Component_Forecast_SaleImport`
|
|
97
127
|
(`Worker/Netsuite/Invoice.php` autoload) and its sibling `_Component_Forecast_Db`
|
|
@@ -6,14 +6,16 @@ project: _Underscore
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
10
|
-
owners: ["dfranks"]
|
|
9
|
+
updated: 2026-08-04
|
|
10
|
+
owners: ["dfranks", "mhammontree"]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/Model.php
|
|
13
13
|
- _underscore/Query.php
|
|
14
|
+
- _underscore/Model/Rate/Subscription.php
|
|
14
15
|
related:
|
|
15
16
|
- ./model-magic-field-access.md
|
|
16
17
|
- ../../worker2/features/netsuite-opportunity-sync.md
|
|
18
|
+
- ../../../../clients/rate/features/subscription-cancellation.md
|
|
17
19
|
---
|
|
18
20
|
|
|
19
21
|
## Summary
|
|
@@ -57,8 +59,50 @@ write-drop). All **non-claim** writes stay on the ORM — `save()` is fine for p
|
|
|
57
59
|
load → set → save → `transactionCommit` by primary key (the idiom
|
|
58
60
|
`_Worker_Netsuite_Opportunity::delete()` already uses).
|
|
59
61
|
|
|
62
|
+
## ⚠ `initial` is never resynced after a save — so a SECOND `save()` on the same instance silently drops the column
|
|
63
|
+
|
|
64
|
+
`save()` only emits columns where **`initial !== value`** (`Model.php:244`), and `initial` is
|
|
65
|
+
populated **only at load/initialize** (`Model.php:702`) — it is **never refreshed after a save**.
|
|
66
|
+
Consequences on a **reused instance**:
|
|
67
|
+
|
|
68
|
+
```php
|
|
69
|
+
$m->f = $v; $m->save(); // UPDATE … SET f = $v (initial['f'] is still the ORIGINAL)
|
|
70
|
+
$m->f = null; $m->save(); // f is OMITTED entirely — the rollback does NOTHING
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The second write compares `null` against the *pre-first-save* `initial`, which was already `null`,
|
|
74
|
+
sees "no change", and emits no column. **Any "set it back and save" rollback / compensating write on
|
|
75
|
+
a reused `_Model` instance is a silent no-op.** This is broader than the no-diff rule below: the
|
|
76
|
+
diff is computed against stale state, not against what is actually in the database.
|
|
77
|
+
|
|
78
|
+
**It fails in the most dangerous possible way** — the code lints, reads correctly, and returns
|
|
79
|
+
success. Concrete cost: a PayPal-failure rollback in
|
|
80
|
+
[Rate subscription cancellation](../../../../clients/rate/features/subscription-cancellation.md)
|
|
81
|
+
would have left customers **marked cancelled while PayPal kept billing them**, with no error
|
|
82
|
+
anywhere.
|
|
83
|
+
|
|
84
|
+
**Fix — load a FRESH instance per write:**
|
|
85
|
+
|
|
86
|
+
```php
|
|
87
|
+
// Do NOT reuse the instance across writes. Reload, and defeat both caches so the
|
|
88
|
+
// reload cannot hand back the pre-write row from earlier in this same request.
|
|
89
|
+
\_Database::useQueryCache(false);
|
|
90
|
+
$model->setIsReadHostEnabled(false); // don't read the replica
|
|
91
|
+
// … load fresh, set the field, save.
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Both flags matter: the query cache can return the pre-write row within the same request, and the
|
|
95
|
+
read host is a replica that may not have the write yet. (`useQueryCache` is a **global** toggle —
|
|
96
|
+
capture and restore the prior value.)
|
|
97
|
+
|
|
98
|
+
Applies to **any** multi-write sequence on one instance, not just rollbacks: two-phase writes,
|
|
99
|
+
retry loops, and compensating transactions all need the reload.
|
|
100
|
+
|
|
60
101
|
## Gotchas / known issues
|
|
61
102
|
|
|
103
|
+
- **Never reuse a `_Model` instance for a second write** (see above) — `initial` is not resynced, so
|
|
104
|
+
the second `save()` drops the column and the write silently vanishes. Reload a fresh instance with
|
|
105
|
+
`useQueryCache(false)` + `setIsReadHostEnabled(false)`.
|
|
62
106
|
- Do **not** reach for `save()` to implement a row-claim / optimistic-concurrency guard — it silently
|
|
63
107
|
degrades to a non-atomic check-then-act because its `WHERE` is primary-key-only and it exposes no
|
|
64
108
|
affected-rows count. Use raw `_Query` + `getAffectedRows()`.
|
|
@@ -67,6 +111,14 @@ load → set → save → `transactionCommit` by primary key (the idiom
|
|
|
67
111
|
|
|
68
112
|
## Change history
|
|
69
113
|
|
|
114
|
+
- 2026-08-04 — TRUE-80282: documented that **`initial` is never resynced after a save**
|
|
115
|
+
(`Model.php:244` diffs on `initial !== value`; `initial` set only at load/initialize,
|
|
116
|
+
`Model.php:702`), so a **second `save()` on the same instance omits the column entirely** — every
|
|
117
|
+
"set it back to null and save" rollback / compensating write on a reused instance is a silent
|
|
118
|
+
no-op. Recorded the required fix (fresh instance per write, with `_Database::useQueryCache(false)`
|
|
119
|
+
+ `setIsReadHostEnabled(false)` so the reload can't return the pre-write row). Found via a PayPal
|
|
120
|
+
rollback that would have left Rate customers marked cancelled while still being billed.
|
|
121
|
+
(mhammontree)
|
|
70
122
|
- 2026-07-17 — Documented that `_Model::save()` cannot perform an atomic conditional update
|
|
71
123
|
(primary-key-only `WHERE`, skips no-diff writes, no affected-rows count) and that raw `_Query` +
|
|
72
124
|
`_Query::getAffectedRows()` is the required surface for any DB row-claim / optimistic-concurrency
|
|
@@ -6,7 +6,7 @@ project: API
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-08-
|
|
9
|
+
updated: 2026-08-04
|
|
10
10
|
owners: ["kyalamarthi", "mhammontree"]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Component/Api/V2/V2.php
|
|
@@ -218,6 +218,29 @@ CLIENT database as a `CustomRecordScript` — NOT in `Core.RecordScripts`.** Fir
|
|
|
218
218
|
- Verified by applying the file **twice** to two environments: exactly one script row and one
|
|
219
219
|
grant row each.
|
|
220
220
|
|
|
221
|
+
## 🔒 A scripted POST BYPASSES the record-level `AclRecordPermissions` check entirely
|
|
222
|
+
|
|
223
|
+
`V2.php:4472` guards the record-permission check with:
|
|
224
|
+
|
|
225
|
+
```php
|
|
226
|
+
if (!$isUsingScriptedCall && empty($routePairs)) { /* … record-level ACL check … */ }
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
So for a scripted call, **authorization comes solely from `AclCustomRecordScripts` /
|
|
230
|
+
`AclRecordScripts`** — the four-table record-CRUD chain is never consulted.
|
|
231
|
+
|
|
232
|
+
**⚠ Therefore: never add an `AclRecordPermissions` CREATE grant to "make a scripted POST work."**
|
|
233
|
+
It cannot be the cause, and it opens a real hole. Concrete case: adding a CREATE grant on
|
|
234
|
+
`Subscriptions` (to debug a 403 on `POST /v2/subscriptions/cancel`) would have let **any portal
|
|
235
|
+
customer insert arbitrary subscription rows via ordinary CRUD** — the script grant was the actual
|
|
236
|
+
missing piece.
|
|
237
|
+
|
|
238
|
+
**Debugging rule: an `EZ-1` on a scripted POST means the SCRIPT grant is missing** (or the
|
|
239
|
+
`RecordScripts`/`CustomRecordScripts` row doesn't match method+route), never a missing CREATE
|
|
240
|
+
permission. See the
|
|
241
|
+
[ACL permission chain](../../_underscore/features/acl-permission-chain.md) for the CRUD chain this
|
|
242
|
+
path deliberately skips.
|
|
243
|
+
|
|
221
244
|
## Data source note (core DB hosts/credentials)
|
|
222
245
|
|
|
223
246
|
The core cluster hosts and credentials this endpoint reads through are **not** documented here;
|
|
@@ -231,6 +254,13 @@ an `AclRecordScripts` grant in each client DB.
|
|
|
231
254
|
|
|
232
255
|
## Gotchas / known issues
|
|
233
256
|
|
|
257
|
+
- **A scripted route cannot carry an id BEFORE the script segment.** The dispatcher consumes the
|
|
258
|
+
**trailing path segment as the script name**, so `/v2/<record>/{id}/<script>` does not match —
|
|
259
|
+
pass the id in the query string or the JSON body instead. This is why Rate's cancel endpoint is
|
|
260
|
+
`POST /v2/subscriptions/cancel` with `{entitlementUuid}` in the body
|
|
261
|
+
([cancellation doc](../../../../clients/rate/features/subscription-cancellation.md)).
|
|
262
|
+
- **A scripted POST skips the record-level ACL check** — see the section above. Do not "fix" a
|
|
263
|
+
scripted-POST 403 with an `AclRecordPermissions` CREATE grant.
|
|
234
264
|
- **The response key is the ROUTE VERBATIM, not the `phpMethod`.** A script registered with
|
|
235
265
|
route `warranty-availability` and `phpMethod` `warrantyAvailability` returns
|
|
236
266
|
**`data.entitlements["warranty-availability"]`** — *not* `data.entitlements.warrantyAvailability`.
|
|
@@ -257,6 +287,14 @@ an `AclRecordScripts` grant in each client DB.
|
|
|
257
287
|
|
|
258
288
|
## Change history
|
|
259
289
|
|
|
290
|
+
- 2026-08-04 — TRUE-80282: documented that a **scripted POST bypasses the record-level
|
|
291
|
+
`AclRecordPermissions` check entirely** (`V2.php:4472` guards it with
|
|
292
|
+
`if (!$isUsingScriptedCall && empty($routePairs))`), so authorization rests solely on the script
|
|
293
|
+
grant — and that adding a CREATE grant to "fix" a scripted-POST `EZ-1` is both ineffective and a
|
|
294
|
+
privilege escalation (on `Subscriptions` it would let any portal customer insert subscription rows
|
|
295
|
+
via normal CRUD). Also recorded that a scripted route **cannot carry an id before the script
|
|
296
|
+
segment** (the trailing segment *is* the script name), so ids go in the query string or body.
|
|
297
|
+
(mhammontree)
|
|
260
298
|
- 2026-08-03 — TRUE-80494: documented that argument binding is **PHP named-argument unpacking of the
|
|
261
299
|
query string** (`parse_str` + merged JSON body, query wins, `$args['api']` last, then
|
|
262
300
|
`$model::$phpMethod(...$args)`; `V2.php` ~4202/~4468) and its two consequences: (a) a script
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
| Doc | Summary | Files |
|
|
4
4
|
|-----|---------|-------|
|
|
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 |
|
|
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/tsconfig.json, toga2-view/tsconfig.app.json, 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
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 |
|
|
7
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 |
|
|
8
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 |
|
|
@@ -6,9 +6,11 @@ project: TOGa View Frontend
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: architecture
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
10
|
-
owners: [apeterson]
|
|
9
|
+
updated: 2026-08-04
|
|
10
|
+
owners: [apeterson, mhammontree]
|
|
11
11
|
files:
|
|
12
|
+
- toga2-view/tsconfig.json
|
|
13
|
+
- toga2-view/tsconfig.app.json
|
|
12
14
|
- toga2-view/src/main.tsx
|
|
13
15
|
- toga2-view/src/App.tsx
|
|
14
16
|
- toga2-view/src/routes.tsx
|
|
@@ -148,6 +150,12 @@ Routes are defined in `src/routes.tsx` with `createBrowserRouter`:
|
|
|
148
150
|
- **Tooling:** `npm run dev` (Vite dev server, port 5173), `npm run build`
|
|
149
151
|
(`tsc && vite build --mode production`), plus `buildBeta` / `buildGamma`. `npm run lint`
|
|
150
152
|
runs ESLint with `--max-warnings 0`. E2E via Cypress (`npm run cypress`).
|
|
153
|
+
- **⚠ `npx tsc --noEmit` at the repo root checks NOTHING.** The root `tsconfig.json` is a
|
|
154
|
+
**references-only solution file**, so it type-checks zero files — a deliberately injected type
|
|
155
|
+
error still exits 0. This makes it a silent false-green in any pre-commit or CI-style check.
|
|
156
|
+
**Always type-check with `npx tsc -p tsconfig.app.json --noEmit`.** That config has
|
|
157
|
+
**pre-existing errors**, so the pass criterion is "no *new* errors vs. a stashed baseline", not
|
|
158
|
+
zero errors — diff against the baseline rather than expecting a clean run.
|
|
151
159
|
- **Many environments:** `.env.{development,alpha,beta,gamma,sprint,stage,test,production}`.
|
|
152
160
|
Each supplies the `VITE_API_*` base URLs consumed by `axiosInstance.ts`. A client/brand is
|
|
153
161
|
selected by hostname at runtime (not a separate build), via the `VITE_API_<SUBDOMAIN>`
|
|
@@ -169,6 +177,9 @@ Routes are defined in `src/routes.tsx` with `createBrowserRouter`:
|
|
|
169
177
|
page/viewModel that consumes it — check call sites before changing its shape.
|
|
170
178
|
- **Tokens live in `localStorage`** (`accessToken`/`refreshToken`/`user`). Treat them as
|
|
171
179
|
sensitive; never log token values or echo them into Sentry extras.
|
|
180
|
+
- Type-check with `npx tsc -p tsconfig.app.json --noEmit` — the root `tsconfig.json` is
|
|
181
|
+
references-only and checks nothing (false green). Compare against a stashed baseline; the app
|
|
182
|
+
config has pre-existing errors.
|
|
172
183
|
|
|
173
184
|
## Client notes
|
|
174
185
|
|
|
@@ -92,9 +92,15 @@ Two things about this flow are easy to get wrong and are the reason it is docume
|
|
|
92
92
|
|
|
93
93
|
5. **Confirm flow.** `handleConfirmCancel()` awaits `cancelSubscription()`; it closes the modal
|
|
94
94
|
**only** on a genuine resolve, and on rejection keeps the modal open and renders
|
|
95
|
-
`subscriptionFields.confirm.errorMessage`.
|
|
96
|
-
|
|
97
|
-
|
|
95
|
+
`subscriptionFields.confirm.errorMessage`. `cancelSubscription()` now issues the real
|
|
96
|
+
`POST /v2/subscriptions/cancel`, so a resolve means the server both wrote the cancellation
|
|
97
|
+
request **and** got a PayPal confirmation. Preserve the reject-on-anything-else behavior: the
|
|
98
|
+
server intentionally fails the call rather than half-cancelling.
|
|
99
|
+
|
|
100
|
+
6. **Post-cancellation display.** Once a cancellation is pending, the service card renders
|
|
101
|
+
**"Service ends by \<date\>"** instead of "Renews", and the Cancel affordance is hidden — the
|
|
102
|
+
subscription stays `isActive = 1` until its period ends, so the *absence* of a cancel button (not
|
|
103
|
+
the absence of the card) is what signals the pending state.
|
|
98
104
|
|
|
99
105
|
## Decision — gate on `serviceType`, not on `isRateWarranty`
|
|
100
106
|
|
|
@@ -115,9 +121,13 @@ not self-service cancellable from the portal; only tech-support subscriptions ar
|
|
|
115
121
|
- **The cancel UI lives only on `GetSupportPage.tsx`.** `ServiceCard` has no cancel affordance —
|
|
116
122
|
do not go looking for one in `components/`, and do not add a second entry point without
|
|
117
123
|
reproducing the same `showCancelUi` gate.
|
|
118
|
-
- **
|
|
119
|
-
|
|
120
|
-
|
|
124
|
+
- **The `serviceType === 'tech'` gate is a UX affordance, NOT the enforcement point.** The backend
|
|
125
|
+
refuses warranty cancellations independently, using the WH purchase guard's authority (title
|
|
126
|
+
keyword or `Items.id 3`). Do not weaken the server check because the button is already hidden.
|
|
127
|
+
- **The cancel endpoint takes `entitlementUuid`, not the subscription uuid.** Passing
|
|
128
|
+
`subscription.uuid` will not work and requesting that field would need a new joined-field ACL
|
|
129
|
+
grant whose absence fails the whole entitlements request. See the
|
|
130
|
+
[cancellation doc](../../../../clients/rate/features/subscription-cancellation.md).
|
|
121
131
|
- **`detectServiceType` now has a second consumer with a policy consequence.** Widening its
|
|
122
132
|
`'tech'` keywords does not just change a card row any more — it **exposes the cancel button** for
|
|
123
133
|
whatever newly matches. Check both call sites before touching the classifier.
|
|
@@ -141,6 +151,14 @@ same user, so the page must distinguish them per service rather than per account
|
|
|
141
151
|
|
|
142
152
|
## Change history
|
|
143
153
|
|
|
154
|
+
- 2026-08-04 — TRUE-80282: **the backend shipped**, so `cancelSubscription()` is no longer a
|
|
155
|
+
rejecting stub — it calls `POST /v2/subscriptions/cancel` with `{ entitlementUuid }` in the body.
|
|
156
|
+
Recorded that the `serviceType === 'tech'` gate is a UX affordance only (the server refuses
|
|
157
|
+
warranties on its own authority), that the argument is the **entitlement** uuid, and that a
|
|
158
|
+
pending cancellation renders "Service ends by \<date\>" with the Cancel affordance hidden while
|
|
159
|
+
the subscription stays active. Full backend behavior:
|
|
160
|
+
[Rate tech-support subscription cancellation](../../../../clients/rate/features/subscription-cancellation.md).
|
|
161
|
+
(mhammontree)
|
|
144
162
|
- 2026-07-28 — Cancel Subscription UI gated by service type: viewModel exposes
|
|
145
163
|
`canCancelSubscription` (`activeService?.serviceType === "tech"`), and the view's `showCancelUi`
|
|
146
164
|
now gates the button, the confirm modal, and the `cancelNote` sentence. WH warranty and
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
| [Rate AIG Warranty Contract Creation (silent-failure interceptor)](features/aig-contract-creation.md) | 2.0 | When a Rate entitlement is created, `_Model_Rate_Entitlement::postPost` (`_underscore/Model/Rate/Entitlement.php`) creates an **AIG warranty contract** as a non | _underscore/Model/Rate/Entitlement.php, _underscore/ApiRequest.php, worker2/Worker/Monitors/RateEntitlement.php |
|
|
6
6
|
| [Rate Monthly Reconciliation Report](features/monthly-reconciliation-report.md) | 1.0 | A monthly cron that emails an Excel reconciliation report covering all Rate subscription sales orders and their linked PayPal payments for the prior calendar mo | worker/crons/notifications/reports/rate/send_monthly_rate_purchases_report.php, worker/schedules/cron.worker.notification.json |
|
|
7
7
|
| [Rate SalesOrder → NetSuite CashSale Export (postPost)](features/netsuite-cashsale-export.md) | 2.0 | Rate sells home-warranty / home-tech-support products. | _underscore/Model/Rate/SalesOrder.php, _underscore/Model/Rate/Item.php |
|
|
8
|
-
| [Rate PayPal Subscription Purchase & Webhook Pipeline](features/paypal-subscription-purchase-webhook.md) | 2.0 | > # ⚠ STATUS (2026-08-04, TRUE-80575) — TWO INDEPENDENT LIVE DEFECTS > > **A. | worker2/Worker/Rate.php, worker2/Config/production.ini, api2/Config/production.ini, _underscore/Component/Api/Paypal.php, toga2-view/src/hooks/usePayPalSubscription.ts, toga2-view/src/services/paypalService.ts, toga2-view/src/pages/CheckOut/viewModel/useCheckoutPageViewModel.ts, toga2-view/src/pages/CheckOut/api/checkoutApi.ts, toga2-view/src/pages/Activation/view/Activation.tsx |
|
|
8
|
+
| [Rate PayPal Subscription Purchase & Webhook Pipeline](features/paypal-subscription-purchase-webhook.md) | 2.0 | > # ⚠ STATUS (2026-08-04, TRUE-80575) — TWO INDEPENDENT LIVE DEFECTS > > **A. | worker2/Worker/Rate.php, worker2/Config/production.ini, api2/Config/production.ini, _underscore/Component/Api/Paypal/Paypal.php, toga2-view/src/hooks/usePayPalSubscription.ts, toga2-view/src/services/paypalService.ts, toga2-view/src/pages/CheckOut/viewModel/useCheckoutPageViewModel.ts, toga2-view/src/pages/CheckOut/api/checkoutApi.ts, toga2-view/src/pages/Activation/view/Activation.tsx |
|
|
9
9
|
| [Rate SAML SSO](features/saml-sso.md) | 2.0 | Rate uses Azure AD as its IdP (`login.rate.com`). | _underscore/Model/Rate/ClientAuthentication.php, saml/Controller/Index.php, toga2-view/src/hooks/useAuthenticationFlow.ts |
|
|
10
10
|
| [Service Card Entitlement Display](features/service-card-entitlements.md) | 2.0 | Rate's home and services pages display one service card per purchased entitlement. | src/components/ServiceCard/ServiceCard.tsx, src/components/ServiceCard/index.ts, src/hooks/useBundleServices.ts, src/hooks/useActiveServices.ts, src/pages/Home/api/homeApi.ts, src/pages/Home/view/HomePage.tsx, src/pages/Home/viewModels/useHomePageViewModel.ts, src/pages/Services/view/ServicesPage.tsx, src/pages/Services/viewModels/useServicePageViewModel.ts, src/api/serviceAddressApi.ts, src/api/apiErrors.ts, dbchanges2/Client_Rate/2026-07-24a - AddressIdFieldPermission.sql |
|
|
11
11
|
| [Rate Service-Purchase Confirmation Emails (Tech / Warranty)](features/service-purchase-emails.md) | 2.0 | When a Rate customer purchases a service, a confirmation email is sent. | _underscore/Model/Rate/Entitlement.php, worker2/Worker/Notification/EmailTemplate.php, dbchanges2/Client_Rate/2026-06-30a - Rate purchase email templates.sql |
|
|
@@ -6,7 +6,7 @@ project: _Underscore
|
|
|
6
6
|
client: rate
|
|
7
7
|
type: client-feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
9
|
+
updated: 2026-08-04
|
|
10
10
|
owners: [mhammontree, tcox]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/Model/Rate/Entitlement.php
|
|
@@ -15,6 +15,7 @@ files:
|
|
|
15
15
|
related:
|
|
16
16
|
- clients/rate/profile.md
|
|
17
17
|
- clients/rate/features/whole-home-warranty-purchase-guard.md
|
|
18
|
+
- clients/rate/features/subscription-cancellation.md
|
|
18
19
|
- ../../../2.0/apps/worker2/features/monitoring-framework.md
|
|
19
20
|
---
|
|
20
21
|
|
|
@@ -99,6 +100,12 @@ creation vs. cancellation. This is an accepted, documented limitation — not an
|
|
|
99
100
|
address, so an entitlement with no address never even attempts contract creation (and
|
|
100
101
|
never logs a `/contract` call).
|
|
101
102
|
- **Auth-induced failures are blind to the monitor** — see the detection limitation above.
|
|
103
|
+
- **The AIG carrier cancel (`/contract/cancel`) is chained off the SAME
|
|
104
|
+
`BILLING.SUBSCRIPTION.CANCELLED` event** that worker2 uses to confirm a portal cancellation — so
|
|
105
|
+
the two flows share a trigger and a failure mode: while `Rate/Webhook` jobs are timing out, neither
|
|
106
|
+
the AIG cancel nor the `dateCancelled` stamp happens. See
|
|
107
|
+
[subscription cancellation](subscription-cancellation.md). **"AIG" here is the *carrier*, not the
|
|
108
|
+
`Client_Aig` tenant** — do not resolve it as a client slug.
|
|
102
109
|
- **The AIG-success branch REASSIGNS `$payload->uuid`** (`$payload->uuid = _String::generateUuid()`,
|
|
103
110
|
repurposed as the AIG contract number) — and it does so **in the same `postPost`** that pins the WH
|
|
104
111
|
service address. On the pre-redesign build the pin step ran **after** this reassignment and keyed
|
|
@@ -110,6 +117,11 @@ creation vs. cancellation. This is an accepted, documented limitation — not an
|
|
|
110
117
|
|
|
111
118
|
## Change history
|
|
112
119
|
|
|
120
|
+
- 2026-08-04 — TRUE-80282: noted that the AIG carrier cancel (`/contract/cancel`) is chained off the
|
|
121
|
+
**same** `BILLING.SUBSCRIPTION.CANCELLED` webhook event that confirms a portal subscription
|
|
122
|
+
cancellation, so both share a trigger and are both blocked by the current `Rate/Webhook` watchdog
|
|
123
|
+
failures; and that "AIG" in this flow is the **carrier**, not the `Client_Aig` tenant.
|
|
124
|
+
(mhammontree)
|
|
113
125
|
- 2026-07-27 — TRUE-79533 beta-verified: updated the service-address note to the **standard**
|
|
114
126
|
`Entitlements.serviceAddressId` FK (the Rate-custom `c_serviceAddressId` was promoted to a
|
|
115
127
|
base-model standard field), and noted that this AIG block and the WH pin read the **same**
|
|
@@ -12,7 +12,7 @@ files:
|
|
|
12
12
|
- worker2/Worker/Rate.php
|
|
13
13
|
- worker2/Config/production.ini
|
|
14
14
|
- api2/Config/production.ini
|
|
15
|
-
- _underscore/Component/Api/Paypal.php
|
|
15
|
+
- _underscore/Component/Api/Paypal/Paypal.php
|
|
16
16
|
- toga2-view/src/hooks/usePayPalSubscription.ts
|
|
17
17
|
- toga2-view/src/services/paypalService.ts
|
|
18
18
|
- toga2-view/src/pages/CheckOut/viewModel/useCheckoutPageViewModel.ts
|
|
@@ -70,7 +70,7 @@ token"*, `Rate.php:394` throws *"Entitlement not found"*.
|
|
|
70
70
|
## Gotchas
|
|
71
71
|
|
|
72
72
|
- **The watchdog timeout is NOT the PayPal-egress problem — do not re-chase egress.**
|
|
73
|
-
`worker2/Worker/Rate.php:59-77` and `_underscore/Component/Api/Paypal.php:13-18` both
|
|
73
|
+
`worker2/Worker/Rate.php:59-77` and `_underscore/Component/Api/Paypal/Paypal.php:13-18` both
|
|
74
74
|
assert that webhook stalls come from the EB worker tier being unable to reach
|
|
75
75
|
`api-m.paypal.com`. That egress problem is real but is **not** what kills these jobs:
|
|
76
76
|
`verify_signature = false` in `worker2/Config/production.ini`, so the signature/OAuth
|
|
@@ -6,7 +6,7 @@ project: TOGa View Frontend
|
|
|
6
6
|
client: rate
|
|
7
7
|
type: client-feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
9
|
+
updated: 2026-08-04
|
|
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
|
+
- subscription-cancellation.md
|
|
27
28
|
- 2.0/apps/toga2-view/features/get-support-cancel-subscription.md
|
|
28
29
|
---
|
|
29
30
|
|
|
@@ -107,8 +108,21 @@ product types would need `detectServiceType` extended or overridden.
|
|
|
107
108
|
unclassified services get no cancel path). See
|
|
108
109
|
[Get Support — Subscription Details & Cancel Subscription Gating](../../../2.0/apps/toga2-view/features/get-support-cancel-subscription.md).
|
|
109
110
|
|
|
111
|
+
## Pending-cancellation display ("Service ends by …")
|
|
112
|
+
|
|
113
|
+
A cancelled-but-not-yet-expired subscription **still has `isActive = 1`** (access is deliberately
|
|
114
|
+
retained through the paid period — see
|
|
115
|
+
[subscription cancellation](subscription-cancellation.md)), so it keeps rendering a card. The card
|
|
116
|
+
switches its date row from **"Renews \<date\>"** to **"Service ends by \<date\>"** once
|
|
117
|
+
`dtRequestToCancel` is set, and the Cancel affordance is hidden. `homeApi` / `useBundleServices`
|
|
118
|
+
therefore also request the cancellation columns — as **optional** fields, dropped on `EZ-2`/`EV-8`
|
|
119
|
+
by `apiErrors.ts`, so a missing grant loses the "ends by" wording rather than the whole page.
|
|
120
|
+
|
|
110
121
|
## Gotchas / known issues
|
|
111
122
|
|
|
123
|
+
- **A pending cancellation is NOT visible in `isActive`.** `isActive` stays `1` until the nightly
|
|
124
|
+
expiry cron. Any UI that infers "still subscribed" from `isActive` alone will show "Renews" to a
|
|
125
|
+
customer who already cancelled — read `dtRequestToCancel` / `dateCancelled`.
|
|
112
126
|
- **`ojoin` is required for address** — using INNER JOIN (`join`) drops every entitlement
|
|
113
127
|
whose contact has no `primaryContactAddressId`, silently hiding those cards.
|
|
114
128
|
- **`T00:00:00` in date formatting** — `formatDate` appends this before parsing to avoid
|
|
@@ -147,6 +161,11 @@ unclassified services get no cancel path). See
|
|
|
147
161
|
|
|
148
162
|
## Change history
|
|
149
163
|
|
|
164
|
+
- 2026-08-04 — TRUE-80282: cards now render **"Service ends by \<date\>"** instead of "Renews" for a
|
|
165
|
+
pending cancellation (and hide the Cancel affordance). Recorded that such a subscription still has
|
|
166
|
+
`isActive = 1` until the nightly expiry cron, so `isActive` alone must not be read as "still
|
|
167
|
+
subscribed", and that the new cancellation columns are requested as **optional** fields so a
|
|
168
|
+
missing grant degrades the wording rather than the page. (mhammontree)
|
|
150
169
|
- 2026-07-28 — `detectServiceType` gained a second, non-display consumer: the Get Support page gates
|
|
151
170
|
its Cancel Subscription UI on `serviceType === 'tech'`, so the classifier now decides which
|
|
152
171
|
entitlements are self-service cancellable. (tcox)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: session
|
|
3
|
+
slug: surface-approval-details-layout
|
|
4
|
+
title: SalesOrder approval-details card — layout split, title, submitted-by suffix
|
|
5
|
+
author: apeterson
|
|
6
|
+
repos: [toga25-supply, dbchanges2]
|
|
7
|
+
framework: "2.0"
|
|
8
|
+
client: shared
|
|
9
|
+
status: active
|
|
10
|
+
created: 2026-08-04
|
|
11
|
+
updated: 2026-08-04
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# Session: surface-approval-details-layout
|
|
15
|
+
**Date:** 2026-08-04
|
|
16
|
+
**Project/Repo:** toga25-supply (2.0) + dbchanges2 (Core SQL)
|
|
17
|
+
**Task:** Reshape the SalesOrders record-modal approval-details card — separate approval-stage layout, surface-driven section title, and an inline "submitted by" title suffix — while keeping the surface/FE boundary clean.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## What WORKED
|
|
22
|
+
<!-- Include specific file paths and evidence -->
|
|
23
|
+
- **Two-layout component split** (approval vs standard). `SalesOrderView.tsx` branches on `isApprovalDetailsActive` → `SalesOrderApprovalSummaryGrid` (new) vs `SalesOrderSummaryGrid`. Approval grid: approval-details spans 3 cols, order-summary totals 1 col (`.approvalSummaryGrid`/`.approvalDetailsSpan`). Chosen over prop-driven branching because the difference is placement across the DOM tree, not styling. tsc -b clean on all touched files.
|
|
24
|
+
- **Shared machinery extracted** so both grids reuse it: `helpers/sectionRenderers.tsx` (`createSectionRenderers` factory — detailSection/approvalDetails/locationCard/totalsCard) and `helpers/getVisibleSections.ts` (isVisible + visibilityRule + approval-suppression filter).
|
|
25
|
+
- **Section relocation in approval view** via `getDetailSections.tsx`: Order Details + Bill To move below the Items table, side-by-side (Order Details first, then Bill To — user reordered); Admin Notes moves to the TOP of the modal (rendered in `SalesOrderView`, suppressed from below-items). Guarded so absent sections don't throw for non-approval clients.
|
|
26
|
+
- **Surface-driven 2-col field grid** for the approval-details card (`.approvalFieldsGrid` = `repeat(2, minmax(0,1fr))`), reflows via BaseDetailField's `grid-column: span var(--field-col-span,1)`.
|
|
27
|
+
- **Section title** wired end-to-end from surface: confirmed resolver emits `titleMessageKey` from `Core.Surfaces.titleMessageId` (`_underscore/Model/Core/Surface.php:634-635`); `buildSection` already resolves `sectionTitle` from it — so it's a pure DB change. Migration `dbchanges2/Core/2026-08-04a - ApprovalDetailsSectionTitle.sql` inserts the message + links the surface.
|
|
28
|
+
- **"Submitted by {name}" title suffix**: added a `config.role: 'titleSuffix'` marker pattern. `buildSection` pulls that element OUT of the grid fields and exposes `section.titleSuffix = {label, valueKey}`; the `approvalDetails` renderer draws it inline in the `<h2>` (dot-path value resolve), styled `.cardTitleSuffix` (muted/italic). Migration `2026-08-04b` stamps the role on the `createdByUser._name` element. tsc clean.
|
|
29
|
+
|
|
30
|
+
## What did NOT work — DO NOT RETRY THESE
|
|
31
|
+
<!-- Exact failure reasons — do not vague-ify -->
|
|
32
|
+
- **Surface-driving grid GEOMETRY (column count + per-field colSpan) via surface config.** Tried `config.columns` on the surface + `config.colSpan` on elements. Dead end: the resolver's `CONFIG` SurfaceOverride REPLACES an element's whole config blob rather than merging (`_underscore/Model/Core/Surface.php` ~L827: `$config = $value`). So per-client colSpan would force adding a first-class `COL_SPAN` to the `SurfaceOverrides.attribute` ENUM + resolver + schema + framework deploy — far too much machinery for "this box is 2 columns wide". Reverted ALL of it (deleted the columns migration, removed the adapter passthroughs and `--approval-cols` var). Geometry is FE-driven; the 2-col grid is a fixed CSS choice. See KB memory `surface-vs-fe-layout-boundary`.
|
|
33
|
+
- **Stable-sort reorder to float approval-details to the front of the shared summary grid** — superseded and removed by the component split. Net state is the split; do not reintroduce the sort.
|
|
34
|
+
|
|
35
|
+
## Not tried yet (candidates for next session)
|
|
36
|
+
- Run the two Core migrations (`2026-08-04a`, then `2026-08-04b`) local → beta → prod; add PLAYBOOK change-log + env-status rows; commit dbchanges2 with a `TRUE-#####:` message.
|
|
37
|
+
- QA in the Compass/CompassCanada sandbox: (1) approval-stage order shows the new layout, 2-col field grid, "Approval Details — submitted by {name}" title, and Submitted By no longer in the grid; (2) a non-approval order is visually unchanged.
|
|
38
|
+
- Optional copy tweak: lowercase the mock's "submitted by" by changing `Core.Messages 'salesOrder.approvalDetails.field.submittedBy'` defaultValue to `submitted by` (left as-is — copy decision).
|
|
39
|
+
- Consider whether any approval-stage order can lack `createdByUser._name` (would show Submitted By in neither grid nor title — acceptable but confirm).
|
|
40
|
+
|
|
41
|
+
## Current file state
|
|
42
|
+
| File | Status | Notes |
|
|
43
|
+
|------|--------|-------|
|
|
44
|
+
| toga25-supply/.../helpers/sectionRenderers.tsx | Created | `createSectionRenderers` factory + `resolvePath`; approvalDetails renders titleSuffix inline |
|
|
45
|
+
| toga25-supply/.../helpers/getVisibleSections.ts | Created | Extracted visibility filter for the standard grid |
|
|
46
|
+
| toga25-supply/.../layoutComponents/SalesOrderApprovalSummaryGrid.tsx | Created | Approval top grid (approval-details span 3 + totals 1) |
|
|
47
|
+
| toga25-supply/.../layoutComponents/SalesOrderSummaryGrid.tsx | Modified | Slimmed to delegate to the two helpers |
|
|
48
|
+
| toga25-supply/.../view/SalesOrderView.tsx | Modified | Branches on approvalActive; Admin Notes at top in approval view |
|
|
49
|
+
| toga25-supply/.../helpers/getDetailSections.tsx | Modified | Approval-only: Order Details + Bill To below items; adminNotes suppressed below |
|
|
50
|
+
| toga25-supply/.../SalesOrderRecordModalLayout/index.ts | Modified | Exports SalesOrderApprovalSummaryGrid |
|
|
51
|
+
| toga25-supply/.../helpers/surfaceBundleToTenantFields.ts | Modified | `titleSuffix` role marker → section.titleSuffix, excluded from grid; geometry passthroughs reverted |
|
|
52
|
+
| toga25-supply/.../SalesOrderRecordModal.module.css | Modified | .approvalSummaryGrid/.approvalDetailsSpan, .approvalFieldsGrid (fixed 2-col), .cardTitleSuffix |
|
|
53
|
+
| dbchanges2/Core/2026-08-04a - ApprovalDetailsSectionTitle.sql | Created | Message "Approval Details" + link surface.titleMessageId (NOT YET RUN) |
|
|
54
|
+
| dbchanges2/Core/2026-08-04b - ApprovalDetailsSubmittedByTitleSuffix.sql | Created | Stamp config.role='titleSuffix' on createdByUser._name element (NOT YET RUN) |
|
|
55
|
+
|
|
56
|
+
## Decisions made
|
|
57
|
+
- **Component split, not prop-driven**, for approval vs standard — the layouts differ in DOM placement (sections move relative to the Items table), not just CSS. Rejected: one prop-driven `SalesOrderSummaryGrid` (would leak branch logic into SalesOrderView anyway).
|
|
58
|
+
- **Grid geometry lives in the FE; surface owns content/visibility/order/ACL.** Rejected surface-driven columns/colSpan (see failure above). Saved as KB feedback memory `surface-vs-fe-layout-boundary`.
|
|
59
|
+
- **Title suffix via a `config.role` marker**, mirroring the existing `sectionLayoutRule`/`sectionVisibility` markers — surface declares the semantic role + value/label (content), FE renders placement. Rejected: hardcoding the value/label in the FE.
|
|
60
|
+
- **Section title stays surface-driven** (it's content, not geometry) — pure DB change, no FE edit needed.
|
|
61
|
+
|
|
62
|
+
## Blockers
|
|
63
|
+
None. FE is committed-ready and typechecks; the two Core migrations are authored but NOT yet run against any DB.
|
|
64
|
+
|
|
65
|
+
## Exact next step
|
|
66
|
+
> Run `dbchanges2/Core/2026-08-04a - ApprovalDetailsSectionTitle.sql` then `2026-08-04b - ApprovalDetailsSubmittedByTitleSuffix.sql` against the LOCAL Core DB, then load an approval-stage order in the Compass sandbox and verify: title reads "Approval Details — submitted by {name}", the field grid is 2-col, and Submitted By is gone from the grid.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
_Saved by /session-save on 2026-08-04_
|
package/package.json
CHANGED