toga-ai 1.0.519 → 1.0.520

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,8 +6,8 @@ project: TOGa View Frontend
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-28
10
- owners: ["tcox"]
9
+ updated: 2026-08-04
10
+ owners: ["tcox", "mhammontree"]
11
11
  files:
12
12
  - toga2-view/src/pages/GetSupport/viewModels/getSupportPageViewModel.ts
13
13
  - toga2-view/src/pages/GetSupport/view/GetSupportPage.tsx
@@ -17,6 +17,7 @@ files:
17
17
  related:
18
18
  - ../architecture.md
19
19
  - ../../../../clients/rate/features/service-card-entitlements.md
20
+ - ../../../../clients/rate/features/subscription-cancellation.md
20
21
  - ../../../../clients/rate/profile.md
21
22
  ---
22
23
 
@@ -30,8 +31,12 @@ services and unclassified services get the subscription details card **without**
30
31
  Two things about this flow are easy to get wrong and are the reason it is documented:
31
32
 
32
33
  - 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.
34
+ - **The backend now exists** (2026-08-04): `cancelSubscription()` calls
35
+ **`POST /v2/subscriptions/cancel`** with `{ entitlementUuid }` in the **body**. It is no longer a
36
+ stub. The route shape, the two-phase PayPal-backed write, and the reason the *entitlement* uuid
37
+ (not the subscription uuid) is the argument are documented in
38
+ [Rate tech-support subscription cancellation](../../../../clients/rate/features/subscription-cancellation.md).
39
+ This is also why the viewModel already exposed `entitlementUuid` on its `subscription` object.
35
40
 
36
41
  ## Key files / entry points
37
42
 
@@ -18,8 +18,8 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
18
18
 
19
19
  ## 2.0 framework
20
20
 
21
- - **_underscore** (_Underscore) _(framework core)_ — 43 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
22
- - **worker2** (Worker) — 38 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
21
+ - **_underscore** (_Underscore) _(framework core)_ — 44 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
22
+ - **worker2** (Worker) — 39 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
23
23
  - **api2** (API) — 21 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
24
24
  - **dbchanges2** (Database Changes) _(framework core)_ — 5 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
25
25
  - **toga2-supply** (TOGa Supply) — 5 doc(s) → [2.0/apps/toga2-supply/INDEX.md](2.0/apps/toga2-supply/INDEX.md)
@@ -5,8 +5,10 @@
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
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 |
9
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 |
10
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 |
12
+ | [Rate Tech-Support Subscription Cancellation (portal self-service)](features/subscription-cancellation.md) | 2.0 | Rate portal customers can cancel their **tech-support** subscription themselves. | _underscore/Model/Rate/Subscription.php, _underscore/Component/Api/Paypal/Paypal.php, worker2/Worker/Rate.php, dbchanges2/Client_Rate/2026-08-04a, dbchanges2/Client_Rate/2026-08-04b, dbchanges2/Core/2026-08-04a, toga2-view/src/pages/GetSupport, toga2-view/src/components/ServiceCard/ServiceCard.tsx, toga2-view/src/pages/Home/api/homeApi.ts, toga2-view/src/hooks/useBundleServices.ts, toga2-view/src/api/apiErrors.ts, api2/Config/beta.ini |
11
13
  | [Rate Whole Home Warranty Per-Address Purchase Guard](features/whole-home-warranty-purchase-guard.md) | 2.0 | > # ⚠ STATUS (2026-07-31, TRUE-80487) — THE GUARD HAD NEVER EXECUTED, IN ANY ENVIRONMENT > > Everything below the next section described a guard that **never ra | _underscore/Model/Rate/Entitlement.php, _underscore/Model/Client/Entitlement.php, _underscore/Model/Client/Address.php, _underscore/Component/Library/Carriers/Usps/Usps.php, toga2-view/src/pages/CheckOut/api/checkoutApi.ts, toga2-view/src/pages/CheckOut/viewModel/useCheckoutPageViewModel.ts, toga2-view/src/pages/ZipValidation/api/zipValidation.ts, dbchanges2/Client/2026-07-22a - EntitlementServiceAddressId.sql, dbchanges2/Core/2026-07-22a - EntitlementServiceAddressIdField.sql, dbchanges2/Client_Rate/2026-07-23a - EntitlementServiceAddressIdFieldPermission.sql, dbchanges2/Client_Rate/2026-07-31a - WarrantyAvailabilityCustomRecordScript.sql, test/@Mark/Rate/verify_wholehome_per_address_guard.php, test/@Mark/Rate/verify_wh_gate_and_unit_dedup.php |
12
14
  | [Rate](profile.md) | 2.0 | Rate is a mortgage/lending client. | |
@@ -0,0 +1,149 @@
1
+ ---
2
+ title: "Rate PayPal Subscription Purchase & Webhook Pipeline"
3
+ framework: "2.0"
4
+ repo: worker2
5
+ project: Worker
6
+ client: rate
7
+ type: client-feature
8
+ status: active
9
+ updated: 2026-08-04
10
+ owners: ["mhammontree"]
11
+ files:
12
+ - worker2/Worker/Rate.php
13
+ - worker2/Config/production.ini
14
+ - api2/Config/production.ini
15
+ - _underscore/Component/Api/Paypal.php
16
+ - toga2-view/src/hooks/usePayPalSubscription.ts
17
+ - toga2-view/src/services/paypalService.ts
18
+ - toga2-view/src/pages/CheckOut/viewModel/useCheckoutPageViewModel.ts
19
+ - toga2-view/src/pages/CheckOut/api/checkoutApi.ts
20
+ - toga2-view/src/pages/Activation/view/Activation.tsx
21
+ related:
22
+ - profile.md
23
+ - service-card-entitlements.md
24
+ - whole-home-warranty-purchase-guard.md
25
+ - service-purchase-emails.md
26
+ - aig-contract-creation.md
27
+ - subscription-cancellation.md
28
+ ---
29
+
30
+ > # ⚠ STATUS (2026-08-04, TRUE-80575) — TWO INDEPENDENT LIVE DEFECTS
31
+ >
32
+ > **A. Every `Rate/Webhook` worker job fails.** 5/5 jobs on record in prod `Core.WorkerJobs`
33
+ > (ids 560657, 563520, 563544, 563545, 574010; 2026-08-01 → 2026-08-03) died with
34
+ > `watchdog: exceeded maxExecutionTime without completing` at ~322s against a 300s watchdog.
35
+ > Cancellations and renewals are therefore silently not being processed.
36
+ >
37
+ > **B. New purchases have no server-side creation path at all.** Only the customer's browser
38
+ > can create an entitlement. If the browser never completes the call, PayPal keeps the money
39
+ > and TOGa records nothing — no trace, no reconciliation.
40
+ >
41
+ > **A and B are separate.** Fixing the watchdog timeout restores cancellations/renewals but
42
+ > does **not** close the money-taken-nothing-recorded hole. Fix B is awaiting a decision
43
+ > (see *Open decisions*). No code has been written for either.
44
+
45
+ ## Summary
46
+
47
+ Rate customers buy home-tech-support and home-warranty subscriptions through PayPal on
48
+ `toga2-view`. The purchase is created **entirely client-side**; `_Worker_Rate::Webhook`
49
+ (worker2) handles only post-purchase lifecycle events (renewal sales orders and
50
+ deactivations) against an entitlement that **already exists**.
51
+
52
+ ## How it works
53
+
54
+ **New purchase (browser-only):**
55
+ 1. `usePayPalSubscription.ts` resolves a plan id from `PAYPAL_PLAN_IDS`
56
+ (`paypalService.ts`) and opens the PayPal subscription flow.
57
+ 2. On PayPal approval, the SDK's `onApprove` callback
58
+ (`usePayPalSubscription.ts:151-177`) fires **in the customer's browser**.
59
+ 3. `handleActivateService` (`useCheckoutPageViewModel.ts:402-489`) builds a single nested
60
+ `POST /entitlements` payload — Entitlement + Subscription + SalesOrder + SalesOrderItem
61
+ + Payment — and posts it via `checkoutApi.ts:20`.
62
+ 4. That browser call is the **only** thing that creates the records.
63
+
64
+ **Lifecycle (worker2):** PayPal → Lambda → SQS → `_Worker_Rate::Webhook`, which handles
65
+ `PAYMENT.SALE.COMPLETED` (renewal sales order on an existing entitlement) and six
66
+ deactivation events. There is **no** `BILLING.SUBSCRIPTION.ACTIVATED` handler. For a new
67
+ purchase the worker dead-ends early: `Rate.php:112` throws *"Subscription not found for
68
+ token"*, `Rate.php:394` throws *"Entitlement not found"*.
69
+
70
+ ## Gotchas
71
+
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
74
+ assert that webhook stalls come from the EB worker tier being unable to reach
75
+ `api-m.paypal.com`. That egress problem is real but is **not** what kills these jobs:
76
+ `verify_signature = false` in `worker2/Config/production.ini`, so the signature/OAuth
77
+ calls never execute on the inbound path. **Those code comments are wrong about the cause.**
78
+ - **The real cause is an unindexed leading-wildcard LIKE.** `isEventProcessed()`
79
+ (`Rate.php:268-290`) runs
80
+ `SELECT COUNT(*) FROM Logs_Rate.Api WHERE source='PAYPAL' AND requestPayload LIKE '%<eventId>%'`.
81
+ `SHOW INDEX FROM Logs_Rate.Api` has indexes only on `id`, `uuid`, `transactionId`,
82
+ `apiId(+dtStamp,isAuthRequest)`, `dtStamp`, `method` — **no index on `source`**, and the
83
+ LIKE is a leading wildcard over a `mediumtext` column across 8.7M rows. Reproduced
84
+ independently: a bare `WHERE source='PAYPAL'` timed out at 300s twice, while
85
+ `dtStamp`-bounded queries on the same table returned instantly.
86
+ - **`isSuccess = 0` on a `Rate/Webhook` job does NOT mean "no side effects."** A
87
+ watchdog-killed job's PHP process can keep running and complete its API writes after the
88
+ row is marked failed. Job 560657 started 07:53:03, was killed 07:58:18, yet its
89
+ `POST /v2/sales-orders` landed 08:00:10 and `POST /v2/entitlement-sales-orders` at
90
+ 08:00:41 (worker IP 34.232.23.158). **Re-running or manually remediating a failed job
91
+ risks duplicates** — always check `Logs_Rate.Api` first. This race also explains why some
92
+ purchases appear to have worked.
93
+ - **Yearly Home Warranty cannot be purchased at all (separate live bug, needs its own
94
+ ticket).** `PAYPAL_PLAN_IDS` (`paypalService.ts:31-41`) defines `monthly_tech`,
95
+ `yearly_tech`, `monthly_warranty` — but **no `yearly_warranty`**. `getPlanId()` builds
96
+ `` `${plan}_${serviceType}` `` → `yearly_warranty` → `undefined` → `onError("Invalid plan
97
+ configuration")`.
98
+ - **Security: the webhook endpoint is unauthenticated and forgeable.** With
99
+ `[paypal] verify_signature` disabled (documented in-code as a temporary tradeoff for the
100
+ blocked egress), anyone who knows the endpoint URL can create a paid SalesOrder.
101
+ Separately, the live PayPal `client_secret` is committed in plaintext in the `[paypal]`
102
+ block of **both** `worker2/Config/production.ini` and `api2/Config/production.ini` — it
103
+ should be rotated and moved out of the repo. (Location only; the value is not recorded
104
+ anywhere in this KB.)
105
+ - **Defect A now also blocks portal cancellations, not just renewals.** As of 2026-08-04 the
106
+ portal's self-service cancel flow relies on this worker consuming
107
+ `BILLING.SUBSCRIPTION.CANCELLED` to stamp `Subscriptions.dateCancelled` (its reconciliation
108
+ channel) — with every `Rate/Webhook` job dying, that path is dead. See
109
+ [Rate tech-support subscription cancellation](subscription-cancellation.md).
110
+ - **Hypothesis, NOT confirmed — the `return_url` lands on a page that creates nothing.**
111
+ `usePayPalSubscription.ts:126` sets `return_url` to `${origin}/activation?success=true`.
112
+ If the SDK falls back to a full-page redirect (popup blocked, mobile in-app browser),
113
+ PayPal redirects there and `onApprove` never runs; `Activation.tsx` is a static
114
+ "success, redirecting in 10s" page that reads no subscription id and calls no API. The DB
115
+ can only prove the *absence* of the call, not why the browser didn't make it. Treat as a
116
+ plausible mechanism, not a cause.
117
+
118
+ ## Investigating this
119
+
120
+ - **Defect A cannot be reproduced locally via a sandbox webhook.** Non-production worker2 is
121
+ not SQS-driven (it is manually HTTP-invoked), so a PayPal sandbox webhook never traverses
122
+ the Lambda→SQS→worker path and the watchdog never fires. Reproduce by timing the
123
+ `isEventProcessed()` query directly against a large `Logs_Rate.Api`.
124
+ - **Defect B** must be exercised via a real sandbox browser checkout.
125
+ - **Always bound `Logs_Rate.Api` queries by `dtStamp` plus a `method`/route filter.**
126
+ Unfiltered windows return hundreds of unrelated bulk contact-sync `PUT` rows and truncate.
127
+
128
+ ## Evidence (TRUE-80575)
129
+
130
+ Zero `POST /v2/entitlements` rows in `Logs_Rate.Api` for all of 2026-08-01, while
131
+ `Client_Rate` User 755 / Contact 773 / Customer 752 (Kwadwo "Drew" Moore) were created at
132
+ 16:25:16 pre-payment and PayPal charged $89.97 at 16:32:47 (txn `0DY909616V1516622`,
133
+ subscription `I-411CRRVH81U8`).
134
+
135
+ ## Open decisions
136
+
137
+ Closing defect B requires giving the server a creation path — either a
138
+ `BILLING.SUBSCRIPTION.ACTIVATED` handler, or a reconciliation job that sweeps PayPal
139
+ subscriptions with no matching `Subscriptions.token`. **Rejected:** framing TRUE-80575 as a
140
+ single "webhook broken" bug. Awaiting Mark's decision; nothing implemented.
141
+
142
+ ## Change history
143
+
144
+ - 2026-08-04 — Documented from TRUE-80575 investigation (read-only): watchdog failures traced
145
+ to the unindexed `isEventProcessed()` LIKE (not PayPal egress — corrects the in-code
146
+ comments), no server-side entitlement-creation path for new purchases, post-kill write race,
147
+ missing `yearly_warranty` plan id, and the disabled-signature/committed-secret exposure.
148
+ Session notes: `knowledge/sessions/2026-08-04-TRUE-80575-rate-paypal-webhook-mhammontree.md`
149
+ (mhammontree)
@@ -0,0 +1,208 @@
1
+ ---
2
+ title: "Rate Tech-Support Subscription Cancellation (portal self-service)"
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: rate
7
+ type: client-feature
8
+ status: active
9
+ updated: 2026-08-04
10
+ owners: [mhammontree, tcox]
11
+ files:
12
+ - _underscore/Model/Rate/Subscription.php
13
+ - _underscore/Component/Api/Paypal/Paypal.php
14
+ - worker2/Worker/Rate.php
15
+ - dbchanges2/Client_Rate/2026-08-04a
16
+ - dbchanges2/Client_Rate/2026-08-04b
17
+ - dbchanges2/Core/2026-08-04a
18
+ - toga2-view/src/pages/GetSupport
19
+ - toga2-view/src/components/ServiceCard/ServiceCard.tsx
20
+ - toga2-view/src/pages/Home/api/homeApi.ts
21
+ - toga2-view/src/hooks/useBundleServices.ts
22
+ - toga2-view/src/api/apiErrors.ts
23
+ - api2/Config/beta.ini
24
+ related:
25
+ - clients/rate/profile.md
26
+ - service-card-entitlements.md
27
+ - whole-home-warranty-purchase-guard.md
28
+ - aig-contract-creation.md
29
+ - paypal-subscription-purchase-webhook.md
30
+ - ../../../2.0/apps/toga2-view/features/get-support-cancel-subscription.md
31
+ - ../../../2.0/apps/api2/features/record-scripts.md
32
+ - ../../../2.0/apps/_underscore/features/model-save-vs-query-atomic-update.md
33
+ - ../../../2.0/apps/_underscore/features/acl-permission-chain.md
34
+ ---
35
+
36
+ ## Summary
37
+
38
+ Rate portal customers can cancel their **tech-support** subscription themselves. The Get Support
39
+ page's Cancel button previously had **no backend** (it called a stub that always rejected — see
40
+ [Get Support cancel gating](../../../2.0/apps/toga2-view/features/get-support-cancel-subscription.md));
41
+ this feature is that backend.
42
+
43
+ **The single most important fact about this flow: TOGA does not bill Rate subscriptions — PayPal
44
+ does.** The recurring charge runs *inside PayPal* against a billing agreement created browser-side
45
+ at checkout and stored as `Subscriptions.token`. There is **no TOGA cron that selects subscriptions
46
+ to charge.** Therefore **writing cancellation columns stops zero money** — a cancel **must** call
47
+ PayPal. Conversely PayPal has no "cancel at period end" (it cancels *immediately*), so the
48
+ "coverage continues until the end of your current period" promise the portal makes is **entirely
49
+ ours to implement**.
50
+
51
+ Endpoint: **`POST /v2/subscriptions/cancel`** with `{"entitlementUuid": "…"}` in the **body**,
52
+ dispatching to `_Model_Rate_Subscription::cancelApi`.
53
+
54
+ ## Key files / entry points
55
+
56
+ - `_underscore/Model/Rate/Subscription.php` — `cancelApi` (the record script) and
57
+ `applyCancellationFields` (the two-phase writer)
58
+ - `_underscore/Component/Api/Paypal/Paypal.php` — the server-side PayPal client (**note the
59
+ directory**, see gotchas)
60
+ - `worker2/Worker/Rate.php` — the `BILLING.SUBSCRIPTION.CANCELLED` webhook handler and
61
+ `extendSubscriptionDate`; also the `Rate/ExpireCancelledSubscriptions` cron
62
+ - `dbchanges2/Client_Rate/2026-08-04a` — `AclFieldPermissions` grants (must sort **before** `b`)
63
+ - `dbchanges2/Client_Rate/2026-08-04b` — the record-script registration + dispatch grant
64
+ - `dbchanges2/Core/2026-08-04a` — Core record/field + `CronJobs` row
65
+ - `toga2-view` — `GetSupport` (real call replaces the stub), `ServiceCard`, `homeApi`,
66
+ `useBundleServices`, `apiErrors`
67
+
68
+ ## Route shape — why the id is in the BODY, and why it is the ENTITLEMENT uuid
69
+
70
+ Two constraints that look like sloppy API design and are not:
71
+
72
+ 1. **`/subscriptions/{id}/cancel` is impossible.** The V2 dispatcher consumes the **trailing path
73
+ segment as the script name**, so any id placed before `cancel` breaks route matching. The id
74
+ therefore travels in the JSON body (supported — see
75
+ [POST + JSON-body args for scripted APIs](../../../2.0/apps/api2/features/scripted-api-post-body-args.md)).
76
+ 2. **The caller passes the `entitlementUuid`, not the subscription uuid.** Surfacing
77
+ `subscription.uuid` to the portal would require a **new joined-field ACL grant**, and an
78
+ ungranted joined field **fails the entire entitlements request** (`EZ-2`) — one missing grant
79
+ would blank the whole services page. The entitlement uuid is already granted and already in
80
+ hand, so the server resolves the subscription from it.
81
+
82
+ ## How it works — the two-phase write (order is load-bearing)
83
+
84
+ `applyCancellationFields` writes the cancellation in **two separate commits**, and the ordering
85
+ exists to survive a race with PayPal's own webhook:
86
+
87
+ 1. **`dtRequestToCancel` is committed BEFORE the PayPal call.** Cancelling at PayPal fires
88
+ `BILLING.SUBSCRIPTION.CANCELLED` back at worker2 **within seconds**. Before this feature that
89
+ handler set `isActive = 0` **and** `dateEnd = today` — a same-day hard cut that directly
90
+ contradicts the portal's "coverage continues" copy. With `dtRequestToCancel` already committed,
91
+ the handler recognises a **wind-down** and leaves `dateEnd` alone.
92
+ 2. **Call PayPal** to cancel the billing agreement.
93
+ 3. **`dateCancelled` is written only AFTER PayPal confirms.** It is the confirmation marker; an
94
+ unconfirmed cancellation must never look confirmed.
95
+ 4. **`isActive` stays `1`.** The portal gates access on `isActive` alone, so flipping it here would
96
+ revoke paid-for access immediately. A daily cron — **`Rate/ExpireCancelledSubscriptions`,
97
+ `Core.CronJobs`, 03:00 Central** — flips it once `dateEnd` has passed.
98
+
99
+ **The effective end date is always server-computed from `dateEnd`.** Client-supplied dates are
100
+ ignored — honoring them would hand out free coverage.
101
+
102
+ **Warranties are refused server-side**, using the *same authority as the purchase guard*: WH title
103
+ keyword **or** `Items.id = 3` (see
104
+ [WH purchase guard](whole-home-warranty-purchase-guard.md)). The frontend's `serviceType === 'tech'`
105
+ gate is a UX affordance, not the enforcement point.
106
+
107
+ ## Reconciliation — the webhook IS the confirmation channel
108
+
109
+ There is deliberately **no api2-side reconciliation endpoint**. When worker2 sees
110
+ `BILLING.SUBSCRIPTION.CANCELLED` on a row that is still unconfirmed, **it stamps `dateCancelled`
111
+ itself**. That closes the loop for cancellations initiated outside the portal too.
112
+
113
+ **The guard split that matters** (getting this backwards either bills a cancelled customer or robs
114
+ a paying one):
115
+
116
+ | Row state | Meaning | `extendSubscriptionDate` | Expiry cron |
117
+ |---|---|---|---|
118
+ | `dtRequestToCancel` + **future** `dateEnd`, no `dateCancelled` | **PENDING** — may still be billing | **still extends** | does **not** deactivate |
119
+ | `dtRequestToCancel` + `dateCancelled` | **CONFIRMED** | **stops** | deactivates once `dateEnd` passes |
120
+
121
+ An unconfirmed cancellation may still be billing, and **that payment genuinely owes the customer
122
+ another period** — so only a *confirmed* cancellation stops the extension. The expiry cron likewise
123
+ requires `dateCancelled` so it can never deactivate someone PayPal is still charging.
124
+
125
+ Historical note: before this ticket **`dtRequestToCancel` was written by nothing and read by
126
+ nothing** — a dead column. It is now the pending/confirmed discriminator.
127
+
128
+ ## PayPal app pairing (where credentials live — never values)
129
+
130
+ **A PayPal subscription can only be cancelled with credentials for the app that OWNS it.** The
131
+ server-side `client_id` must therefore match the client-id the **frontend** used to create the
132
+ agreement.
133
+
134
+ - Verified: `api2/Config/production.ini` `[paypal] client_id` matches
135
+ `toga2-view/.env.production`.
136
+ - **beta / development use a DIFFERENT PayPal app**, so non-prod testing cannot touch live
137
+ agreements. Beta credentials live in `api2/Config/beta.ini` `[paypal]`, pointed at
138
+ `api-m.sandbox.paypal.com`.
139
+ - Sandbox credentials already existed in the 1.0 `webhook/config.rohan-mac.ini` because the 1.0
140
+ webhook was previously the only server-side PayPal caller.
141
+
142
+ **Never place the live pair in a non-prod ini — cancellation is irreversible at PayPal.**
143
+
144
+ ## Migration ordering is a dependency contract
145
+
146
+ `dbchanges2` runs files **alphabetically within a folder**, so the same-day letter suffix is the
147
+ contract between these two files: the **field-permission grants (`2026-08-04a`) must land BEFORE the
148
+ record-script registration (`2026-08-04b`)**. Reversed, the endpoint goes live while the worker
149
+ still cannot see the columns it needs — which is precisely the window in which a cancel truncates a
150
+ customer's paid coverage.
151
+
152
+ ## Frontend
153
+
154
+ `GetSupport` now calls the real endpoint instead of the rejecting stub. `ServiceCard` /
155
+ `useBundleServices` / `homeApi` render **"Service ends by \<date\>"** in place of "Renews" once a
156
+ cancellation is pending, and the Cancel affordance is hidden. Non-essential new fields are requested
157
+ as **optional** and dropped on `EZ-2`/`EV-8` (`apiErrors`), so a missing grant degrades this one
158
+ feature rather than failing the page.
159
+
160
+ **No cancellation email is sent — PM decision**, not an oversight.
161
+
162
+ ## Gotchas / known issues
163
+
164
+ - **`_Component_Api_Paypal` must live at `Component/Api/Paypal/Paypal.php`** — a flat
165
+ `Component/Api/Paypal.php` fatals on first use. See
166
+ [component/model namespace + autoload registration](../../../2.0/apps/_underscore/features/component-model-namespace-registration.md).
167
+ - **Never roll a cancellation back with "set the field to null and `save()` again" on the same model
168
+ instance** — `_Model::save()` silently omits the column and the rollback does nothing. This bug
169
+ was written and nearly shipped here: it would have left customers marked cancelled while PayPal
170
+ kept billing them. `applyCancellationFields` loads a **fresh instance per write**. See
171
+ [_Model::save() vs raw _Query](../../../2.0/apps/_underscore/features/model-save-vs-query-atomic-update.md).
172
+ - **Do not add an `AclRecordPermissions` CREATE grant to "make the scripted POST work."** A scripted
173
+ POST skips the record-permission check entirely; a CREATE grant on `Subscriptions` would let any
174
+ portal customer insert arbitrary subscription rows through normal CRUD. An `EZ-1` on a scripted
175
+ POST means the **script** grant is missing.
176
+ - **Writing cancellation columns without calling PayPal does not stop billing.** There is no TOGA
177
+ charging cron to stop.
178
+ - **`isActive` is not the cancellation flag.** It stays `1` through the wind-down; read
179
+ `dtRequestToCancel` / `dateCancelled` to know the cancellation state.
180
+ - **The AIG carrier cancel is chained off the same `BILLING.SUBSCRIPTION.CANCELLED` event** — see
181
+ [AIG contract creation](aig-contract-creation.md). AIG is the *carrier*, not the `Client_Aig`
182
+ tenant.
183
+ - **`api2/Config/production.ini` carries a plaintext live PayPal secret in the repo.** Flagged for
184
+ remediation; do not propagate the pattern.
185
+ - **⚠ The confirmation channel this design depends on is currently BROKEN in production.** Every
186
+ `Rate/Webhook` worker job is dying on the watchdog (unindexed `isEventProcessed()` LIKE — see
187
+ [PayPal subscription purchase & webhook pipeline](paypal-subscription-purchase-webhook.md),
188
+ Defect A). Until that is fixed, `BILLING.SUBSCRIPTION.CANCELLED` is **not processed**, so
189
+ `dateCancelled` is only ever stamped by `cancelApi`'s own post-PayPal write and the
190
+ webhook-side reconciliation path is dead. Treat the webhook fix as a prerequisite for relying
191
+ on reconciliation.
192
+
193
+ ## Change history
194
+
195
+ - 2026-08-04 — TRUE-80282: built portal self-service cancellation for Rate tech-support
196
+ subscriptions. `POST /v2/subscriptions/cancel` (`{entitlementUuid}` in the **body** — the V2
197
+ dispatcher consumes the trailing segment as the script name; the entitlement uuid avoids a joined
198
+ field grant whose absence would fail the whole entitlements request). Two-phase write:
199
+ `dtRequestToCancel` committed **before** the PayPal call so the returning
200
+ `BILLING.SUBSCRIPTION.CANCELLED` webhook treats it as a wind-down instead of truncating `dateEnd`
201
+ to today; `dateCancelled` only **after** PayPal confirms; `isActive` left at 1 until the daily
202
+ `Rate/ExpireCancelledSubscriptions` cron (03:00 Central). Recorded that **PayPal, not TOGA, bills
203
+ these subscriptions** (billing agreement in `Subscriptions.token`; no TOGA charging cron;
204
+ `dtRequestToCancel` was previously a dead column), that PayPal cancels immediately so the
205
+ period-end grace is ours, and the pending-vs-confirmed guard split governing
206
+ `extendSubscriptionDate` and the expiry cron. WH refused server-side by the purchase guard's
207
+ authority (title keyword or `Items.id 3`); end date always server-computed from `dateEnd`; no
208
+ cancellation email (PM decision). (mhammontree)
@@ -14,7 +14,7 @@ project: SAML SSO Gateway
14
14
  client: rate
15
15
  type: profile
16
16
  status: active
17
- updated: 2026-07-31
17
+ updated: 2026-08-04
18
18
  owners: ["rgirish", "bala", "mhammontree", "tcox"]
19
19
  files: []
20
20
  related:
@@ -24,6 +24,8 @@ related:
24
24
  - clients/rate/features/service-card-entitlements.md
25
25
  - clients/rate/features/aig-contract-creation.md
26
26
  - clients/rate/features/service-purchase-emails.md
27
+ - clients/rate/features/paypal-subscription-purchase-webhook.md
28
+ - clients/rate/features/subscription-cancellation.md
27
29
  ---
28
30
 
29
31
  ## Summary
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.519",
3
+ "version": "1.0.520",
4
4
  "description": "TOGA Technology Team Claude Knowledge System — shared AI coding harness with skills, knowledge base CLI, and project installer for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",