toga-ai 1.0.971 → 1.0.972

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: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-10-05
10
- owners: ["mhammontree", "dfranks", "bala", "snaredla", "jcardinal"]
9
+ updated: 2026-10-08
10
+ owners: ["mhammontree", "dfranks", "bala", "snaredla", "jcardinal", "rgirish"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
13
13
  - api2/Controller/Index.php
@@ -211,7 +211,7 @@ A `postPost`/`postPut` hook that hands work to worker2 (`_Worker::runTask`) sits
211
211
  5. **The pattern that works:**
212
212
  - Read the record **inside the same request** with `internalApiRequest`, wrapped in `getIsReadHostEnabled()` / `setIsReadHostEnabled(false)` and restored in a **`finally`** (pin the read to the writer, then put the flag back for everyone else).
213
213
  - **Superseded 2026-10-05 for queueing: pass only the uuid, never the object** — a nested record can exceed the SQS 256KB cap and the job is lost. The worker reads the record itself with a bounded wait for the commit. Rule + reference: [runTask parameters are ids only](../../worker2/features/creating-worker-actions.md#-runtask-parameters-are-idsuuids-only--never-record-objects). (The Elite sales-order push still carries `?object $salesOrder` — legacy.) The writer-pinned read stays correct when the hook itself needs the record.
214
- - Pass **`false`** for `internalApiRequest`'s 5th argument (`$throwExceptionsOnError`). Its throw is guarded by `!empty($this->response->messages)` on the **shared** response object, and `json_encode([])` is the **truthy** string `"[]"` — so it throws even with **zero real errors** once any message (even a warning) is present anywhere in the request. **Not theoretical:** it took Elite's production `POST /v2/sales-orders` down on 2026-08-20, from the *parent* model's own `internalApiRequest` (see [the inherited-chain section](#-activating-a-clients-first-interceptor-row-also-switches-on-the-whole-inherited-chain)). The throw site is `V2.php:2416`.
214
+ - Pass **`false`** for `internalApiRequest`'s 5th argument (`$throwExceptionsOnError`). Its throw is guarded by `!empty($this->response->messages)` on the **shared** response object, and `json_encode([])` is the **truthy** string `"[]"` — so it throws even with **zero real errors** once any message (even a warning) is present anywhere in the request — **including the OUTER request's own warnings** collected before the internal call (e.g. `WO-1`, request depth below an interceptor row's `minDepth`; the worker posts at `depth -1`). **Not theoretical:** it took Elite's production `POST /v2/sales-orders` down on 2026-08-20, from the *parent* model's own `internalApiRequest` (see [the inherited-chain section](#-activating-a-clients-first-interceptor-row-also-switches-on-the-whole-inherited-chain)). The throw site is `V2.php` ~L2436 (open bug, 2026-10-08, needs its own ticket).
215
215
 
216
216
  **Two more failure modes on this path:**
217
217
  - **Config drift kills it before it starts.** `_Worker::runTask`'s non-debug path reads `[cloud] aws_worker_queue_url` / `aws_worker_queue_region`, and `_Config::__callStatic` **throws on a missing key even inside a group that exists** — an uncaught throw from `postPost` surfaces as **HTTP 500 `EO-1` with a null envelope and no log row**. See [_Config group access](../../_underscore/features/config-group-access.md).
@@ -225,10 +225,10 @@ A client whose `ApiPayloadInterceptors` set is **empty** has never run `postPost
225
225
  1. Elite's `postPost` calls `parent::postPost()` first (correctly — approvals must still be created);
226
226
  2. the parent, `_underscore/Model/Client/SalesOrder.php:359`, reads `/approval-templates` via `internalApiRequest` with **throwing left ON**;
227
227
  3. Elite has **zero `ApprovalTemplates`** rows, so that nested GET returns empty and puts a message on the **shared** response object;
228
- 4. `V2.php:2416` does `json_encode(array_filter($messages, ...ERROR...))` → the string `"[]"` → **truthy** → throw, with no real error in it;
228
+ 4. `V2.php` ~L2436 does `json_encode(array_filter($messages, ...ERROR...))` → the string `"[]"` → **truthy** → throw, with no real error in it;
229
229
  5. the controller catches it, returns `EO-1`, and **rolls back** the write.
230
230
 
231
- **Reading the signature:** an exception whose **message is `[]`** on a write route means *this* — a message-array truthiness throw. Nothing is wrong with the payload, and the client's own interceptor code may never have executed. **Two fixes, both in shared code:** pass **`false`** as `internalApiRequest`'s 5th argument in the **parent** (narrow, per-caller); or fix **`V2.php:2416`** to test the **filtered array** rather than `json_encode()` of it — which fixes **every** caller on this path. Senior-owned: raise it rather than patching one caller.
231
+ **Reading the signature:** an exception whose **message is `[]`** on a write route means *this* — a message-array truthiness throw. Nothing is wrong with the payload, and the client's own interceptor code may never have executed. **Two fixes, both in shared code:** pass **`false`** as `internalApiRequest`'s 5th argument in the **parent** (narrow, per-caller); or fix **`V2.php` ~L2436** to look only at messages added **after `$savedMessageCount`** and throw only when that filtered **array** has a real `TYPE_ERROR` — which fixes **every** caller on this path. Senior-owned: raise it rather than patching one caller.
232
232
 
233
233
  **Before activating a row for a client that has none:** walk the parent chain for the derived method and confirm the data each inherited step depends on actually exists for that tenant (here: at least one `ApprovalTemplates` row). Same reason activation must be staged `isActive = 0` first — see Gotchas.
234
234
 
@@ -263,6 +263,7 @@ The reverse direction: not "queue a worker from an interceptor" but **"run the p
263
263
  - **⚠ A throw from a PRE interceptor leaves NO `Logs_<Client>.Api` row.** The transaction log is written near the END of `_Component_Api_V2::execute()` (~L2225-2271), *after* route processing (~L1742-2043), so an exception raised from `prePost` never reaches it and the rejected request looks like it never arrived. See [V2 request logging](./request-logging.md#-a-throw-from-a-pre-interceptor-leaves-no-api-log-row-at-all).
264
264
  - **⚠ Client behaviour never goes on `_Model_Client_X`.** Every tenant with a row for that record inherits it through `parent::` calls. Put it on `_Model_<Client>_X`.
265
265
  - **A client's rows may be in `Core` (with `clientId`), not in `Client_<X>`** — check both before concluding a client has no interceptors.
266
+ - **⚠ The same hook in BOTH `Core` (with `clientId`) and `Client_<X>` fires TWICE** — both tables are read, nothing dedupes. Adyen (2026-10-08): Core ids 15–17 duplicated client rows 1, 2, 5 → double worker jobs and double NetSuite queueing; fixed by deactivating the Core rows. Keep each `(record, phase, method)` in one table only. See [Adyen reclaim flow](../../../clients/adyen/features/service-request-salesorder-netsuite.md#gotchas).
266
267
  - **The method name is derived, so a typo'd enum silently misses.** A row with `prePostProcessing = 'PRE'`, `httpMethod = 'PUT'` resolves to `prePut`, not `prePost`; the engine finds no method and moves on.
267
268
  - **Registration is per-environment.** Like `RecordFields` / `RecordScripts` / ACL rows, this is metadata — "works in prod, not in beta/dev-sandbox" is the signature of drift, not of a bad deploy. See the [non-prod metadata drift repair workflow](../../dbchanges2/workflows/nonprod-metadata-drift-repair.md).
268
269
  - **⚠ An in-request read-after-write in an interceptor works on beta and fails 100% in production.** Reads default to the **reader** connection, which cannot see the request's own uncommitted row; dev-sandbox points writer and reader at the same endpoint, so it hides the bug. Pin with `setIsReadHostEnabled(false)` (restore in `finally`).
@@ -287,3 +288,6 @@ The reverse direction: not "queue a worker from an interceptor" but **"run the p
287
288
  - [Email template sending](../../_underscore/features/email-template-sending.md)
288
289
  - [Elite SalesOrder → NetSuite push](../../../clients/elite/features/salesorder-netsuite-push.md)
289
290
  - [Compass sales-order line renumbering](../../../clients/compass-usa/features/sales-order-line-renumbering.md)
291
+
292
+ ## Change history
293
+ - 2026-10-08 — `internalApiRequest` "[]" throw also fires on the OUTER request's warnings (e.g. WO-1); throw site now ~L2436; Core + client duplicate rows fire twice. (rgirish)
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-10-06
9
+ updated: 2026-10-08
10
10
  owners: [jcardinal, dfranks, mhammontree, tcox, bala, ajean, rgirish]
11
11
  files:
12
12
  - worker2/Worker/
@@ -151,6 +151,7 @@ in the deployed worker code, so ship the code deploy and the dbchanges2 cron-row
151
151
  ```bash
152
152
  curl -X POST https://worker.togahub.com/ \
153
153
  -H "Content-Type: application/json" \
154
+ -H "Authorization: <worker key>" \
154
155
  -d '{
155
156
  "action": "Category/MyAction/DoSomething",
156
157
  "parameters": { "param1": "example_value", "count": 5 }
@@ -159,6 +160,9 @@ curl -X POST https://worker.togahub.com/ \
159
160
 
160
161
  Use realistic placeholder values, not empty strings/nulls.
161
162
 
163
+ - Needs the worker key in `Authorization` (never paste the value into docs, tickets or chat).
164
+ - Runs **synchronously** and creates **no `Core.WorkerJobs` row** — the SQS path (`_Worker::runTask` → `WorkerProductionQueue`) is the only one that logs a job. Use it to re-run a single action (e.g. one service request); read the result from the HTTP reply.
165
+
162
166
  ### The HTTP reply is PREFIXED — never `JSON.parse` it raw
163
167
 
164
168
  The dispatcher returns your action's return value **wrapped in the same composed success
@@ -539,6 +543,7 @@ Rules: rename **both sides in one PR and one deploy**; grep every repo for the o
539
543
  commit-before-SQS transaction pattern that the worker relies on.
540
544
 
541
545
  ## Change history
546
+ - 2026-10-08 — Direct POST needs the worker key in `Authorization`, runs synchronously, writes no `WorkerJobs` row. (rgirish)
542
547
  - 2026-10-06 — Absent-row section: a 200 + MessageId send can still produce no row for up to 1h (failed first delivery hidden by the visibility timeout). (ajean)
543
548
  - 2026-10-05 — Rule: `runTask` parameters are ids/uuids only; worker reads with bounded wait; producer catch must `_Error::captureException`. (jcardinal)
544
549
  - 2026-10-05 — Added read-host read-after-write gotcha for worker actions (TRUE-82185) (mhammontree)
@@ -6,8 +6,8 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-23
10
- owners: ["bala", "jcardinal"]
9
+ updated: 2026-10-08
10
+ owners: ["bala", "jcardinal", "rgirish"]
11
11
  files:
12
12
  - worker2/Worker/Netsuite/SalesOrder.php
13
13
  - test/@Bala/tests/netsuite_salesorder_payload_tests.php
@@ -105,10 +105,11 @@ Four helpers gained a transfer-order branch, each **defaulting to the old behavi
105
105
  `fetchSalesOrder($ctx, $uuid, $isTransferOrder = false)`, `writeBackNetSuiteId()` (route), and
106
106
  `buildNetSuiteOrder()`, which now prefers an explicit `$order->otherRefNum` **before** the
107
107
  ticket-number → service-request-number chain. **`CreateNetSuite` / `UpdateNetSuite` / `Sync` keep
108
- their original signatures**, and the sales-order path is provably unchanged because
109
- `_Model_Client_SalesOrder` declares **no `otherRefNum` field** — the new branch is unreachable from a
110
- sales order. When you add a precedence like that, verify the *other* model does not have the field
111
- rather than assuming it will not be set.
108
+ their original signatures**. `_Model_Client_SalesOrder` declares **no `otherRefNum` field**, but the
109
+ branch **is** reachable for a sales order when the caller sets it on the queued snapshot: since
110
+ 2026-10-08 `_Model_Adyen_SalesOrder::queueNetsuiteCreate()` sets `otherRefNum` = employee name for
111
+ SR-generated orders (see [Adyen reclaim flow](../../../clients/adyen/features/service-request-salesorder-netsuite.md)).
112
+ When you add a precedence like that, check every producer of the snapshot, not only the model fields.
112
113
 
113
114
  ### The job carries the order snapshot — the worker does not re-read it
114
115
 
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-10-01
9
+ updated: 2026-10-08
10
10
  owners: ["snaredla", "bala", "rgirish"]
11
11
  files:
12
12
  - worker2/Worker/Sync/ServiceRequest.php
@@ -37,9 +37,9 @@ _Worker_Sync_ServiceRequest::GenerateSalesOrderFromServiceRequest(
37
37
  ): string
38
38
  ```
39
39
 
40
- Queued from a client's `service-requests` `postPost` interceptor; invocable directly for testing like any worker action. Consumers: **Elite** ([desk intake](../../../clients/elite/features/togadesk-service-request-intake.md)) and **Adyen** ([reclaim flow](../../../clients/adyen/features/service-request-salesorder-netsuite.md), built 2026-09-30, not deployed).
40
+ Queued from a client's `service-requests` `postPost` interceptor; invocable directly for testing like any worker action. Consumers: **Elite** ([desk intake](../../../clients/elite/features/togadesk-service-request-intake.md)) and **Adyen** ([reclaim flow](../../../clients/adyen/features/service-request-salesorder-netsuite.md)).
41
41
 
42
- **Per-client config = `SALES_ORDER_DEFAULTS_BY_CLIENT`**, keyed by `Core.Clients.clientIdentifier`: `locationUuid` (required — NetSuite rejects an order with no location) + `salesOrderTypeUuid` (nullable). Elite → location `3ca47b91-…`, type drop-ship `5994b81d-…`; Adyen → location `191c75ff-f02e-4dda-abc6-c8d26c35da62`, no type. **A client missing from the map throws** — onboarding a client to this worker = add a map entry. The uuids are the same in beta and prod.
42
+ **Per-client config = `SALES_ORDER_DEFAULTS_BY_CLIENT`**, keyed by `Core.Clients.clientIdentifier`: `locationUuid` (required — NetSuite rejects an order with no location) + `shippingMethodId` (fallback when the SR names none). Elite → location `3ca47b91-…`, ship method 1; Adyen → location `191c75ff-f02e-4dda-abc6-c8d26c35da62` (`Locations` id 1 → NS location 70), ship method **5** (Ground). Sales order type is one constant for every client: drop-ship `5994b81d-…` (`SALES_ORDER_TYPE_UUID`). **A client missing from the map throws before any API call** — onboarding a client to this worker = add a map entry with **that tenant's own** row ids (verify the location has `c_netsuiteInternalLocationId`). The uuids are the same in beta and prod.
43
43
 
44
44
  **Proven end to end on dev-sandbox** (`https://api.dev.sandbox.togahub.com/v2`): one service request produced **SA100004** plus purchase order **P1**, both bridge rows linked, and a **rerun returned early** rather than duplicating.
45
45
 
@@ -55,8 +55,8 @@ Steps:
55
55
  1. **Resolve the tenant.** `getClientContextFromCore()` looks up by `Core.Clients.clientIdentifier`, registers the client DB connection, returns Toga API credentials. (Deliberate local copy of the same private in `_Worker_Netsuite_SalesOrder`; lift to a shared parent when a third caller appears.)
56
56
  2. **Guard.** `fetchServiceRequestFromToga2()` joins `SalesOrders` on `serviceRequestId`. If a sales order already exists, **return early** with a summary — makes the action idempotent under SQS redelivery and manual reruns. A request already in the **`Exception`** stage is skipped for the same reason.
57
57
  3. **Reconcile the requester's address** (`reconcileRequesterAddressInToga2`) and **opt the requester into Order Updates** (`optInRequesterToOrderUpdatesInToga2`).
58
- 4. **Build and POST the sales order** (`buildSalesOrderPayloadForToga2`): customer by uuid, `serviceRequestId`, `ticketId`, `locationId` + type from the client's map, `shippingMethodId` = the SR's, else **`ShippingMethods` id 1**, and `shipToAddress` **only when the request has a deliverable address**. Lines come from `fetchServiceRequestLinesFromToga2()`: one line per SR **unit**; if the SR has no units, one line per **bundle item** (`ServiceRequests_Bundles` → `BundleItems` → `Items`). Each line is quantity 1, price 0, `lineNumber` assigned by the worker. No units and no bundle items → throws (no empty order).
59
- 5. **Generate purchase orders** (`tryGeneratePurchaseOrdersInToga2` → `generatePurchaseOrdersInToga2`): re-read created lines with `fetchSalesOrderItemsFromToga2()`, resolve a vendor per item with `fetchVendorItemsFromToga2()`, group lines by vendor, POST **one PO per vendor** with line numbers restarted per order. Items with no vendor are listed in the job result as `No vendor for: …` — the job does **not** fail (Adyen has no `VendorItems`, so it gets no PO).
58
+ 4. **Build and POST the sales order** (`buildSalesOrderPayloadForToga2`): customer by uuid, `serviceRequestId`, `ticketId`, `location` from the client's map, drop-ship type, `shippingMethodId` = the SR's, else the client's map value, and `shipToAddress` **only when the request has a deliverable address**. Lines: one per SR **unit** (qty 1); if the SR has no units, one per **bundle item** from `fetchServiceRequestBundleItemsFromToga2()` (`ServiceRequests_Bundles` → `BundleItems` → `Items`, `BundleItems.isActive = 1`, `BundleItems.serviceRequestTypeId` NULL or equal to the SR's type, qty = `BundleItems.quantity`). Price 0, `lineNumber` assigned by the worker. No units and no bundle items → throws (no empty order).
59
+ 5. **Generate purchase orders — units path only.** A bundle-only SR gets **no** PO (result: `No purchase orders: the Service Request has no units.`). (`tryGeneratePurchaseOrdersInToga2` → `generatePurchaseOrdersInToga2`): re-read created lines with `fetchSalesOrderItemsFromToga2()`, resolve a vendor per item with `fetchVendorItemsFromToga2()`, group lines by vendor, POST **one PO per vendor** with line numbers restarted per order. Items with no vendor are listed in the job result as `No vendor for: …` — the job does **not** fail.
60
60
  6. **On a rejected sales-order POST**, `moveServiceRequestToExceptionInToga2()` parks the request in the `Exception` stage so the failure is visible *and* a redelivered job short-circuits on the step-2 stage guard.
61
61
 
62
62
  ### Vendor resolution replaces Prudential's hardcoded vendor
@@ -93,8 +93,9 @@ The three structural checks (string resolves → producer matches → intercepto
93
93
  - **⚠ Before nesting a child collection in a payload, confirm the child is registered in `Core.InherentRecordChildren` under that parent record.** `salesOrderItemsCommittedUnits` was briefly nested under sales-order lines; it is **impossible** — record 152 has **zero** `Core.InherentRecordChildren` parent registrations under record 15, nothing writes that table via the API at all, and its model's `unitId` is a foreign key to `_Model_Client_User`, not to a unit. A nested key the API does not recognize is not obviously rejected; run the `InherentRecordChildren` check rather than assuming.
94
94
  - **`shipToAddress` carries `childPolicy CREATE`** — posting an all-null address shape **creates a blank address row**. `hasDeliveryAddressForServiceRequest()` omits the key entirely instead.
95
95
  - **Line numbers are assigned by the worker and re-checked.** After the POST the worker maps created lines back by `lineNumber`; a line it cannot map means the API renumbered them, and that is **reported** rather than silently dropped from the PO.
96
- - **⚠ Do not add Prudential to the map without a review.** The worker now reads bundle items, but only as plain qty-1 lines — not Prudential's `BundleItems.preferredVendorId` / Dell flow. Prudential is not in `SALES_ORDER_DEFAULTS_BY_CLIENT`, so a run for it throws.
97
- - **Fallback ship method `ShippingMethods` id 1 is a per-tenant row id**, not a known method. When the SR names none, verify id 1 is the right NetSuite-mapped method for that client (open).
96
+ - **⚠ Do not add Prudential to the map without a review.** Bundle items become plain SO lines with no PO — not Prudential's `BundleItems.preferredVendorId` / Dell flow. Prudential is not in `SALES_ORDER_DEFAULTS_BY_CLIENT`, so a run for it throws.
97
+ - **⚠ Map ids are per-tenant row ids — never copy one client's entry.** Elite's ids broke Adyen: `ShippingMethods` id 1 does not exist in `Client_Adyen` (FK failure), and Adyen `Locations` id 2 has no `c_netsuiteInternalLocationId` (NetSuite push failed).
98
+ - **`SalesOrders.serviceRequestId` is the only duplicate guard.** A NetSuite order entered by hand has it NULL, so re-running an old SR whose order was hand-made creates a **second** order. Link or skip those SRs first (Adyen backlog: [reclaim flow](../../../clients/adyen/features/service-request-salesorder-netsuite.md#gotchas)).
98
99
  - **⚠ Inbound NetSuite sync can duplicate an order this worker created (all clients, open).** `library/app/api/toga2.php:1002` matches sales orders only by `c_netsuiteInternalSalesOrderId`. If the inbound sync runs after NetSuite has the order but before the push writes the id back, it imports a second copy.
99
100
  - **Placement:** cross-system sync workers live under `worker2/Worker/Sync/`. This worker does **not** belong in `Worker/Netsuite/` (which keeps the NetSuite push, `Worker/Netsuite/SalesOrder.php`) and not in `Worker/Client/`.
100
101
  - **⚠ Renaming this action is a TWO-REPO, single-deploy change.** The method name here and the `runTask(action: 'Sync/ServiceRequest/GenerateSalesOrderFromServiceRequest', …)` **string** in `_underscore/Model/Elite/ServiceRequest.php:44` must land and deploy **together** — the rename from `GenerateSalesOrder` on 2026-08-12 spans both. Ship either alone and the dispatcher targets a name that no longer exists; because `postPost` wraps `runTask` in try/catch + `error_log`, Sales Order generation then stops **silently**. Same exposure for `Sync/SalesOrderStatus/PostSalesOrderStatusToTogadesk1`. See [creating worker actions](./creating-worker-actions.md).
@@ -112,6 +113,7 @@ Recorded so nobody re-debugs them as bugs. All four confirmed against Elite's da
112
113
  - The worker is otherwise complete; these are data/config gaps for the client to close.
113
114
 
114
115
  ## Change history
116
+ - 2026-10-08 — Map gains `shippingMethodId` (replaces hardcoded id 1); bundle lines filtered by `isActive` + SR type, qty from `BundleItems.quantity`; no POs for bundle-only SRs. (rgirish)
115
117
  - 2026-09-30 — Per-client `SALES_ORDER_DEFAULTS_BY_CLIENT` (sets `locationId`, unknown client throws); bundle-item lines when an SR has no units; no-vendor items reported, not failed; Adyen added as second consumer. Not deployed. (rgirish)
116
118
 
117
119
  ## Related
@@ -5,8 +5,8 @@ repo: _underscore
5
5
  project: _Underscore
6
6
  client: adyen
7
7
  type: client-feature
8
- status: draft
9
- updated: 2026-10-01
8
+ status: active
9
+ updated: 2026-10-08
10
10
  owners: ["rgirish"]
11
11
  files:
12
12
  - _underscore/Model/Adyen/ServiceRequest.php
@@ -22,38 +22,46 @@ related:
22
22
  - ../profile.md
23
23
  ---
24
24
 
25
- Adyen's Reclaim SR → SO → NetSuite chain (same as Elite, but items come from a seeded bundle because Adyen sends no serials); open for the prePost bundle attach, the 2026-09-30a setup migration, deploy order, or duplicate risks.
25
+ Adyen's Reclaim SR → SO → NetSuite chain (same as Elite, but items come from a seeded bundle because Adyen sends no serials); open for the prePost bundle attach, the setup migration, the employee-name NetSuite PO #, the Core/client interceptor double-fire, or the backlog SR↔NS order map.
26
26
 
27
27
  ## Summary
28
28
 
29
- Adyen's API (`Apis` id 4) posts Service Requests, all of type **"Reclaim"**, and **cannot send serial numbers**. The chain is the same as Elite's: SR `postPost` → worker2 builds the Sales Order → SO `postPost` → NetSuite push. The only Adyen difference: the SR's items come from a seeded **"Reclaim" bundle**, attached in `prePost`.
29
+ Adyen's API (`Apis` id 4, used by ServiceNow) posts Service Requests, all of type **"Reclaim"**, and **cannot send serial numbers**. Chain = Elite's: SR `postPost` → worker2 builds the Sales Order → SO `postPost` → NetSuite push. Adyen differences: items come from a seeded **"Reclaim" bundle** attached in `prePost`, and the NetSuite PO # is the **employee name**.
30
30
 
31
- **Status (2026-10-01): code + migration written, NOT run or deployed anywhere, not tested end to end.**
31
+ **Status (2026-10-08):** code committed in worker2 + `_underscore`; prod `Client_Adyen.ApiPayloadInterceptors` rows 1, 2, 5 active; prod Core duplicates removed (see Gotchas).
32
32
 
33
33
  ## How it works
34
34
 
35
- 1. `_Model_Adyen_ServiceRequest::prePost` — when type is `Reclaim` and the payload has **no units and no bundles**, adds `serviceRequestBundles: [{bundle:{uuid:'23717330-7672-4590-9e3d-7f4097c18723'}}]` (`RECLAIM_BUNDLE_UUID`).
36
- 2. `_Model_Adyen_ServiceRequest::postPost` — same as Elite's: queues `Sync/ServiceRequest/GenerateSalesOrderFromServiceRequest`.
37
- 3. The worker turns the bundle's items into SO lines (qty 1 each) and sets location "Adyen" from its client map — see [SR → SO generation](../../../2.0/apps/worker2/features/service-request-sales-order-generation.md). No PO: Adyen has no `VendorItems` (job reports `No vendor for: …`, does not fail).
38
- 4. The existing `_Model_Adyen_SalesOrder::postPost` queues `Netsuite/SalesOrder/CreateNetSuite` ([shared push](../../../2.0/apps/worker2/features/netsuite-salesorder-outbound-push.md)).
35
+ 1. `_Model_Adyen_ServiceRequest::prePost` — type `Reclaim` with **no units and no bundles** → adds `serviceRequestBundles: [{bundle:{uuid:'23717330-7672-4590-9e3d-7f4097c18723'}}]` (`RECLAIM_BUNDLE_UUID`).
36
+ 2. `_Model_Adyen_ServiceRequest::postPost` — queues `Sync/ServiceRequest/GenerateSalesOrderFromServiceRequest`.
37
+ 3. Worker turns the bundle items into SO lines; location + ship method come from Adyen's map entry (`Locations` id 1 → NS location 70 "Adyen"; `ShippingMethods` id 5 Ground). No PO for bundle-only SRs — see [SR → SO generation](../../../2.0/apps/worker2/features/service-request-sales-order-generation.md).
38
+ 4. `_Model_Adyen_SalesOrder::postPost` → `queueNetsuiteCreate()` reads the order back (writer-pinned, `throwExceptionsOnError = false`), and **for SR-generated orders sets `otherRefNum` = employee name** from the SO contact, then queues `Netsuite/SalesOrder/CreateNetSuite` ([shared push](../../../2.0/apps/worker2/features/netsuite-salesorder-outbound-push.md)), which prefers `otherRefNum` over ticket/SR number.
39
+ - `buildEmployeeName()`: Adyen often sends the full name in **both** first and last name; if they match (case-insensitive) it uses one copy.
39
40
 
40
- **Why a bundle, not units:** a unit with only an item (no serial) matches an **existing** unit, so every SR would link the same unit — see [nested writes](../../../2.0/apps/api2/features/nested-relationship-writes.md). `ServiceRequests_Bundles.bundleId` is `MATCH` (links only), and Prudential already posts bundles daily with 201.
41
+ **Why a bundle, not units:** a unit with only an item (no serial) matches an **existing** unit, so every SR would link the same unit — see [nested writes](../../../2.0/apps/api2/features/nested-relationship-writes.md). `ServiceRequests_Bundles.bundleId` is `MATCH` (links only).
42
+
43
+ ### Decisions (PM, 2026-10-08)
44
+ - Reclaim bundle = `SVC-FS-OFFBOARD-ADN` (NS 1579821) + **`Misc Laptop`** (NS 1545437), both $0. Keep Misc Laptop — **not** "Apple Macbook 14in" (NS 1625438).
45
+ - NetSuite PO # (`otherrefnum`) = employee name, matching the hand-entered offboarding orders so ops can search NetSuite by name.
41
46
 
42
47
  ### Setup migration — `dbchanges2/Client_Adyen/2026-09-30a - ServiceRequestSalesOrderSetup.sql`
43
- - `ApiPayloadInterceptors`: record 35 (`service-requests`) PRE+POST / POST, scoped to Adyen API; record 14 (`sales-orders`) POST/POST and POST/PUT, `apiId` NULL (the worker writes as a different API identity).
44
- - Items `SVC-FS-OFFBOARD-ADN` (NS 1579821) + `Misc Laptop` (NS 1545437, SERIALIZED); "Reclaim" bundle + 2 `BundleItems`.
45
- - Location "Adyen" (NS location 70); ShippingCarrier FedEx + ShippingMethod Ground (NS 318627).
46
- - `Customers.c_netsuiteInternalCustomerId = 31054`.
48
+ - `ApiPayloadInterceptors`: record 35 (`service-requests`) PRE+POST / POST, `apiId` 4 only; record 14 (`sales-orders`) POST/POST and POST/PUT, `apiId` NULL (the worker writes as a different API identity).
49
+ - Items, "Reclaim" bundle + 2 `BundleItems`, Location "Adyen" (NS 70), FedEx Ground (NS 318627), `Customers.c_netsuiteInternalCustomerId = 31054`.
50
+
51
+ These copy Adyen's hand-made NetSuite reclaim orders (e.g. 290879, 288260, 279871).
47
52
 
48
- These values copy Adyen's hand-made NetSuite reclaim orders (e.g. 290879, 288260, 279871): always `SVC-FS-OFFBOARD-ADN` + one device item, location 70, ship method 318627.
53
+ ### Backlog — 8 Reclaim requests ↔ NetSuite SOs (2026-10-08)
54
+ NS SOs: 290879, 292873, 292882, 292874, 292875, 292876, 288261, 292872. **290879 and 288261 were hand-entered and are NOT linked to their SRs in Toga** (`SalesOrders.serviceRequestId` NULL).
49
55
 
50
56
  ## Gotchas
51
57
 
52
- - **⚠ Deploy order: worker2 → `_underscore` → SQL.** Running the SQL first registers a PRE/POST interceptor whose `prePost` method does not exist yet, so api2 fails **every** Adyen SR POST.
53
- - **Before 2026-09-30a, `Client_Adyen` had 0 `ApiPayloadInterceptors` rows** — the Adyen model hooks committed 2026-08-24 never fired. "Nothing happened" on Adyen is a data check first.
54
- - **Open — Adyen resending the same SR creates a duplicate.** `c_externalReferenceId` is not unique (SR100024 already duplicates SR100021 — see [profile](../profile.md)).
55
- - **Open — NetSuite PO # (`otherrefnum`) will be the SR number**, not the employee name the hand-made orders carry. The profile's SR↔NetSuite matching reads that field.
56
- - **Open — shipping method fallback** and the inbound-sync duplicate race are shared worker issues — see [SR → SO generation](../../../2.0/apps/worker2/features/service-request-sales-order-generation.md#gotchas).
58
+ - **⚠ Never re-run the worker for an SR whose NS order was hand-entered.** The worker's only duplicate guard is `SalesOrders.serviceRequestId`; hand-made orders have it NULL, so a re-run creates a second order (and a second NetSuite push). Link the SR first.
59
+ - **⚠ Adyen hooks ran TWICE until 2026-10-08.** Prod `Core.ApiPayloadInterceptors` ids 15, 16, 17 (`clientId 48`, `minDepth 1`, records 35 + 14) duplicated `Client_Adyen` rows 1, 2, 5 — double worker jobs, and would double-queue NetSuite pushes. Their `minDepth 1` vs the worker's `depth -1` also raised warning **WO-1**, which trips the `internalApiRequest` `"[]"` throw (see [interceptors](../../../2.0/apps/api2/features/api-payload-interceptors.md)). Deactivated; the client rows still fire. Never re-activate them.
60
+ - **⚠ Deploy order: worker2 → `_underscore` → SQL.** SQL first registers a PRE row whose `prePost` does not exist yet → every Adyen SR POST fails.
61
+ - **Before 2026-09-30a, `Client_Adyen` had 0 client `ApiPayloadInterceptors` rows** — "nothing happened" on Adyen is a data check first.
62
+ - **Open — Adyen resending the same SR creates a duplicate.** `c_externalReferenceId` is not unique (SR100024 duplicates SR100021 — see [profile](../profile.md)).
63
+ - **Open — inbound-sync duplicate race** is a shared worker issue — see [SR → SO generation](../../../2.0/apps/worker2/features/service-request-sales-order-generation.md#gotchas).
57
64
 
58
65
  ## Change history
59
- - 2026-09-30 — Built: Reclaim bundle attach in `prePost`, Elite-style `postPost`, setup migration 2026-09-30a. Not deployed. (rgirish)
66
+ - 2026-10-08 — NetSuite PO # = employee name (`buildEmployeeName`); Adyen map entry (location id 1, ship method 5); prod Core interceptor duplicates 15–17 deactivated; Misc Laptop kept (PM). (rgirish)
67
+ - 2026-09-30 — Built: Reclaim bundle attach in `prePost`, Elite-style `postPost`, setup migration 2026-09-30a. (rgirish)
@@ -12,7 +12,7 @@ project: Worker
12
12
  client: adyen
13
13
  type: profile
14
14
  status: draft
15
- updated: 2026-10-01
15
+ updated: 2026-10-08
16
16
  owners: ["rgirish"]
17
17
  files:
18
18
  - worker/crons/toga2/netsuite/sync_togasupply_adyen.php
@@ -47,7 +47,7 @@ Adyen live TOGa Supply client (`Client_Adyen`) on the 1.0 NetSuite→TOGa Supply
47
47
  - **NetSuite → TOGa Supply sync.** Per-client thin wrapper over the shared engine — full mechanics on [per-client sync](../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md).
48
48
  - **Transfer orders ENABLED** (`IS_ENABLED_INTEGRATION_TRANSFER_ORDERS = true`). Adyen's NetSuite transfer orders arrive **as NetSuite sales orders**, classified by `TRANSFER_ORDER_DETECTION_MODE__ZERO_DOLLAR_HOLD` — `holdInvoice === true` **AND** `total === 0`, same rule as GroWrk (see [GroWrk transfer order flow](../growrk/features/transfer-order-flow.md)). Mode is now declared explicitly in Adyen's launcher; `App_Api_Toga2::isTransferOrder()` throws if it is missing, so **`library` and `worker` must deploy together** for Adyen.
49
49
  - **IDs:** `Core.Clients` id **48**; NetSuite customer **31054** ("4950 Adyen"); Adyen API = `Apis` id 4.
50
- - **Reclaim SR → Sales Order → NetSuite** (built 2026-09-30, not deployed): same chain as Elite, items from a seeded "Reclaim" bundle because Adyen sends no serials — see [Adyen reclaim flow](./features/service-request-salesorder-netsuite.md).
50
+ - **Reclaim SR → Sales Order → NetSuite**: same chain as Elite, items from a seeded "Reclaim" bundle (Adyen sends no serials); NetSuite PO # = employee name — see [Adyen reclaim flow](./features/service-request-salesorder-netsuite.md) (also holds the 8-order backlog map).
51
51
  - **`Client_Adyen`** is one of the client DBs with surface-layer / ACL drift noted on the [ACL permission chain](../../2.0/apps/_underscore/features/acl-permission-chain.md) and [surface-layer schema](../../2.0/apps/dbchanges2/features/surface-layer-schema.md) docs.
52
52
 
53
53
  ## Gotchas
@@ -84,10 +84,8 @@ Adyen tests the tracking API on **prod**. `2026-09-22a - AdyenApiTestSeed.sql` s
84
84
  **Known data gaps (2026-09-28):**
85
85
 
86
86
  1. SR100024 duplicates SR100021 (same Adyen ref 48748808) — a lookup by that ref returns **2 SRs**.
87
- 2. SR100026 → NS SO 290879, not fulfilled yet.
88
- 3. SR100027–30 have no NS SO.
89
- 4. NS SO 288261 has no SR in Toga.
90
- 5. SR100000/01/02/25 are Adyen test SRs with no NS order.
87
+ 2. Items 2–4 of the 2026-09-28 list (SR100026, SR100027–30, NS SO 288261) are superseded by the **2026-10-08 backlog map** — 8 Reclaim SRs ↔ NS SOs, 290879 + 288261 hand-entered and not linked in Toga. See [reclaim flow → Backlog](./features/service-request-salesorder-netsuite.md#backlog--8-reclaim-requests--netsuite-sos-2026-10-08).
88
+ 3. SR100000/01/02/25 are Adyen test SRs with no NS order.
91
89
 
92
90
  ### Open defect — `Client_Adyen.Items` missing `isFulfillable` (prod AND beta)
93
91
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.971",
3
+ "version": "1.0.972",
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",