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.
- package/knowledge/2.0/apps/toga2-view/features/get-support-cancel-subscription.md +9 -4
- package/knowledge/INDEX.md +2 -2
- package/knowledge/clients/rate/INDEX.md +2 -0
- package/knowledge/clients/rate/features/paypal-subscription-purchase-webhook.md +149 -0
- package/knowledge/clients/rate/features/subscription-cancellation.md +208 -0
- package/knowledge/clients/rate/profile.md +3 -1
- package/package.json +1 -1
|
@@ -6,8 +6,8 @@ project: TOGa View Frontend
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
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
|
-
- **
|
|
34
|
-
|
|
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
|
|
package/knowledge/INDEX.md
CHANGED
|
@@ -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)_ —
|
|
22
|
-
- **worker2** (Worker) —
|
|
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-
|
|
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