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
|
|
34
|
-
|
|
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`;
|
|
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'`
|
|
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)
|
|
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