toga-ai 1.0.213 → 1.0.215

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 @@
6
6
  | [ACL Permission Chain (Record & Field Authorization)](features/acl-permission-chain.md) | Authorization in the 2.0 API is **metadata-driven**: whether a role may Create/Read/Update/Delete a record is decided by rows across **four linked tables**, not | api2/Component/Api/V2/V2.php, _underscore/Model/Core/Page.php, dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql, dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql |
7
7
  | [Carrier Shipping Labels (UPS/FedEx) & NetSuite Item Fulfillment](features/carrier-shipping-labels.md) | Backend mechanics behind TOGa Supply's Fulfill & Ship: buying a carrier label (UPS/FedEx), persisting it, and creating the NetSuite Item Fulfillment with tracki | _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Client/ItemFulfillments/TrackingNumber.php, _underscore/Component/Library/LabelPdf/LabelPdf.php, _underscore/Component/Library/Carriers/Ups/Ups.php, _underscore/Component/Library/Carriers/Fedex/Fedex.php, _underscore/Trait/Netsuite/ItemFulfillment.php, _underscore/Trait/Netsuite/SalesOrder.php, _underscore/Component/Library/NetSuite/NetSuite.php, _underscore/Model/Client/TrackingNumber.php, _underscore/Model/Client/ShippingMethod.php, _underscore/Model.php, _underscore/Cloud.php |
8
8
  | [Client Email Template Sending](features/email-template-sending.md) | `_Model_Client_EmailTemplate` sends a stored, client-defined email template by UUID. | _underscore/Model/Client/EmailTemplate.php, _underscore/Model/Client/EmailTemplateOutgoingEmailAddress.php, _underscore/Email.php |
9
- | [Forecast.Sales NetSuite import engine (real-time webhook)](features/forecast-sale-import.md) | Real-time importer that takes a NetSuite **sale** record and writes its lines into `Forecast.Sales` (the Forecast2 revenue table). | _underscore/Component/Forecast/SaleImport/SaleImport.php, _underscore/Component/Forecast/Db/Db.php, worker2/Worker/Netsuite/Invoice.php, worker2/Worker/Netsuite/CashSale.php, worker2/Worker/Netsuite/CreditMemo.php, worker2/Worker/Netsuite/CashRefund.php, worker2/Worker/Netsuite/Opportunity.php, worker2/Worker/Netsuite/SalesOrder.php, test/@dave/test_invoice_lifecycle.php, test/@dave/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js, test/@dave/NetSuite/api-message-queue/dev_ue_api_msg_queue_enqueue.js |
9
+ | [Forecast.Sales NetSuite import engine (real-time webhook)](features/forecast-sale-import.md) | Real-time importer that takes a NetSuite **sale** record and writes its lines into `Forecast.Sales` (the Forecast2 revenue table). | _underscore/Component/Forecast/SaleImport/SaleImport.php, _underscore/Component/Forecast/Db/Db.php, worker2/Worker/Netsuite/Invoice.php, worker2/Worker/Netsuite/CashSale.php, worker2/Worker/Netsuite/CreditMemo.php, worker2/Worker/Netsuite/CashRefund.php, worker2/Worker/Netsuite/JournalEntry.php, worker2/Worker/Netsuite/Opportunity.php, worker2/Worker/Netsuite/SalesOrder.php, dbchanges2/Forecast/2026-06-26a - Add journalEntry to Sales transaction type enum.sql, test/@dave/test_invoice_lifecycle.php, test/@dave/test_je_lifecycle.php, test/@dave/test_creditmemo_lifecycle.php, test/@dave/test_cashsale_lifecycle.php, test/@dave/test_cashrefund_lifecycle.php, test/@dave/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js, test/@dave/NetSuite/api-message-queue/dev_ue_api_msg_queue_enqueue.js |
10
10
  | [_Model magic-field access (__get without __isset)](features/model-magic-field-access.md) | `_Model` exposes DB columns as "magic" properties via `__get()`, but it defines **no** `__isset()`. | _underscore/Model/Core/Model.php |
11
11
  | [NetSuite REST Client (_Component_Api_Netsuite) — record writes & SuiteQL](features/netsuite-rest-client.md) | `_Component_Api_Netsuite` is the **2.0 `_underscore` NetSuite REST client** — the shared primitive every worker2/api2 NetSuite caller uses for record GETs, Suit | _underscore/Component/Api/Netsuite/Netsuite.php |
12
12
  | [Per-Client Database Connections & the Local Logs Trap](features/per-client-database-connections.md) | When `_underscore` serves a request for a client it opens **three distinct per-client database connections**, not one. | _underscore/Database.php, _underscore/ApiRequest.php, _underscore/Model/Client/Logs/Api.php |
@@ -15,9 +15,15 @@ files:
15
15
  - worker2/Worker/Netsuite/CashSale.php
16
16
  - worker2/Worker/Netsuite/CreditMemo.php
17
17
  - worker2/Worker/Netsuite/CashRefund.php
18
+ - worker2/Worker/Netsuite/JournalEntry.php
18
19
  - worker2/Worker/Netsuite/Opportunity.php
19
20
  - worker2/Worker/Netsuite/SalesOrder.php
21
+ - dbchanges2/Forecast/2026-06-26a - Add journalEntry to Sales transaction type enum.sql
20
22
  - test/@dave/test_invoice_lifecycle.php
23
+ - test/@dave/test_je_lifecycle.php
24
+ - test/@dave/test_creditmemo_lifecycle.php
25
+ - test/@dave/test_cashsale_lifecycle.php
26
+ - test/@dave/test_cashrefund_lifecycle.php
21
27
  - test/@dave/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js
22
28
  - test/@dave/NetSuite/api-message-queue/dev_ue_api_msg_queue_enqueue.js
23
29
  related:
@@ -30,13 +36,14 @@ related:
30
36
  ## Summary
31
37
  Real-time importer that takes a NetSuite **sale** record and writes its lines into
32
38
  `Forecast.Sales` (the Forecast2 revenue table). Covers the four NetSuite sale record
33
- types: **invoice, cashSale, creditMemo, cashRefund**. It is the webhook-driven replacement
34
- for the SALES section of the legacy 5-minute pull cron
39
+ types: **invoice, cashSale, creditMemo, cashRefund**, plus **journalEntry** (GL
40
+ revenue/cost adjustments posted directly as journal entries — see *JournalEntry import*).
41
+ It is the webhook-driven replacement for the SALES section of the legacy 5-minute pull cron
35
42
  (`worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php`), and mirrors the
36
43
  already-shipped SalesOrder (open-orders) and Opportunity webhook handlers.
37
44
 
38
- The shared engine is `_Component_Forecast_SaleImport`; four thin worker2 handlers
39
- (`_Worker_Netsuite_{Invoice,CashSale,CreditMemo,CashRefund}`) just delegate to it.
45
+ The shared engine is `_Component_Forecast_SaleImport`; thin worker2 handlers
46
+ (`_Worker_Netsuite_{Invoice,CashSale,CreditMemo,CashRefund,JournalEntry}`) just delegate to it.
40
47
 
41
48
  **Critical, non-obvious facts (read before touching this):**
42
49
  - **Sign convention differs from the cron** — the raw REST record is all-positive, so the
@@ -50,7 +57,9 @@ The shared engine is `_Component_Forecast_SaleImport`; four thin worker2 handler
50
57
  |---|---|
51
58
  | `_underscore/Component/Forecast/SaleImport/SaleImport.php` | the engine (shared body for all four types) |
52
59
  | `_underscore/Component/Forecast/Db/Db.php` | shared NS-REST + sublist-pagination helpers |
53
- | `worker2/Worker/Netsuite/Invoice.php` etc. | four thin handlers; `post`/`put` → `sync`, `delete` → `removeAll` |
60
+ | `worker2/Worker/Netsuite/Invoice.php` etc. | four sale-type thin handlers; `post`/`put` → `sync`, `delete` → `removeAll` |
61
+ | `worker2/Worker/Netsuite/JournalEntry.php` | thin JE handler; `post`/`put` → `syncJournalEntry`, `delete` → `removeAllJournalEntry` |
62
+ | `test/@dave/test_je_lifecycle.php` | JE lifecycle harness (direct-engine create/update/delete) |
54
63
 
55
64
  Each handler is `abstract class _Worker_Netsuite_<Type> implements
56
65
  _Interface_Static_Webhook_Netsuite`. The router (`_Worker_NetSuite::Webhook`) PascalCases
@@ -65,7 +74,9 @@ Per-record flow in `sync`:
65
74
  1. REST GET the record (`expandSubResources=true`) via `_Component_Forecast_Db::fetchRecord`.
66
75
  2. **Per-type status gate** → if excluded, call `removeAll` and stop. Exclusions: invoice
67
76
  `Voided`/`Rejected`; cashSale `Unapproved Payment`; creditMemo `Voided`; cashRefund has
68
- no gate.
77
+ no gate. **A cashSale created via REST lands in status `Deposited`** (NOT the excluded
78
+ `Unapproved Payment`), so REST-created cash sales pass the gate and import — relevant when
79
+ building cashSale test fixtures.
69
80
  3. If `shippingCost != 0`, append a **synthetic SHIPPING line at lineNumber 0** using
70
81
  NetSuite item internalId **13500** (a real `Forecast.Items` row).
71
82
  4. Per line: resolve the item (with self-heal — see below); skip `itemGroup` items
@@ -117,9 +128,56 @@ uses) and then writes the line — instead of the cron's silent skip. Failure se
117
128
  the job for retry.
118
129
  - `itemGroup` items carry no line revenue and are skipped.
119
130
 
131
+ ## JournalEntry import (the fifth transaction type)
132
+ JournalEntry extends the same engine to capture **GL revenue/cost adjustments posted
133
+ directly as journal entries**. It is **structurally different from the four sale types** — a
134
+ JE has **no item lines**; each line is a raw GL posting (account, debit/credit, entity,
135
+ location, memo). Handler `_Worker_Netsuite_JournalEntry`: `post`/`put` → `syncJournalEntry`,
136
+ `delete` → `removeAllJournalEntry`. The router PascalCases `journalEntry` → this class, so
137
+ **no router change** is needed (same as the sale types). The engine's
138
+ `syncJournalEntry`/`buildJournalEntryRows`/`importOneJournalEntry`/etc. reuse the existing
139
+ item-line upsert/reconcile machinery (`syncLines`/`guardedInsert`/`deleteRows`).
140
+
141
+ **Mapping model (durable design):**
142
+ - A JE line carries **no native item and no salesRep** (confirmed against the live account:
143
+ 0 JEs carry an item on a revenue/cost line). The fields the business rule needs **do not
144
+ exist in NetSuite yet** — they are stubbed behind constants `JE_LINE_ITEM_FIELD` /
145
+ `JE_LINE_SALESREP_FIELD` (currently **null**), so until the fields are added every kept
146
+ line groups under `(null, null)` into **one aggregate row**.
147
+ - Lines hitting a **revenue** or **cost** GL account are grouped by **(salesRep, item)**:
148
+ `revenue = Σ(credit − debit)` over revenue lines, `cost = Σ(debit − credit)` over cost
149
+ lines, `profit = revenue − cost`.
150
+ - **Account classification is done on the fly by NetSuite `accttype`** — SuiteQL
151
+ `SELECT id, accttype FROM account WHERE id IN (...)`, cached per run, bucketed via a tunable
152
+ `JE_ACCTTYPE_BUCKETS` map. It is **NOT** a hardcoded account-id list. PROVISIONAL default:
153
+ `Income → revenue`, `COGS → cost` (pending sales-team sign-off; open questions: whether
154
+ `OthIncome`/`Expense` count, and the Income-typed but "Cost"-named accounts 430/433).
155
+ - Group key uses `json_encode([salesRep, item])` (not a string-join) to stay collision-safe
156
+ once the fields go live — an empty-string separator would let `"5|" + null` collide.
157
+
158
+ **Reversal handling (a JE-only pattern):**
159
+ - Setting a reversal date makes NetSuite auto-create a **paired reversing JE**, but **only
160
+ one webhook fires** (for the original). The engine resolves the reversal from the original
161
+ via SuiteQL `transaction.reversal` (the reversal's id) and imports it too, keyed by its
162
+ **own** internalId, so each posting period nets correctly (the reversal carries opposite
163
+ debit/credit → the natural negation). Helper: `resolveJournalEntryReversal`.
164
+ - The reversal's REST `createdFrom` = the original entry, so its `Forecast.Sales` rows carry
165
+ `createdFromNetsuiteTransactionInternalId = original`. A **delete on the original therefore
166
+ cascades to the reversal rows locally** via `deleteRowsCreatedFrom` with **no NetSuite
167
+ call** — important because the record may already be gone on a delete event.
168
+ - The partner-reversal import is wrapped so a reversal **deleted in NetSuite before the
169
+ webhook fires** (`fetchRecord` 404) does **not** abort the original entry's
170
+ already-committed import (it is logged + skipped).
171
+
172
+ **Additive + dormant.** The enum add (`'journalEntry'` in `Sales.netsuiteTransactionType`)
173
+ is backward-compatible; with the prod JE enqueuer deployment **off**, no JE webhook fires so
174
+ the handler never runs. Safe to merge and deploy the NS enqueuer later. The
175
+ `'journalentry':'journalEntry'` entry was added to both enqueuer `RECORD_TYPE_MAP`s.
176
+
120
177
  ## Data model — Forecast.Sales schema facts
121
178
  Prod `Forecast.Sales` writable columns: `netsuiteTransactionType`
122
- (enum `'invoice','cashSale','creditMemo','cashRefund'`), `netsuiteTransactionInternalId`,
179
+ (enum `'invoice','cashSale','creditMemo','cashRefund','journalEntry'` — `journalEntry`
180
+ added by `dbchanges2/Forecast/2026-06-26a`), `netsuiteTransactionInternalId`,
123
181
  `tranDate`, `tranNumber`, `customerId`, `leadSource`, `salesRepEmployeeId`, `itemId`,
124
182
  `lineNumber`, `revenue`/`profit` `decimal(14,2)`, `createdFromNetsuiteTransactionInternalId`.
125
183
  - **No `amountDue` and no `dtPendingBilling` column** in prod (no staged dbchanges2 adds
@@ -142,8 +200,19 @@ events therefore require this one script deployed on **Invoice, Cash Sale, Credi
142
200
  Cash Refund**. The drainer (`customscript_ss_amq_drain`) is record-type-agnostic and needs
143
201
  no per-type deployment.
144
202
 
203
+ **To make a UE enqueuer fire for a REST/M2M-driven record, the deployment's AUDIENCE must
204
+ include that integration user/role.** The **"Execute As Role"** field does NOT control
205
+ triggering — it only sets the run-as role; changing it has no effect on whether the webhook
206
+ fires. Adjusting the audience is what made create/edit/delete events fire for REST-created
207
+ test records. Symptom of a misconfigured audience (or a missing per-type deployment): **no
208
+ AMQ queue row is created at all** — SuiteQL the AMQ custom record shows 0, nothing at ngrok,
209
+ nothing at prod `Logs.Webhook`.
210
+
145
211
  The enqueuer's `RECORD_TYPE_MAP` (lowercase NS `record.type` → camelCase `recordType` the
146
- worker2 router PascalCases) already maps the four sale types 1:1. Note: NetSuite emits item
212
+ worker2 router PascalCases) maps the four sale types 1:1, plus
213
+ `'journalentry':'journalEntry'` (added this session to both the prod and dev enqueuers); a
214
+ JE webhook therefore also requires this script deployed on the **Journal Entry** record
215
+ type. Note: NetSuite emits item
147
216
  events as specific **subtypes** (`inventoryItem`, `nonInventoryResaleItem`, `kitItem`,
148
217
  `itemGroup`, …) with no generic `item` type — collapsing those subtype VALUES to `'item'`
149
218
  would be needed only for a future real-time item webhook, **not** for this Sales importer
@@ -166,6 +235,11 @@ Actions: `check | create | get | update | delete | sync | recent | amq | deploym
166
235
  [REST client doc](./netsuite-rest-client.md)); the framework was **not** edited this session.
167
236
  - `amq` action SuiteQLs the AMQ custom record; `deployments` SuiteQLs `scriptdeployment`+`script`
168
237
  to show which enqueuer fires per record type (used to find the dual-deployment trap below).
238
+ - **Per-type siblings** `test_creditmemo_lifecycle.php`, `test_cashsale_lifecycle.php`,
239
+ `test_cashrefund_lifecycle.php` (and `test_je_lifecycle.php`) mirror this harness, each with its
240
+ own `create()` POSTing via `_ApiRequest` with the `~`-delimited Location regex workaround.
241
+ **cashRefund and creditMemo require a header-level location** on create. The create webhook can
242
+ lag **~20s** before the row appears (read-after-write/processing) — wait before asserting.
169
243
 
170
244
  ## Gotchas / known issues
171
245
  - **Testing locally pollutes PROD unless you UNDEPLOY the prod AMQ enqueuer first.** Each sale
@@ -182,6 +256,16 @@ Actions: `check | create | get | update | delete | sync | recent | amq | deploym
182
256
  for the test id). The **dev** enqueuer's `debugWrap` envelope
183
257
  `{action,parameters:{payload,headers}}` is required because a local worker2 has no
184
258
  `WebhookIngestion` Lambda to wrap the raw body.
259
+ - **The legacy 5-min pull cron can leak a live test record into PROD `Forecast.Sales`
260
+ regardless of whether the new webhook code is deployed.** Because all testing creates real
261
+ (tiny, ±$0.01) transactions in the *single* prod NetSuite account, if a test record is
262
+ still alive when `worker/crons/toga2/forecast2/import_sales.php` runs (every 5 min), the
263
+ cron pulls it into prod `Forecast.Sales` — this is independent of the webhook (prod has no
264
+ Sales handler deployed). Observed repeatedly across test batches; each leaked row was an
265
+ exact test match (customerId 2 / item 25 / revenue ±0.01) and was removed from the prod
266
+ writer with a guarded `DELETE`. **Mitigations:** keep the create→delete window short (most
267
+ records slip between cron runs), or pause `import_sales` during a batch; the daily
268
+ discrepancy-fix cleans any straggler anyway since the record is deleted in NetSuite.
185
269
  - **No local app logs for debug-path webhooks — verify arrival via the ngrok inspector, and decode
186
270
  the body.** The legacy `{action,parameters}` debug path writes **no** `WorkerJobs`/`Logs.Webhook`
187
271
  locally. Confirm inbound arrival at the ngrok request inspector
@@ -203,6 +287,31 @@ Actions: `check | create | get | update | delete | sync | recent | amq | deploym
203
287
  - The cron's sign handling is not portable here — see Sign convention.
204
288
 
205
289
  ## Change history
290
+ - 2026-06-26 — **Hardened + verified the full webhook path for all four sale types end-to-end**
291
+ (create→insert, edit→update, delete→removeAll) with sign conventions reconfirmed (invoice/cashSale
292
+ +, creditMemo/cashRefund −). Recorded durable gotchas: a UE enqueuer fires for a REST/M2M user only
293
+ when the deployment's **AUDIENCE** includes that user — **"Execute As Role" does NOT control
294
+ triggering** (no-AMQ-row is the symptom); a **REST-created cashSale lands in status `Deposited`**, not
295
+ the excluded `Unapproved Payment`, so it imports; the create webhook can lag **~20s**. Sharpened the
296
+ prod-leak note: the legacy 5-min `import_sales.php` cron pulls a live test record into prod
297
+ `Forecast.Sales` independent of webhook deployment (exact ±$0.01 test matches deleted from the prod
298
+ writer; mitigate by short create→delete windows / pausing the cron). Added per-type harnesses
299
+ `test_{creditmemo,cashsale,cashrefund}_lifecycle.php`. JE php-reviewer hardening applied
300
+ (partner-reversal fetch isolated in try/catch, null-safe SuiteQL `items[0]`). (dfranks)
301
+ - 2026-06-26 — **Extended the importer to a fifth transaction type, `journalEntry`** (GL
302
+ revenue/cost adjustments). New thin handler `_Worker_Netsuite_JournalEntry`
303
+ (post/put→`syncJournalEntry`, delete→`removeAllJournalEntry`); engine gained
304
+ `TYPE_JOURNAL_ENTRY` + JE methods reusing the existing line upsert/reconcile machinery;
305
+ `'journalEntry'` added to the `Sales.netsuiteTransactionType` enum (dbchanges2 + local
306
+ ALTER) and `'journalentry'` to both enqueuer maps. Durable design recorded: JEs have **no
307
+ item lines** (lines are GL postings), grouped by (salesRep, item) with item/salesRep
308
+ **stubbed null** until the NetSuite fields exist; **on-the-fly accttype classification**
309
+ (`JE_ACCTTYPE_BUCKETS`, provisional Income→revenue / COGS→cost); and the **reversal
310
+ pattern** (resolve via `transaction.reversal`, import keyed by its own id, delete cascades
311
+ through `createdFrom` locally, 404-on-reversal tolerated). Verified e2e via the direct
312
+ engine against prod NetSuite with prod isolated (JE enqueuer undeployed → 0 prod rows);
313
+ built harness `test/@dave/test_je_lifecycle.php`. Additive + dormant until the NS enqueuer
314
+ deploys. (dfranks)
206
315
  - 2026-06-26 — **Verified the full create→update→delete webhook lifecycle end-to-end on a local box**
207
316
  (ngrok tunnel → local worker2 → local `Forecast.Sales`), driving a real **$0** NetSuite invoice
208
317
  through create→insert, edit→update (tracked-column change-detection), delete→`removeAll`; confirmed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.213",
3
+ "version": "1.0.215",
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",