toga-ai 1.0.306 → 1.0.308

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,7 +6,7 @@ project: Library
6
6
  client: pcmaticb2b
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-07-06
9
+ updated: 2026-07-09
10
10
  owners: [snaredla]
11
11
  files:
12
12
  - library/app/api/toga2.php
@@ -75,11 +75,12 @@ Ruled out while debugging "nothing syncs to V1" (0/77 contacts + 0/37 entitlemen
75
75
 
76
76
  Imports Startech (Easeedesk V3) config into TOGA 2.0 — users, groups, devices, categories, ticket types.
77
77
 
78
- - **Groups** must come from `GET /groups_dispatching` (`data.dispatch[]`, keys `group_id`/`group_name`). The company-scoped `dynamic_field_options->data->groups` returns empty here — groups are provider-level, not per-company.
79
- - **Categories** — `App_Api_StarTechTicket::getCategories($companyId, $ticketTypeIds)` forwards a `ticket_type` filter alongside `company`. ⚠ The param name `ticket_type` is **unverified** against the Easeedesk API (not documented for `/tickets/categories`) — confirm in Postman before relying on type filtering; if it's ignored, categories come back unfiltered.
78
+ - **Groups** come from `dynamic_field_options->data->groups` (keys `ID`/`group_name`) — verified 2026-07-09 returning **16 groups** for company 24412. The earlier claim that this returns empty and groups must come from `/groups_dispatching` was **wrong**: the empty result was caused by **incorrect credentials** in `startechticket.php` (`agilant.api`), not the endpoint. Fixed by using the `pcmaticb2b@startechteam.ro` account. `/groups_dispatching` returned 0 and is not used.
79
+ - **Categories** — `App_Api_StarTechTicket::getCategories($companyId, $ticketTypeIds)` forwards a `ticket_type` filter alongside `company`. **Verified 2026-07-09**: `company=24412&ticket_type=50,54` returns 15 categories (the scoped set); the `ticket_type` param works as intended.
80
80
  - Any single upsert failing aborts the run (all `send()` calls throw on API error); the block order is users → groups → devices → categories → ticket types.
81
81
 
82
82
  ## Change history
83
83
 
84
84
  - 2026-06-23: Initial doc — ticket type maps (both directions), NULL sync behavior, TOGADESK_PCS type added
85
85
  - 2026-07-06: Added syncWithToga V1 leg (cross-ref gating, cron-abort ordering, debugging findings) and supporting-records import notes (groups via groups_dispatching, getCategories ticket-type filter)
86
+ - 2026-07-09: Corrected the groups note — groups come from `dynamic_field_options->data->groups` (16 groups); the empty result was a credentials bug (`agilant.api`), not the endpoint. Confirmed `getCategories` `ticket_type` filter works (`50,54` → 15 categories).
@@ -2,5 +2,6 @@
2
2
 
3
3
  | Doc | Framework | Summary | Files |
4
4
  |-----|-----------|---------|-------|
5
+ | [PC Matic B2B — Startech Entitlement Provisioning (customer + SKU)](features/startech-entitlement-provisioning.md) | 2.0 | When a PC Matic B2B entitlement is created in TOGA 2.0 (`POST /entitlements`), the client registers the customer on StarTech's OptimumDesk platform and assigns | _underscore/Trait/Startech/Entitlement.php, _underscore/Model/Pcmaticb2b/Entitlement.php, _underscore/Config.php, api2/Config/production.ini, api2/Config/beta.ini, api2/Config/sandbox-client.ini, test/@srija/Startech Testing/PC Matic B2B/test_e2e_pcmaticb2b.sh |
5
6
  | [PC Matic B2B — Startech Ticket Sync](features/startech-ticket-sync.md) | 1.0 | Bidirectional ticket sync between TOGaDesk 1.0 (client 177), TOGA 2.0 (client 21), and Startech (Easeedesk). | worker/crons/toga2/startech/sync_togadesk_startech_pcmaticb2b.php, worker/crons/toga2/startech/common_import_supporting_records.php, library/app/api/toga2.php, library/app/api/startechticket.php, togadesk/desk/includes/controllers/quickactions.php, togadesk/desk/template/pages/tickets/manage.php, togadesk/desk/includes/functions.php, worker2/Worker/Startech.php, _underscore/Trait/Startech/Ticket.php, dbchanges2/Client_Pcmaticb2b/2026-06-22-pcmaticb2b-enhancements.sql |
6
7
  | [PC Matic B2B Client Profile](profile.md) | 1.0 | PC Matic B2B is a client using TOGaDesk 1.0 for ticket management, with TOGA 2.0 as the data layer and Startech (Easeedesk) as an external ticketing system for | worker/crons/toga2/startech/sync_togadesk_startech_pcmaticb2b.php, togadesk/desk/includes/functions.php |
@@ -0,0 +1,118 @@
1
+ ---
2
+ title: PC Matic B2B — Startech Entitlement Provisioning (customer + SKU)
3
+ framework: "2.0"
4
+ project: TOGa
5
+ client: pcmaticb2b
6
+ type: client-feature
7
+ status: active
8
+ updated: 2026-07-09
9
+ owners: [snaredla]
10
+ files:
11
+ - _underscore/Trait/Startech/Entitlement.php
12
+ - _underscore/Model/Pcmaticb2b/Entitlement.php
13
+ - _underscore/Config.php
14
+ - api2/Config/production.ini
15
+ - api2/Config/beta.ini
16
+ - api2/Config/sandbox-client.ini
17
+ - test/@srija/Startech Testing/PC Matic B2B/test_e2e_pcmaticb2b.sh
18
+ related:
19
+ - clients/pcmaticb2b/features/startech-ticket-sync.md
20
+ - clients/pcmaticb2b/profile.md
21
+ - 1.0/apps/library/features/startech-pcmaticb2b-sync.md
22
+ ---
23
+
24
+ ## Summary
25
+
26
+ When a PC Matic B2B entitlement is created in TOGA 2.0 (`POST /entitlements`), the client
27
+ registers the customer on StarTech's OptimumDesk platform and assigns the entitlement SKU.
28
+ This is the "provisioning" leg — distinct from the ticket sync. It is **one-way, POST-only**:
29
+ it fires on entitlement create, not on update.
30
+
31
+ ## Key files / entry points
32
+
33
+ - `_Model_Pcmaticb2b_Entitlement::postPost` (`_underscore/Model/Pcmaticb2b/Entitlement.php`)
34
+ activates the subscription (`isActive = true`) then calls the trait via
35
+ `use _Trait_Startech_Entitlement { postPost as traitEntitlementPostPost; }`.
36
+ - `_Trait_Startech_Entitlement::postPost` (`_underscore/Trait/Startech/Entitlement.php`)
37
+ does the two OptimumDesk calls.
38
+
39
+ ## How it works
40
+
41
+ 1. `POST /customer` — body `{ firstName, lastName, emailAddress }` →
42
+ `{"status":"OK","customerId":"<id>"}`. If already present, the response message contains
43
+ `Already Registered` + `customerId=<id>` (regex-extracted).
44
+ 2. `POST /sku` — body is a **JSON array** of assignment objects:
45
+ `[{ "customerID": "<id>", "sku": "140102", "purchaseDate": "YYYY-MM-DD", "quantity": "1" }]`
46
+ → `{"status":"OK","service_id":<id>,"sku":140102}`.
47
+ - `customerID` is **capital-D** (lowercase `customerId` fails customer resolution).
48
+ - `sku` `140102` = `_Model_Pcmaticb2b_Entitlement::STARTECH_ENTITLEMENT_SKU`.
49
+
50
+ ## Endpoint & credentials
51
+
52
+ - Unified endpoint `https://scuses.optimumdesk.com`, **HTTP Basic auth**, account
53
+ `pcmaticb2b@startechteam.ro`.
54
+ - Credentials come from config section **`[startechPcmaticb2b]`**, read via
55
+ `_Config::startechPcmaticb2b('opDeskEndpoint' | 'opDeskUsername' | 'opDeskPassword')`.
56
+ `_Config` has **no explicit method** — `__callStatic` maps the method name directly to the
57
+ ini section name, so the section name and the call must match exactly.
58
+ - The section exists in `production.ini`, `beta.ini`, and `sandbox-client.ini`
59
+ (key order: endpoint, username, password). It was previously named `[asiIntegration]`
60
+ (account `asi.integration`) — renamed because that account's catalog lacked SKU 140102.
61
+
62
+ ## Data model
63
+
64
+ - `_Model_Pcmaticb2b_Entitlement` adds `c_togaServiceRequestId` (INTEGER). The trait does
65
+ **not** write it — it is set later by the `syncWithToga` V1 write-back leg.
66
+ - `prePost` rejects a cancelled subscription (`dateCancelled !== null` → "Subscription not valid").
67
+
68
+ ## Client variations
69
+
70
+ Provisioning is pcmaticb2b-only; the trait is applied solely to `_Model_Pcmaticb2b_Entitlement`.
71
+
72
+ ## Verifying it worked
73
+
74
+ There is **no GET-SKU / list-services endpoint** on the provisioning API — probing
75
+ `/sku?customerID=`, `/customer/{id}/services`, `/skus/{id}` all return the OptimumDesk
76
+ "machine-to-machine" 404 page. Only `GET /customer/{id}` works, and it does **not** list SKUs.
77
+
78
+ The authoritative check is the **outbound API log**:
79
+ ```sql
80
+ SELECT direction, method, route, responseCode, responsePayload
81
+ FROM Logs_Pcmaticb2b.Api
82
+ WHERE direction = 'OUT' AND route IN ('/customer', '/sku')
83
+ ORDER BY id DESC LIMIT 4;
84
+ ```
85
+ Look for `OUT POST /sku` → `service_id` + `sku:140102`, `responseCode 200`. The framework
86
+ logs the trait's outbound calls here with `direction='OUT'`.
87
+
88
+ ## End-to-end verification (2026-07-09, post-deploy)
89
+
90
+ Confirmed live against production `api.togahub.com`:
91
+ - `POST /entitlements` → 201; subscription flipped `isActive=1`.
92
+ - Trait fired → OUT log showed `POST /customer` OK (`customerId 1864974`) and
93
+ `POST /sku` OK (`service_id 623917`, `sku 140102`).
94
+
95
+ Runnable harness: `test/@srija/Startech Testing/PC Matic B2B/test_e2e_pcmaticb2b.sh` (bash +
96
+ curl + python). Covers entitlement flow, provisioning direct, ticketing, and import read-side
97
+ with PASS/FAIL assertions. TOGA2 curl auth = `POST /auth/api {client,api,secret}` →
98
+ `data.tokens.access`, then `Authorization: Bearer`.
99
+
100
+ ## Gotchas / known issues
101
+
102
+ - **Silent failure.** On any error the trait `error_log`s and returns — no exception. If creds
103
+ or endpoint are wrong, provisioning fails invisibly. Check `error_log` / the OUT log.
104
+ - **`/sku` must be an array**, and `customerID` must be capital-D.
105
+ - **One-way, POST-only.** No `postPut` — an entitlement created/updated through any non-POST
106
+ path (or the `syncWithToga` PUT write-back) does not (re)register the SKU.
107
+ - **Config drift across envs.** All three env inis must carry `[startechPcmaticb2b]`; a missing
108
+ section means `_Config::startechPcmaticb2b()` returns falsy and the trait bails.
109
+
110
+ ## Related docs
111
+
112
+ - Ticket sync: `clients/pcmaticb2b/features/startech-ticket-sync.md`
113
+ - Library sync engine + import: `1.0/apps/library/features/startech-pcmaticb2b-sync.md`
114
+
115
+ ## Change history
116
+
117
+ - 2026-07-09: Initial doc — entitlement→OptimumDesk customer+SKU provisioning, unified
118
+ endpoint, `[startechPcmaticb2b]` config rename, no-GET-SKU / OUT-log verification, E2E test.
@@ -5,7 +5,7 @@ project: TOGa
5
5
  client: pcmaticb2b
6
6
  type: client-feature
7
7
  status: active
8
- updated: 2026-07-06
8
+ updated: 2026-07-09
9
9
  owners: [snaredla]
10
10
  files:
11
11
  - worker/crons/toga2/startech/sync_togadesk_startech_pcmaticb2b.php
@@ -22,6 +22,7 @@ related:
22
22
  - clients/pcmaticb2b/profile.md
23
23
  - 2.0/apps/worker2/features/startech-webhook-handler.md
24
24
  - 1.0/apps/library/features/startech-pcmaticb2b-sync.md
25
+ - clients/pcmaticb2b/features/startech-entitlement-provisioning.md
25
26
  ---
26
27
 
27
28
  ## Summary
@@ -72,6 +73,8 @@ Column: `TINYINT(1) UNSIGNED NULL DEFAULT NULL`
72
73
 
73
74
  The `ticketType` on Startech-originated tickets is resolved dynamically via `c_startechTicketTypeId` on `TicketTypes` — not hardcoded.
74
75
 
76
+ **Verified 2026-07-09 (company 24412):** `getTicketTypes(24412)` returns exactly two active types — **50 = Support Request**, **54 = Phone Call Support**. The import cron uses `STARTECH_TICKET_TYPE_IDS='50,54'` and maps them to TOGaDesk departments `50→336` (Support Request), `54→337` (Phone Call/PCS). The older `SUPPORT REQUEST=6 / API=201` values above are stale for this company — treat 50/54 as authoritative.
77
+
75
78
  ## Sync Direction 1: Startech to TOGA 2.0 (Webhook)
76
79
 
77
80
  `worker2/Worker/Startech.php` — `_Worker_Startech::Webhook()`
@@ -162,7 +165,34 @@ const TOGADESK_TICKET_TYPE__TOGADESK_PCS = 'TOGaDesk-PCS';
162
165
  - **Staff-email collision.** `App_Api_Toga2::syncToga2ContactIntoTogadesk1People` matches `people` by email OR referenceId and **updates in place**. If the email already exists as a staff/admin account (clientid ≠ 177), it links `referenceId` onto that staff row and never creates a client-177 `type='user'` customer. Use a fresh, non-staff email for customer test tickets.
163
166
  - **The escalate button applies desk changes unconditionally.** The handler sets dept→337, `escalateToStartech=1`, Ticket Type→"Phone Call Support", and adds history **regardless of 2.0 linkage**. The `c_escalateToStartech=1` PUT (which triggers Startech) fires **only when `referenceId` is already set**; unlinked tickets are left for the sync cron to link — the button does not create the 2.0 ticket on-demand.
164
167
 
168
+ ## Sync Risks (2026-07-09, from full-path trace)
169
+
170
+ These are latent issues in the current loop-guard design — flagged for a follow-up fix,
171
+ not yet resolved:
172
+
173
+ 1. **`c_escalateToStartech` is never reset after escalation.** Once an analyst escalates,
174
+ the 2.0 record keeps `=1` permanently. Every subsequent togadesk→2.0 sync PUT (every 2 min
175
+ on any ticket/note change) delivers `$outData` with `escalate=1` + `c_startechTicketId` set,
176
+ so `postPut` passes the `!== 1` guard and re-pushes a `change_ticket_status`/category update
177
+ to StarTech each cycle. Not an infinite loop, but repeated echo updates.
178
+ 2. **Webhook flips `1→0`, silencing later analyst-side updates.** Once StarTech's webhook writes
179
+ `c_escalateToStartech=0` on an escalated ticket, later Togadesk edits (synced up without
180
+ setting escalate) leave the stored value at 0, so `postPut`'s `!==1` guard blocks them —
181
+ legitimate analyst changes to an escalated ticket may stop propagating to StarTech.
182
+ 3. **Interceptor firing is DB-gated.** `postPost`/`postPut` only run if an active
183
+ `ApiPayloadInterceptor` row exists for `_Model_Pcmaticb2b_Ticket` POST/PUT
184
+ (`api2/Component/Api/V2/V2.php`). Correct trait code is insufficient — verify the rows in
185
+ the live client DB or escalation silently never fires.
186
+
187
+ ## Verification
188
+
189
+ - **Entitlement provisioning** (customer + SKU) — see
190
+ `clients/pcmaticb2b/features/startech-entitlement-provisioning.md`. Confirm via
191
+ `Logs_Pcmaticb2b.Api` `direction='OUT'` rows for `/customer` + `/sku`.
192
+ - **Runnable E2E harness:** `test/@srija/Startech Testing/PC Matic B2B/test_e2e_pcmaticb2b.sh`.
193
+
165
194
  ## Change history
166
195
 
167
196
  - 2026-06-23: Initial doc — full bidirectional sync, c_escalateToStartech semantics, ticket type maps, escalate button, DB migration, constants, gotchas
168
197
  - 2026-07-06: Added sync prerequisites/gotchas (entitlement gate, staff-email collision) and clarified the escalate button's actual behavior (desk changes always; 2.0 PUT only when linked)
198
+ - 2026-07-09: Corrected Startech ticket-type IDs (50 Support Request, 54 Phone Call Support for company 24412); added Sync Risks (escalate flag never reset, webhook 1→0 silencing, interceptor DB-gating) and Verification (OUT-log + E2E test); cross-linked entitlement provisioning.
@@ -41,7 +41,7 @@ Credit Card Receipts/
41
41
  {Person}/
42
42
  {Year}/
43
43
  {BillingCycleFolder}/ ← receipt files live here (e.g. "Amex ending in 06-03-2026")
44
- {Person} reports/ ← .xlsx billing statements for cross-verification
44
+ {Person} report[s]/ ← .xlsx billing statements (folder name matched on singular "report")
45
45
  Archive/ ← successfully processed receipts land here (renamed, flat)
46
46
  Archive/exception/ ← failed receipts land here (original name preserved, flat)
47
47
  ```
@@ -51,13 +51,23 @@ Credit Card Receipts/
51
51
  1. **Auth** — OAuth2 client-credentials token from Microsoft Graph
52
52
  2. **Walk** — `walkReceiptsFolder()` enumerates all receipt files and statement Excels
53
53
  3. **Filter** — optional `$year` / `$person` / `$billingCycle` / `$limit` applied to the receipt list. `$billingCycle` uses `str_ends_with()` so passing just the date (e.g. `"06-03-2026"`) matches any card type. Person folders named `"Brent Peterkin CC receipts"` are normalized to `"Brent Peterkin"` via regex strip of ` CC receipts` suffix before any map lookups.
54
- 4. **Pre-load statements** — all billing-cycle `.xlsx` files under `reports/` are downloaded
55
- and parsed upfront via PhpSpreadsheet (auto-detect header row by scanning for
56
- description/amount keywords)
54
+ 4. **Pre-load statements** — all billing-cycle `.xlsx` files under a person's statements
55
+ folder are downloaded and parsed upfront via PhpSpreadsheet (auto-detect header row by
56
+ scanning for description/amount keywords). **Statements folder detection matches the
57
+ singular `report`.** `walkReceiptsFolder()` recognizes a person's statements folder via
58
+ `stripos($child->name,'report')` (singular). It previously matched `'reports'` (plural)
59
+ only, so cardholders whose folder is named `"{Name} report"` (singular — Angela Leis,
60
+ Kathryn Lockhart, Meghan Lowney, Eileen Wiseman) had their statement Excel **never
61
+ loaded**: `$parsedStatements[$person]` was unset and the entire `Notes`-memo override was
62
+ silently skipped (AI-inferred memos shipped instead). The singular match also covers
63
+ `"reports"`, `"Report"`, `"Amex reports"`. **Folder-name matching is the gate for the
64
+ whole Notes-memo feature** — verifying `matchStatementNotes()` in isolation is
65
+ insufficient; the statement must actually load during `Run()`, so verify against a real
66
+ run that `$parsedStatements` includes the person.
57
67
  5. **Pass 1 — Extract** — for each receipt:
58
68
  - Enforce type + size limit: unsupported mime types (`application/octet-stream`) throw immediately; 4 MB cap for images, 10 MB for documents
59
69
  - Download file bytes from SharePoint
60
- - **`.docx` files bypass Talos** and route to `extractDocxData()` (Talos returns HTTP 500 on a `.docx` MIME type — it only accepts PDF/image). That method opens the docx as a ZIP, extracts `word/document.xml`, strips tags, and regex-parses ride lines into `line_items[]`. The ride-line regex (in both `extractDocxData()` and `parseDocxRideLines()`) accepts **slash or dot** date separators (`[\/.]` — e.g. `05/06/2025` and `05.06.2025`) and treats the `[:\-]?` description delimiter as **optional**. Dash-separated dates (`05-06-2025`) are intentionally **not** supported — a dash would clash with the `:`/`-` description delimiter. A docx that matches zero ride lines throws `ReceiptProcessingException` and produces no Excel, so a new date format silently zero-outputs — widen the regex when a cardholder introduces one
70
+ - **`.docx` files bypass Talos** and route to `extractDocxData()` (Talos returns HTTP 500 on a `.docx` MIME type — it only accepts PDF/image). That method opens the docx as a ZIP, extracts `word/document.xml`, strips tags, and regex-parses ride lines into `line_items[]`. **Tag-stripping — a run boundary is not a word boundary.** When flattening the XML to text, `</w:r>` (run end) must be replaced with `''` (empty string), **not** a space — Word splits a single word across multiple runs, so replacing `</w:r>` with a space injects spaces mid-word (`"Innocence"`->`"Innoc ence"`, `"Armstrong"`->`"Armstron g"`, `"2026.06.03"`->`"202 6 . 0 6 .0 3"`). Only `</w:p>` (paragraph end) is a real line break (`"\n"`); `<w:t>` text keeps its own spaces. Both `extractDocxData()` and `parseDocxRideLines()` do this replacement. **Header strip before ride parsing.** `extractDocxData()` first removes any statement header line via `preg_replace('/(?:AMEX|Visa)\s+Closing\s+Date\s+\d{4}[.\/-]\d{1,2}[.\/-]\d{1,2}/i','',$text)` — otherwise the ride regex greedily matches `"AMEX Closing Date 2026.06.03"` as the first ride (reading the `YYYY.MM.DD` as a date and swallowing the real first ride into its description, corrupting row 1 and dropping the last real ride). The header is stripped by keyword rather than by restricting ride dates to 2-digit years — narrowing ride dates would regress Jheanelle's 4-digit dot dates. The ride-line regex (in both `extractDocxData()` and `parseDocxRideLines()`) accepts **slash or dot** date separators (`[\/.]` — e.g. `05/06/2025` and `05.06.2025`) and treats the `[:\-]?` description delimiter as **optional**. Dash-separated dates (`05-06-2025`) are intentionally **not** supported — a dash would clash with the `:`/`-` description delimiter. A docx that matches zero ride lines throws `ReceiptProcessingException` and produces no Excel, so a new date format silently zero-outputs — widen the regex when a cardholder introduces one
61
71
  - All other types POST to Talos AI `/api/ai/generate` → structured `{vendor_name, invoice_date, total, payment_memo, category, ...}`
62
72
  - **Year guard on AI dates** — if Talos returns an `invoice_date` whose year is more than 1 year from the current year (AI hallucination on two-digit year inputs, e.g. `5/13/76` → 1976, `5/28/28` → 2028), the year is clamped to the current year while month/day are preserved
63
73
  - **Payment memo — the statement `Notes` column is authoritative.** `loadStatementExcel()` detects a `Notes` column (exact-match `"notes"`/`"note"` first; a substring fallback runs only if no exact header was found, guarded by `$notesExact`) and attaches a `notes` field to each parsed row. In `Run()`, if the matching statement row has a **non-empty `notes` value**, it is used **verbatim** as `payment_memo` — this is the cardholder's own hand-written memo (e.g. `"Postage: Board materials shipped to Frank Tow"`) and beats any AI-inferred memo. Only if the row has no notes does processing fall back to the prior AI/description behavior. This **supersedes** the earlier `isActivityHeadingMemo()` allow-list, which dropped the cardholder's Notes entirely (it only detected columns containing `"description"`/`"memo"`) and rejected valid headings like `Communications:`, `Postage:`, `Office Supplies:`, `RF:` — shipping AI memos in their place.
@@ -201,6 +211,22 @@ the old one-shot `/tmp/tow_restore_archives.php` CLI script.
201
211
  walks all subfolders of `Archive/` (both the processed root and `exception/`) and collects
202
212
  every non-folder item into a flat accumulator array.
203
213
 
214
+ ### Reprocessing a cycle locally (operational)
215
+ To re-run one cardholder + cycle after a code fix, without deploying:
216
+ 1. **Restore receipts** — call `MoveBack(person, "Amex ending in MM-DD-YYYY", "2026")` to move
217
+ archived receipts back into the billing-cycle folder.
218
+ 2. **Run via CLI, not the HTTP endpoint** — invoke the action directly in PHP so the run
219
+ bypasses the Worker HTTP/`WorkerJobs` dispatch:
220
+ ```
221
+ ENVIRONMENT=dev-... php -r '... require "_underscore.php"; echo _Worker_Client_TowFoundation_ProcessReceipts::Run(null,null,$person,$cycle);'
222
+ ```
223
+ The local worker HTTP endpoint returns **HTTP 500** on direct `{action,parameters}` calls
224
+ because the 2026-07-07 change made the direct-payload path require a `Core.WorkerJobs`
225
+ table that local dev DBs lack (MySQL Error #1146 `core.workerjobs doesn't exist`). The CLI
226
+ path avoids that dispatch entirely.
227
+ 3. **Email is safe locally** — each `Run()` sends the summary email, but with `debug_mode=1`
228
+ locally all mail is redirected to `send_debug_emails_to`, so no client contact is reached.
229
+
204
230
  ## Email routing
205
231
 
206
232
  Defined as constants in `TowFoundation.php`:
@@ -254,6 +280,15 @@ Fatal errors send only to `NOTIFY_EMAIL_DEV` (no CC/BCC).
254
280
  - **Person folder name normalization** — SharePoint folders are named `"Brent Peterkin CC receipts"` but `CLASS_MAP` / `PAYMENT_ACCOUNT_MAP` keys are just `"Brent Peterkin"`. The ` CC receipts` suffix is stripped via regex in `walkReceiptsFolder()`. Without this, Class and Payment Account columns are blank for those persons.
255
281
  - **Archive paths must use the actual SharePoint folder name, not the normalized name** — the normalized person name (` CC receipts` stripped, spaces replaced) is for **map lookups only**. When building the SharePoint archive path, use the *actual* folder name (e.g. `"Emily Tow CC receipts"`), not the normalized `"Emily Tow"` — otherwise the PATCH move 404s because the path segment does not exist. Keep the normalized name and the real folder name as separate values.
256
282
  - **`billingCycle` filter is suffix-match, not exact** — always pass just the date portion (`"06-03-2026"`), not the full folder name. Passing the full name (`"Amex ending in 06-03-2026"`) also works but would miss Mastercard folders.
283
+ - **Missing per-cycle statement → no Notes memos (data dependency, not a bug)** — statements
284
+ are keyed by `cycleKey = filename-without-extension`, and the code looks up the statement
285
+ whose name matches the billing-cycle folder (e.g. `"Amex ending in 06-03-2026"`). If a
286
+ cardholder's report folder has no statement file for that cycle, **zero Notes are loaded**
287
+ and **all** of that person's memos fall back to AI-inferred (their hand-written `"RF:"` red
288
+ memos never appear). As of the 2026-07-09 run, **Nadia Alia's** `"Nadia's Amex reports"`
289
+ folder held Jan–May statements but no `"Amex ending in 06-03-2026.xlsx"` (and even her 05-03
290
+ file has no `Notes` column). Resolution is a **client action**: upload Nadia's 06-03-2026
291
+ statement with a `Notes` column, then re-run her. Not a code fix.
257
292
  - **Vendors not in QB vendor list** — as of June 2026 these vendors are not in Tow Foundation's QuickBooks, so `qb_vendor` is blank for their charges until the client adds them to QB and the vendor list is refreshed: **Ole Mole**, **AMORE PIZZA CAFE**, **Green & Tonic (New Canaan)**, **NCFP (National Center for Philanthropy)**, **Rippling**, **Langan's**. This is a client action, not a code fix.
258
293
  - **Duplicate detection is within-run and `fileId`-keyed only** — dedup keys on the SharePoint
259
294
  drive-item `fileId`, so it catches only a literal re-upload of the same item present in the
@@ -274,9 +309,16 @@ Fatal errors send only to `NOTIFY_EMAIL_DEV` (no CC/BCC).
274
309
  - **Amount reconciliation is flag-only** — mismatched OCR amounts are surfaced in the "Amounts to
275
310
  verify" email section but never auto-corrected from the statement; auto-override was deferred as
276
311
  higher-risk.
312
+ - **Not yet reprocessed / undeployed (as of 2026-07-09)** — **Jheanelle** and **Diane** still need
313
+ a `MoveBack` + `Run` to pick up the docx run-boundary / Closing-Date-header fixes. All 2026-07-09
314
+ fixes are **local and uncommitted** — no deployment has been performed.
315
+ - **Subway docx line-item memos come from the docx, by design** — the per-ride memos on a subway
316
+ `.docx` are sourced from the docx line-item description, not the statement `Notes` column. This is
317
+ intentional; do not "fix" it by routing them through `matchStatementNotes()`.
277
318
 
278
319
  ## Change history
279
320
 
321
+ - 2026-07-09 — **Local reprocess-all-cardholders verification pass — three more fixes + two data/ops findings (code-only, uncommitted).** These fixes were found *after* the six-fix pass earlier the same day, while re-running every cardholder locally and inspecting the uploaded QB files. (1) **Statements-folder detection matched plural `reports` only** — `walkReceiptsFolder()` now matches `stripos(name,'report')` (singular), so `"{Name} report"` folders (Angela Leis, Kathryn Lockhart, Meghan Lowney, Eileen Wiseman) actually load their statement; without this the earlier Notes-memo fix silently did nothing for them in a real `Run()` (folder-name matching is the gate — verifying `matchStatementNotes()` in isolation is not enough). (2) **docx run-boundary word-splitting** — `</w:r>` was replaced with a space, injecting spaces mid-word (`"Innoc ence"`, `"Armstron g"`, `"202 6 . 0 6 .0 3"`) in Emily Tow's subway memos; now replaced with `''` at both sites (`extractDocxData`, `parseDocxRideLines`) — only `</w:p>` is a real line break. (3) **docx "Closing Date" header mis-parsed as first ride** — the ride regex read `"AMEX Closing Date 2026.06.03"` as a ride and swallowed the real first ride; now stripped upfront via `preg_replace('/(?:AMEX|Visa)\s+Closing\s+Date\s+\d{4}[.\/-]\d{1,2}[.\/-]\d{1,2}/i','',$text)` (kept 4-digit dot dates for Jheanelle rather than narrowing ride dates). Data/ops findings (no code change): **Nadia Alia's** June statement is absent from SharePoint → all her memos AI-inferred until the client uploads `"Amex ending in 06-03-2026.xlsx"` with a Notes column; local worker HTTP endpoint 500s on direct `{action,parameters}` calls (post 2026-07-07 `Core.WorkerJobs` dependency absent in dev) → reprocess a cycle via CLI + `MoveBack` (debug_mode redirects mail). Verified correct: Angela, Emily (incl. 14 subway line items), Kathryn, Meghan (Kellari $548.11 flagged), Michael (all 5 charges). Nadia pending client statement; Jheanelle + Diane not yet reprocessed. (rgirish)
280
322
  - 2026-07-09 — **Client QA pass on a production run — six fixes (code-only, not deployed).** (1) Statement `Notes` column is now the **authoritative** payment memo, used verbatim; `loadStatementExcel()` detects `Notes` (exact-match first, guarded substring fallback) — replaces the `isActivityHeadingMemo()` allow-list that dropped hand-written memos (fixed Angela, Emily, Katy, Meghan, Nadia). (2) `matchStatementCandidate($strict)` returns `null` on ambiguous same-amount matches for the Notes memo instead of guessing; `matchStatementNotes()` is strict, description fallback stays best-effort. (3) Dedup now keys on SharePoint `fileId` only — the old person|date|vendor|amount key dropped distinct same-amount charges (Michael lost 2 of 5). (4) docx ride-line regex now accepts dot dates (`[\/.]`) with optional `[:\-]?` delimiter — Jheanelle's dot-format subway docx previously matched 0 lines and produced no output. (5) `buildExcelFileName()` now derives the reporting month from the billing-cycle end (`parseCycleEndDate()`/`resolvePersonCycle()`) not the charge date, so the filename agrees with the Ref No. (Diane). (6) Systemic discovery: non-docx `payment_amount` is AI-OCR only, never reconciled against the statement `Amount` (Meghan $548.11 vs $538.11); added flag-only `statementHasAmount()` + `$warnings` "Amounts to verify" email section — auto-override deferred. (rgirish)
281
323
  - 2026-06-29 — **Reversed the over-aggressive memo override.** Replaced `isBareTranportationMemo()` (added earlier same day) with `isActivityHeadingMemo()`: the statement memo now overrides the AI memo **only** when it is a proper activity-purpose heading (e.g. `Grantee meeting:`, `Peer meeting:`), not whenever the AI memo started with `"Transportation:"`. The previous approach replaced semi-useful AI memos with raw bank strings (`"AplPay LYFT …"`, `"UBER"`) on ride-share/taxi rows where the statement has no activity heading — degraded 8 rows on Emily Tow's statement (1, 17, 18, 21, 26, 31, 32, 34). New priority: statement heading > AI memo, with AI memo kept for any non-heading statement description. (rgirish)
282
324
  - 2026-06-29 — `payment_memo` override now also fires on a **bare** `"Transportation:"` AI memo, not just an empty one: new `isBareTranportationMemo()` lets the cardholder's statement Excel (the authoritative source for activity-purpose headings like "Peer meeting:", "Grantee event:") override the AI's generic transportation heading. Fixes wrong headings on Emily Tow May 2026 transit rows. Added the committed `MoveBack` action (+ `collectFilesRecursiveRaw()`) to restore a person's archived receipts into a billing-cycle folder for reprocessing, replacing the throwaway `/tmp/tow_restore_archives.php` script. (rgirish)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.306",
3
+ "version": "1.0.308",
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",