toga-ai 1.0.329 → 1.0.331

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.
@@ -5,7 +5,7 @@
5
5
  | [Worker (1.0 Framework) Architecture](architecture.md) | `worker` is the legacy (**1.0** `App_` framework) **background-job tier**. | worker/index.php, worker/_/app/framework.php, worker/crons/, worker/schedules/, worker/ebs/cron.worker.php, worker/.ebextensions/035_cron.worker.config |
6
6
  | [Compass MA Sales Order Exception Report](features/compass-ma-sales-order-exception-report.md) | A worker cron that emails operations the "Compass Refresh Exception Report" — Compass `MA%` sales orders whose corresponding Office Depot (ODP) sales order has | worker/crons/toga2/compass/workflow/7_generate_ma_sales_order_exception_report.php |
7
7
  | [Compass Partial In-Transit & Delivered Emails (per package)](features/compass-partial-in-transit-delivered-emails.md) | Compass USA and Compass Canada send a **per-package** in-transit email (and a matching delivered email) instead of one email listing the whole order. | worker/crons/toga2/compass/update_salesorder_status_from_odp.php, worker/crons/toga2/compasscanada/workflow/3_update_salesorder_status_from_grand_and_toy.php |
8
- | [Forecast2 ↔ NetSuite Reconciliation & Trueup Tooling](features/forecast2-netsuite-reconciliation.md) | CLI tools to **audit** and **repair** drift between the production `Forecast` DB (core2) and NetSuite. | test/@dave/checker.php, _underscore/Component/Forecast/SaleImport/SaleImport.php, test/@dave/looper.php, test/@dave/reconcile_netsuite_totals.php, test/@dave/fixer.php, test/@dave/analyze_netsuite_forecast_diff.php, test/@dave/trueup_sales.php, test/@dave/trueup_open_orders.php, test/@dave/loop_trueup_open_orders.php, test/@dave/trueup_opportunities.php, test/@dave/probe_sales_gap_direct.php, test/@dave/probe_missing_oo_timing.php, test/@dave/probe_missing_oo_createdby.php, test/@dave/probe_drift_so_dates.php, test/@dave/probe_profit_invoices.php, test/@dave/probe_profit_gap.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php, worker/crons/toga2/forecast2/periodic_forecast_discrepancy_fix_open_orders.php, worker/crons/toga2/forecast2/import_open_orders.php, worker/schedules/cron.worker.infrastructure.json |
8
+ | [Forecast2 ↔ NetSuite Reconciliation & Trueup Tooling](features/forecast2-netsuite-reconciliation.md) | CLI tools to **audit** and **repair** drift between the production `Forecast` DB (core2) and NetSuite. | test/@dave/checker.php, worker2/Component/Forecast/SaleImport/SaleImport.php, test/@dave/looper.php, test/@dave/reconcile_netsuite_totals.php, test/@dave/fixer.php, test/@dave/analyze_netsuite_forecast_diff.php, test/@dave/trueup_sales.php, test/@dave/trueup_open_orders.php, test/@dave/loop_trueup_open_orders.php, test/@dave/trueup_opportunities.php, test/@dave/probe_sales_gap_direct.php, test/@dave/probe_missing_oo_timing.php, test/@dave/probe_missing_oo_createdby.php, test/@dave/probe_drift_so_dates.php, test/@dave/probe_profit_invoices.php, test/@dave/probe_profit_gap.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php, worker/crons/toga2/forecast2/periodic_forecast_discrepancy_fix_open_orders.php, worker/crons/toga2/forecast2/import_open_orders.php, worker/schedules/cron.worker.infrastructure.json |
9
9
  | [NetSuite → TOGa Supply Per-Client Sync (thin wrappers)](features/netsuite-togasupply-per-client-sync.md) | Syncs NetSuite transactions (sales orders, purchase orders, invoices, item receipts, item fulfillments, inventory adjustments) into each TOGa Supply (2.0) clien | worker/crons/toga2/netsuite/common_sync_togasupply.php, worker/crons/toga2/netsuite/sync_togasupply_canon.php, worker/schedules/cron.worker.sync.json, dbchanges2/_modules/netsuite/2026-04-01 - Parameters.sql, library/app/api/netsuite/rest.php, library/app/systemmonitor/netsuiteintegration.php |
10
10
  | [OneUptime external uptime monitoring for 1.0 workers](features/oneuptime-worker-uptime-monitoring.md) | Every 1.0 worker box self-reports its liveness to an external OneUptime monitor once per minute by curl-POSTing to a per-worker "Incoming Request" heartbeat URL | library/app/worker.php, worker/crons/worker/worker_heartbeat.php |
11
11
  | [Prudential: Send Shipments for the Day report (daily cron)](features/send-shipments-for-the-day.md) | Daily cron (9:00 PM) that emails Prudential and Dell stakeholders an Excel report of all devices shipped that day, including tracking number, serial number, emp | worker/crons/notifications/reports/send_shipments_for_the_day.php |
@@ -6,11 +6,11 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-06-30
9
+ updated: 2026-07-14
10
10
  owners: [dfranks]
11
11
  files:
12
12
  - test/@dave/checker.php
13
- - _underscore/Component/Forecast/SaleImport/SaleImport.php
13
+ - worker2/Component/Forecast/SaleImport/SaleImport.php
14
14
  - test/@dave/looper.php
15
15
  - test/@dave/reconcile_netsuite_totals.php
16
16
  - test/@dave/fixer.php
@@ -223,8 +223,8 @@ by reconciling a chosen tranDate range directly against NetSuite.
223
223
  `41100/41300/41500`, cost `51100/51200` (exact-number, never prefix — see the sale-import doc for the
224
224
  sub-account traps). `revenue = Σ(−foreignamount)` over revenue-acct lines; `profit = Σ(−foreignamount)`
225
225
  over **all** allowlisted lines (cost lines net profit down via debit-positive `foreignamount`). Group by
226
- **(local salesRep, item)** → one Sales row per group; synthetic `lineNumber` from
227
- `ksort(json_encode([salesRepId,itemId]))` then `1..N` — **fixer MUST key identically** or its upsert
226
+ **(local salesRep, item, customer)** → one Sales row per group; synthetic `lineNumber` from
227
+ `ksort(json_encode([salesRepId,itemId,customerId]))` then `1..N` — **fixer MUST key identically** or its upsert
228
228
  dupes against the webhook importer's rows. JE `foreignamount`s are **cent-precision**, so a per-JE
229
229
  sum-then-round equals the importer's per-salesRep round-then-sum **exactly** — this is what keeps
230
230
  checker's "in sync" == fixer's "nothing to fix" for JEs (the compute-identically invariant applied to JEs).
@@ -249,6 +249,35 @@ by reconciling a chosen tranDate range directly against NetSuite.
249
249
  correctly re-derives "no raw rep" from NetSuite. One-time cleanup =
250
250
  `fixer.php --commit --prod --category sales` over the JE window, then `checker.php` to confirm
251
251
  (in current prod this was a no-op — 0 no-rep JE rows — so the importer filter is preventative).
252
+ - **`fixer.php` now resolves `customerId` on JE lines, mirroring the importer (TRUE-80129,
253
+ dfranks 2026-07-14).** Both `fixJournalEntries()` (the hand-maintained MIRROR of
254
+ `buildJournalEntryRows` — same account allowlist, same group key, same `ksort` ordering,
255
+ which must stay in lockstep or its upsert dupes against the webhook importer's rows) used to
256
+ hard-code `customerId = NULL` in its UPDATE and INSERT. The fix:
257
+ - Added a `Customers` lookup (reusing the exact pattern `fixOpportunities()` already uses in
258
+ the same file).
259
+ - Added **`tl.entity AS customer`** to the bulk-line SuiteQL SELECT. `tl.entity` is a
260
+ **NATIVE** column, so it needs **no** `> 0` / `IS NOT NULL` perf-trap workaround (unlike the
261
+ `custcol_*` salesRep/item columns — see the custom-column planner gotcha below); the comment
262
+ in-file notes native columns carry no such predicate risk.
263
+ - Extended the group key to `(salesRep, item, customer)` (`json_encode([$salesRepId, $itemId,
264
+ $customerId])`) to key **identically** to the importer.
265
+ - Added `customerId` to the **read-back SELECT + change-detection compare** (a deliberate guard
266
+ against the known "new column dropped from change-detection" footgun class), plus the UPDATE
267
+ and INSERT.
268
+ - New constant `JE_CUSTOMER_LINE_FIELD = 'entity'`. Non-Customer entities (vendor/employee
269
+ refs) resolve to null naturally — NetSuite internal ids are one global sequence across entity
270
+ subtypes, so a non-customer id simply misses `Customers`.
271
+ - **Backfilling `customerId` onto existing JE rows is a MANUAL, all-time developer step — not
272
+ part of the code PR (TRUE-80129 decision).** Because the checker (`journalEntryNsTotals()`)
273
+ only detects **revenue/profit** drift per JE, a JE row with a correct total but a missing
274
+ `customerId` is **not** flagged discrepant, so a normal checker/fixer pass will never
275
+ repopulate it — shipping the code fix alone does **not** backfill history. The chosen backfill
276
+ (run once, manually, by the developer after deploy): **DELETE all**
277
+ `netsuiteTransactionType='journalEntry'` rows from `Sales` (**no date-window restriction**),
278
+ then run **`fixer.php --commit --prod --category sales`** over full JE history so
279
+ `fixJournalEntries()` takes the INSERT branch for every group and repopulates every JE row
280
+ including `customerId`. Explicitly **out of scope for the code PR**.
252
281
  - **JE item dimension is NULL today; here is how to enable it later.** `itemId` is null on JE Sales rows
253
282
  because the JE import path reads item **only** via the configurable `JE_LINE_ITEM_FIELD` (currently null)
254
283
  — it does **not** read native `transactionline.item`, so even a populated native `tl.item` would NOT flow
@@ -256,7 +285,8 @@ by reconciling a chosen tranDate range directly against NetSuite.
256
285
  `custcol_*` if a custom column is added) — `journalLineLookup` then resolves it via
257
286
  `lookupId('Items','netsuiteItemInternalId',…)` and the group key already carries the item slot; AND (2)
258
287
  update **`fixer.php`** to SELECT the item column in its JE SuiteQL, resolve NS item→local id (reuse
259
- `createMissingForecastItemFromNetSuite` self-heal), and extend the group key to `(salesRep, item)`.
288
+ `createMissingForecastItemFromNetSuite` self-heal), and keep the group key `(salesRep, item, customer)`
289
+ (customer was added under TRUE-80129; the item slot is already present).
260
290
  **`checker.php` needs no change.** Consider whether item should self-heal (salesRep does **not** — see
261
291
  the sale-import doc). The salesRep dimension itself is the JE line custom column `custcol_sales_rep_line`
262
292
  (wired under TRUE-79862).
@@ -519,6 +549,20 @@ None — Forecast2 is a single shared dataset.
519
549
 
520
550
  ## Change history
521
551
 
552
+ - 2026-07-14 — **`fixer.php` now resolves `customerId` on JE lines, mirroring the importer +
553
+ recorded the manual all-time backfill** (TRUE-80129, dfranks). `fixJournalEntries()` (the
554
+ hand-maintained mirror of `buildJournalEntryRows`) previously hard-coded `customerId = NULL`;
555
+ it now adds a `Customers` lookup (same pattern as `fixOpportunities()`), selects
556
+ **`tl.entity AS customer`** (a **native** column → no `> 0`/`IS NOT NULL` perf-trap workaround
557
+ needed), extends the group key to `(salesRep, item, customer)` — keying identically to the
558
+ importer or its upsert dupes — and adds `customerId` to the read-back SELECT + change-detection
559
+ compare (guarding the "column dropped from change-detection" footgun) + UPDATE + INSERT. New
560
+ const `JE_CUSTOMER_LINE_FIELD = 'entity'`. Also documented that the **backfill of existing JE
561
+ rows is a manual, all-time developer step** (DELETE all `journalEntry` Sales rows, then
562
+ `fixer.php --commit --prod --category sales` over full JE history) — the checker only flags
563
+ revenue/profit drift, so a missing-only `customerId` is never auto-detected. Corrected the
564
+ misattributed `files:` path: `_Component_Forecast_SaleImport` lives in **worker2**, not
565
+ `_underscore`. (dfranks)
522
566
  - 2026-07-07 — **Recorded that these reconcilers are the only backstop for NetSuite sublist
523
567
  line-field inline edits + added `Logs.Webhook` forensics** (SALES profit-drift forensics; invoice
524
568
  6715127, 2026-02-20, $3,392.70). A line-level inline edit (e.g. `TRANLINE.MCOSTESTIMATE`) fires no
@@ -14,7 +14,7 @@
14
14
  | [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 |
15
15
  | [Error Reporting — Issue/Event Aggregation (agreed POST-to-receiver design)](features/error-reporting-issue-event.md) | Platform-wide error-reporting infrastructure for TOGA 2.0, built around a two-table **Issue / Event** aggregation model in the shared **Core Logs DB**. | _underscore/Error.php, _underscore/Model/Core/Logs/Issue.php, _underscore/Model/Core/Logs/Event.php, dbchanges2/Logs/2026-07-06 - Issue and Event tables.sql |
16
16
  | [Record-Changed Event Publishing (_Event::publish to SQS)](features/event-publish-sqs.md) | `_Event::publish()` (in `_underscore/Event.php`) is the PHP side of the real-time event pipeline. | _underscore/Event.php |
17
- | [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, _underscore/Component/Api/Netsuite/Netsuite.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/test_fetchrecord_routes.php, test/@dave/verify_je_classification.php, test/@dave/probe_je_accounts.php, test/@dave/probe_je_shape.php, test/@dave/fixer.php, test/@dave/Junk Drawer/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js, test/@dave/Junk Drawer/NetSuite/api-message-queue/dev_ue_api_msg_queue_enqueue.js |
17
+ | [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). | worker2/Component/Forecast/SaleImport/SaleImport.php, worker2/Component/Forecast/Db/Db.php, _underscore/Component/Api/Netsuite/Netsuite.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/test_fetchrecord_routes.php, test/@dave/verify_je_classification.php, test/@dave/probe_je_accounts.php, test/@dave/probe_je_shape.php, test/@dave/fixer.php, test/@dave/Junk Drawer/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js, test/@dave/Junk Drawer/NetSuite/api-message-queue/dev_ue_api_msg_queue_enqueue.js |
18
18
  | [Item-Fulfillment Stage Lifecycle (picked/packed/shipped) & Order Status](features/item-fulfillment-stage-lifecycle-and-order-status.md) | Every ItemFulfillment (IF) now carries an explicit **stage** — picked → packed → shipped — resolved through `ItemFulfillmentStages → ItemFulfillmentStatuses` (m | _underscore/Model/Client/SalesOrder.php, _underscore/Model/Quad/SalesOrder.php, _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/SalesOrderStatus.php, _underscore/Model/Client/SalesOrderItem.php, _underscore/Model/Client/Item.php, _underscore/Model/Client/PurchaseOrderItem.php, library/app/api/toga2.php, dbchanges2/Client/2026-06-30a - BackfillNullStageItemFulfillmentsToShipped.sql, dbchanges2/Client/2026-06-30b - SalesOrderStatusesPickedPacked.sql, dbchanges2/Client/2026-06-30c - ItemFulfillmentStageIdNotNull.sql, dbchanges2/Client_CompassCanada/2026-06-30a - ItemFulfillmentLifecycleAndShippedBackfill.sql |
19
19
  | [_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 |
20
20
  | [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 |
@@ -6,11 +6,11 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-07
9
+ updated: 2026-07-14
10
10
  owners: [dfranks]
11
11
  files:
12
- - _underscore/Component/Forecast/SaleImport/SaleImport.php
13
- - _underscore/Component/Forecast/Db/Db.php
12
+ - worker2/Component/Forecast/SaleImport/SaleImport.php
13
+ - worker2/Component/Forecast/Db/Db.php
14
14
  - _underscore/Component/Api/Netsuite/Netsuite.php
15
15
  - worker2/Worker/Netsuite/Invoice.php
16
16
  - worker2/Worker/Netsuite/CashSale.php
@@ -57,14 +57,20 @@ The shared engine is `_Component_Forecast_SaleImport`; thin worker2 handlers
57
57
  - **Sign convention differs from the cron** — the raw REST record is all-positive, so the
58
58
  webhook applies the sign factor **uniformly to every line**, not just shipping. Copying
59
59
  the cron's sign handling reintroduces the credit-memo sign bug. (See Sign convention.)
60
- - The engine is a `_Component_*` class and therefore **must live in `_underscore`**, not in
61
- worker2 — the autoloader rejects a `_Component_*` loaded from a project path. (See Gotchas.)
60
+ - **The engine is checked into the `worker2` repo, NOT `_underscore`** despite its
61
+ `_Component_*` name. `_Component_Forecast_SaleImport` and `_Component_Forecast_Db` both
62
+ live under `worker2/Component/Forecast/…` and declare `namespace worker;` (the project-local
63
+ `_Component_*` requirement). The class-name convention alone does **not** imply an
64
+ `_underscore` home — planning/context tools repeatedly assert `_underscore` and are **wrong**;
65
+ a `git` checkout of `_underscore` has **no** `Component/Forecast/SaleImport/SaleImport.php`.
66
+ Only `_Component_Api_Netsuite` (the REST client, `fetchRecord`/`createRecord`) is genuinely in
67
+ `_underscore`. (See Gotchas.)
62
68
 
63
69
  ## Key files / entry points
64
70
  | File | Role |
65
71
  |---|---|
66
- | `_underscore/Component/Forecast/SaleImport/SaleImport.php` | the engine (shared body for all four types) |
67
- | `_underscore/Component/Forecast/Db/Db.php` | shared NS-REST + sublist-pagination helpers |
72
+ | `worker2/Component/Forecast/SaleImport/SaleImport.php` | the engine (shared body for all four types); checked into **worker2**, declares `namespace worker;` |
73
+ | `worker2/Component/Forecast/Db/Db.php` | shared NS-REST + sublist-pagination helpers (also **worker2**, `namespace worker;`) |
68
74
  | `worker2/Worker/Netsuite/Invoice.php` etc. | four sale-type thin handlers; `post`/`put` → `sync`, `delete` → `removeAll` |
69
75
  | `worker2/Worker/Netsuite/JournalEntry.php` | thin JE handler; `post`/`put` → `syncJournalEntry`, `delete` → `removeAllJournalEntry` |
70
76
  | `test/@dave/test_je_lifecycle.php` | JE lifecycle harness (direct-engine create/update/delete) |
@@ -237,8 +243,34 @@ item-line upsert/reconcile machinery (`syncLines`/`guardedInsert`/`deleteRows`).
237
243
  `51200→249` COGS Service. Read-only resolver tool: `test/@dave/probe_je_accounts.php`.
238
244
  - **Likely future one-line tweak:** if the Agent sub-accounts should count, add `41300.01` to
239
245
  the revenue list and `51200.01` to the cost list.
240
- - Group key uses `json_encode([salesRep, item])` (not a string-join) to stay collision-safe
241
- once the fields go live — an empty-string separator would let `"5|" + null` collide.
246
+ - **Customer is now resolved on JE lines and is a THIRD grouping dimension (TRUE-80129,
247
+ dfranks 2026-07-14).** `buildJournalEntryRows` previously hard-coded `customerId = null` on
248
+ every JE row. It now reads `JE_LINE_CUSTOMER_FIELD = 'entity'` — a **NATIVE JE-line reference
249
+ field**, unlike the `custcol_*` salesRep/item fields — via the same `journalLineLookup()`
250
+ bare-lookup helper, resolving against `Customers.netsuiteInternalId` (the identical
251
+ table/column the four sale-transaction types already use for `customerId`). `customerId` is
252
+ folded into the group key as a third dimension (see below), so JE lines sharing rep/item but
253
+ differing by customer now split into **separate** `Forecast.Sales` rows.
254
+ - **Non-Customer entities resolve to null with no explicit type check needed** — a JE line's
255
+ `entity` may be a vendor or employee ref, but NetSuite **internal ids are a single global
256
+ sequence across all entity subtypes** (customer/vendor/employee never share an id), so a
257
+ vendor/employee id simply misses `Customers.netsuiteInternalId` and yields null. No
258
+ `is-customer` guard required. (Reusable NetSuite fact: internal ids are globally unique
259
+ across record subtypes, not namespaced per subtype.)
260
+ - Group key uses `json_encode([salesRep, item, customer])` (not a string-join) to stay
261
+ collision-safe — an empty-string separator would let `"5|" + null` collide. (The customer
262
+ slot was added under TRUE-80129; the fixer mirror MUST key identically — see the
263
+ reconciliation doc.)
264
+
265
+ **Backfilling `customerId` onto existing JE rows requires a manual, all-time re-import — the
266
+ code fix alone does NOT backfill (TRUE-80129 decision).** The checker/fixer only detect
267
+ **revenue/profit** drift per JE, so an already-correct-total JE row that is merely **missing
268
+ `customerId`** is **not** flagged discrepant and a normal fixer pass will never repopulate it.
269
+ The chosen backfill (run **manually, once, by the developer after deploy — explicitly out of
270
+ scope for the code PR**): `DELETE` **all** `netsuiteTransactionType='journalEntry'` rows from
271
+ `Sales` (no date-window restriction), then run `fixer.php --commit --prod --category sales`
272
+ over full JE history so `fixJournalEntries()` takes the INSERT branch for every group and
273
+ repopulates all JE rows including `customerId`. (See the reconciliation doc for the fixer side.)
242
274
 
243
275
  **Reversal handling (a JE-only pattern):**
244
276
  - Setting a reversal date makes NetSuite auto-create a **paired reversing JE**, but **only
@@ -524,21 +556,40 @@ success from a `Forecast.Sales` row alone.
524
556
  webhook or the daily discrepancy-fix.
525
557
  - **Local _underscore boots on PHP 8.0.30 (xampp8)** — the floor enforced in `_underscore.php` is
526
558
  **8.0.0**, not 8.1.
527
- - **`_Component_*` classes must live under the `_underscore` framework root, never a project
528
- repo.** The autoloader rejects a `_Component_*`/`_Model_*` loaded from a project path
529
- (e.g. `./` under worker2) with "namespace … has not been defined" — at **class-load
530
- (runtime), not `php -l`**. So this engine lives in `_underscore` next to
531
- `_Component_Forecast_Db` even though only worker2 uses it; it references the worker2 class
532
- `_Worker_Netsuite_Item`, resolved lazily at runtime in the worker2 context. **A
533
- legitimately project-local `_Component_*`/`_Model_*` (e.g. the worker2 copies of these
534
- classes) must still declare `namespace worker;` or it throws the same error at autoload —
535
- and this exact global-namespace mistake caused the SALES drift (webhooks fatally threw
536
- before writing, presenting as 7-month historical drift). See the
559
+ - **This engine is a project-local `_Component_*` that lives in `worker2`, NOT `_underscore`
560
+ correcting the earlier assumption.** `_Component_Forecast_SaleImport` and
561
+ `_Component_Forecast_Db` are checked into **`worker2/Component/Forecast/…`** and declare
562
+ `namespace worker;`. A `_Component_*`/`_Model_*` loaded from a project path is legal **only
563
+ when it declares its project namespace** omit it and the autoloader throws "namespace …
564
+ has not been defined" at **class-load (runtime), not `php -l`** (this exact global-namespace
565
+ mistake caused the SALES drift: webhooks fatally threw before writing, presenting as 7-month
566
+ historical drift). So the naming convention does **not** put the file in `_underscore`; the
567
+ namespace declaration is what makes the worker2 location valid. It references the worker2
568
+ class `_Worker_Netsuite_Item`, resolved lazily at runtime. Only `_Component_Api_Netsuite`
569
+ (the REST client) is actually in `_underscore`. **Planning/context tooling has repeatedly
570
+ mis-asserted an `_underscore` home for this engine — verify against a `git` checkout, not the
571
+ class name.** See the
537
572
  [namespace-registration doc](./component-model-namespace-registration.md) for the
538
- eval-bridge mechanism and the `catch`/`throw`/`extends` backslash-qualify traps.**
573
+ eval-bridge mechanism and the `catch`/`throw`/`extends` backslash-qualify traps.
539
574
  - The cron's sign handling is not portable here — see Sign convention.
540
575
 
541
576
  ## Change history
577
+ - 2026-07-14 — **JE lines now resolve `customerId` (was hard-coded null) + corrected the
578
+ engine's repo location to worker2** (TRUE-80129, dfranks). `buildJournalEntryRows` reads
579
+ `JE_LINE_CUSTOMER_FIELD = 'entity'` — a **native** JE-line reference field (not a `custcol_*`,
580
+ unlike salesRep/item) — via `journalLineLookup()` against `Customers.netsuiteInternalId` (same
581
+ table/column the four sale types use), and folds `customerId` into the group key as a third
582
+ dimension: `json_encode([salesRep, item, customer])`. Non-Customer entities (vendor/employee
583
+ refs) resolve to null with no type check because **NetSuite internal ids are one global
584
+ sequence across all entity subtypes**. Recorded that the backfill of existing JE rows is a
585
+ **manual, all-time** developer step (DELETE all `journalEntry` Sales rows, then
586
+ `fixer.php --commit --prod --category sales` over full JE history) — the code fix alone does
587
+ NOT backfill because the checker only flags revenue/profit drift, not a missing customerId.
588
+ **Also corrected a long-standing misattribution:** `_Component_Forecast_SaleImport` and
589
+ `_Component_Forecast_Db` are checked into the **worker2** repo (`namespace worker;`), NOT
590
+ `_underscore` — the `_Component_*` name does not imply an `_underscore` home; a `git` checkout
591
+ of `_underscore` has no such file. Only `_Component_Api_Netsuite` is actually in `_underscore`.
592
+ (dfranks)
542
593
  - 2026-07-08 — **Added the webhook-vs-fixer diagnostic playbook** for proving whether inbound
543
594
  NetSuite webhooks actually wrote `Forecast.Sales`: correlate `Logs.Webhook` (prod-logs;
544
595
  receipt-only, NO status column) against `Core.WorkerJobs` (prod-core; `isSuccess`/
@@ -6,7 +6,7 @@ project: "AI-BDR"
6
6
  client: shared
7
7
  type: feature
8
8
  status: draft
9
- updated: 2026-07-13
9
+ updated: 2026-07-14
10
10
  owners: [tcox]
11
11
  files:
12
12
  - bdr/PLAN.md
@@ -16,8 +16,15 @@ files:
16
16
  related:
17
17
  - architecture.md
18
18
  - web-funnel-content-model.md
19
+ - web-funnel-app.md
19
20
  ---
20
21
 
22
+ > **Execution status (2026-07-14):** PLAN **Phases 0–5 are BUILT and QA'd** — the web
23
+ > app engine, full mockup port, and real backend are done. Remaining work is
24
+ > decision-gated, not code (§9.10 HubSpot mechanism, leadership copy, Phase 7 polish,
25
+ > Phase 8 deploy). The built codebase is documented in `web-funnel-app.md`; this doc
26
+ > stays a plan mirror. This body still mirrors `BDR/PLAN.md`.
27
+
21
28
  # BDR: Multi-Campaign "AI BDR / Agent Studio" Web App — Implementation Plan
22
29
 
23
30
  > **Status:** Draft 2 (needs one more review). Open items tracked in §9; final campaign direction /
@@ -194,7 +201,7 @@ the bundle carries the UUID so a slug maps to the right campaign for logging.
194
201
  ## 4. Decisions locked at kickoff
195
202
  | Decision | Choice | Consequence |
196
203
  |---|---|---|
197
- | Repo name | **`bdr`** | New greenfield repo. Not yet in the TOGA registry. |
204
+ | Repo name | **`bdr`** | Greenfield repo, registered in the TOGA registry (`agilantsolutions/BDR`). |
198
205
  | Framework | **Next.js (App Router) + TypeScript** | Chosen because the site is highly public: SSR + SEO, and a built-in server for the secret-bearing HubSpot/Toga/content calls. Matches `info`, so its patterns port directly. |
199
206
  | UI source | **The mockup** | Rebuild its Agent Studio flow faithfully (pixel-perfect). The mockup is React, so components port cleanly into Next.js client components. |
200
207
  | Backend source | **`info`** | Reuse HubSpot/Toga/callback logic directly (same framework). |
@@ -551,5 +558,11 @@ so fidelity should be near-exact.
551
558
  - [ ] Pixel-perfect parity pass against the mockup screens + screenshots.
552
559
 
553
560
  ## Change history
561
+ - 2026-07-14 — Phases 0–5 executed and QA'd (engine + full mockup port + real backend);
562
+ built codebase now documented in `web-funnel-app.md`. `BDR/PLAN.md` itself corrected:
563
+ absolute local paths genericized, and the stale "not yet in TOGA registry" note fixed
564
+ (bdr IS registered). Repo now lives at `agilantsolutions/BDR` (branch `BDR-Development`);
565
+ the local `.claude/` harness is gitignored (devs pull it via `npx toga-ai`). Body still
566
+ mirrors PLAN.md. (tcox)
554
567
  - 2026-07-13 — Draft 2 resync: re-mirrored the body from the current `BDR/PLAN.md`. Framework locked to Next.js App Router (earlier React/Vite SPA idea dropped); content store decided HubSpot over Contentful (rationale in §4/§5.2); no-hardcoding (HubSpot-owned, fetched by slug from the start); same-product framing confirmed (front door of the existing AI-BDR backend, "backend relationship" open question resolved); meeting-specific dates/cadence removed. Still a draft. (tcox)
555
568
  - 2026-07-08 — Captured the full BDR web-funnel implementation plan verbatim into the knowledge repo as a companion to the distilled `web-funnel-content-model.md` feature doc (redundancy intentional). (tcox)
@@ -0,0 +1,143 @@
1
+ ---
2
+ title: Web Funnel — Built Next.js App (structure, stack, how to run)
3
+ framework: "2.0"
4
+ repo: ai-bdr
5
+ project: AI-BDR
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-07-14
10
+ owners: [tcox]
11
+ files:
12
+ - bdr/PLAN.md
13
+ - bdr/.gitignore
14
+ - bdr/eslint.config.mjs
15
+ - bdr/amplify.yml
16
+ - bdr/src/app
17
+ - bdr/src/lib/resolveCampaignId.ts
18
+ - bdr/src/content
19
+ - bdr/src/components
20
+ - bdr/src/flow
21
+ - bdr/src/server
22
+ - bdr/src/app/api
23
+ - bdr/test
24
+ - bdr/public
25
+ - bdr/mockup/styles.css
26
+ related:
27
+ - web-funnel-content-model.md
28
+ - bdr-web-funnel-plan.md
29
+ - ../architecture.md
30
+ ---
31
+
32
+ ## What this is
33
+
34
+ The **built** state of the BDR web funnel — the Next.js UI / lead-capture front
35
+ door of the existing AI-BDR product (see `../architecture.md`). The forward-looking
36
+ plan lives in `bdr-web-funnel-plan.md`; the config-driven content contract lives in
37
+ `web-funnel-content-model.md`. **This doc covers the actual shipped codebase: the
38
+ stack, the directory map, what was ported vs. parked, the test suite, and the
39
+ decisions that are load-bearing for anyone touching the repo.**
40
+
41
+ As of this writing **PLAN Phases 0–5 are BUILT and QA'd** — the web app engine and
42
+ full mockup port are complete, all screens/modals render config-driven from a
43
+ bundle, and the real backend is wired behind adapters. Developer QA verdict: the UI
44
+ is "very close, minimal adjustments to be near exact" vs. the mockup. Remaining work
45
+ is **decision-gated, not code** (see "What is parked / not built").
46
+
47
+ - **Repo:** `agilantsolutions/BDR` on GitHub, working branch `BDR-Development`. The
48
+ machine-local `.claude/` harness is **gitignored** — committing it would snapshot
49
+ the whole KB stale; devs get the harness via `npx toga-ai`, not from this repo.
50
+
51
+ ## Stack
52
+
53
+ - **Next.js 16.2.3, App Router + TypeScript**, mirroring the `info` repo's config.
54
+ - **No Tailwind (deliberate).** Tailwind 4's preflight reset would fight the mockup's
55
+ hand-authored CSS (ported verbatim as `src/app/studio.css`) and threaten pixel
56
+ parity. Do **not** add Tailwind to this repo — the styling is intentionally the
57
+ mockup's own CSS-variable system. See the styles.css escape-sequence gotcha below.
58
+ - **ESLint flat config** carries a **no-em-dash rule** on shipped strings
59
+ (`Literal` / `TemplateElement` / `JSXText` selectors) — enforces the mockup copy
60
+ rule on in-repo strings (`eslint.config.mjs`).
61
+ - **Vitest** test suite: **67 tests across 7 files, all green.** Every phase was
62
+ verified with `tsc` + lint + build + a live SSR `curl` before commit.
63
+ - **Deploy target:** AWS Amplify (SSR), `amplify.yml` present (Phase 8 not done).
64
+
65
+ ## Directory map (what got built)
66
+
67
+ - `src/app/*` — App Router shell + pages; `page.tsx` is a server component that
68
+ resolves the campaign and primes the lead server-side (from `?hsContactId`) and
69
+ **never fails rendering**. `src/app/studio.css` is the mockup CSS ported verbatim.
70
+ - `src/lib/resolveCampaignId.ts` — campaign entry resolution: `?campaign=<slug>` →
71
+ `?hsCampaignId=` mapped → `DEFAULT`. Slug regex `^[a-z0-9][a-z0-9-]{0,63}$`.
72
+ - `src/content/*` — the content layer (schema/provider/default/hook). See
73
+ `web-funnel-content-model.md` for the contract; the schema is now THE contract.
74
+ - `src/components/*` — full mockup component library port: `Ico` (27-icon registry
75
+ hydrating bundle icon keys), `SwapFade`, `TalkingPreview`, `VoiceDial`, etc., with
76
+ the mockup's motion hardening ported **verbatim** (do not "simplify" the settle
77
+ guards / effect-owned timers away).
78
+ - `src/flow/*` — `useAgentFlow` state machine (the `go()` / `back` map, modal gates)
79
+ plus the screens. Persists to `localStorage` (`aibdr.v2`) where **only voice
80
+ survives a refresh**; the flow restarts at Welcome.
81
+ - `src/server/*` — the real backend, ported from `info`'s `toga.ts` / `hubspot.ts`
82
+ behind `LeadSink` + `CallbackService` adapters (detail in
83
+ `web-funnel-content-model.md`).
84
+ - `src/app/api/*` — route handlers `call-now`, `call-later`, `contact`.
85
+ - `public/*` — assets pruned to the **default campaign only** (~6.2 MB).
86
+ - `test/*` — the 67-test Vitest suite.
87
+
88
+ ## How the built app behaves (key runtime facts)
89
+
90
+ - **Agent identity is campaign config.** AutoBuild lands on the campaign's
91
+ `bundle.agent`, **not** a hardcoded "Alex" — the presented agent (art, accent,
92
+ voice, name) comes from the resolved bundle. The manual Creator is parked, so the
93
+ user does not pick the agent in the default flow.
94
+ - **Template tokens** `<agent>` / `<date>` / `<tz>` / `<time>` / `<detail>` in bundle
95
+ strings are substituted at render by an `agentize()` helper.
96
+ - **Funnel always renders.** `resolveCampaign` never throws — an unknown slug or a
97
+ provider failure returns the `DEFAULT` bundle. The server page primes the lead but
98
+ never fails rendering on a bad/absent contact.
99
+ - **Boundary validation on every route handler.** 16 KB body cap; phone / UUID / ISO
100
+ checks; all handlers return the `{isSuccess, status, error, messages, data}`
101
+ envelope (same contract as `info`/Toga).
102
+
103
+ ## What is parked / not built
104
+
105
+ - **Creator screen + `TweaksPanel` — deliberately NOT ported** (parked per plan
106
+ §9.3 / §9.7).
107
+ - **`hubspotProvider` is a throwing stub**, gated on the §9.10 HubSpot content-
108
+ modeling decision. Until HubSpot is seeded, `DEFAULT_BUNDLE` carries the mockup's
109
+ copy verbatim as documented **interim dev content**; it slims to a bare
110
+ engineering fallback once HubSpot is seeded.
111
+ - **`info`'s dead 501 `visit` route was dropped** on purpose (attack surface, no
112
+ caller).
113
+ - **Remaining work is decision-gated, not code:** §9.10 HubSpot content mechanism
114
+ (HubDB likely — gates the provider + campaign seeding, Phase 6); leadership's
115
+ first-campaign copy; Phase 7 pixel/GA4/accessibility polish; Phase 8 Amplify SSR +
116
+ `togatech` integration.
117
+
118
+ ## Gotchas
119
+
120
+ - **The mockup's `mockup/styles.css` contains literal `\n` and `\"` escape sequences**
121
+ (JS-string generation artifacts, around lines ~759 / ~840) that break real CSS
122
+ parsers. This was **fixed in the ported `src/app/studio.css`**, but
123
+ `mockup/styles.css` was intentionally left pristine — so **anyone re-porting the
124
+ mockup CSS must re-apply this fix.**
125
+ - **No PII in logs.** All of `info`'s PII-logging was stripped on port (identifier-
126
+ only logs); the Toga error paths **strip query strings** because the contact
127
+ lookup carries the email in the query. Do not reintroduce value logging.
128
+ - **No Tailwind** — see Stack. The mockup CSS is the styling system; adding Tailwind
129
+ risks pixel parity.
130
+ - **`.claude/` is gitignored** in the BDR repo on purpose — do not commit the
131
+ harness; pull it with `npx toga-ai`.
132
+
133
+ ## Change history
134
+ - 2026-07-14 — Initial doc: recorded the BUILT state of the BDR web funnel (PLAN
135
+ Phases 0–5 executed + QA'd). Next.js 16.2.3 App Router/TS, deliberate no-Tailwind,
136
+ ESLint no-em-dash rule, `resolveCampaignId`, full mockup component/flow port
137
+ (`useAgentFlow`, Ico registry, motion hardening verbatim), agent identity as
138
+ campaign config, real backend behind LeadSink/CallbackService with PII logging
139
+ stripped and boundary-validated route handlers, 67 green Vitest tests. Recorded the
140
+ parked items (Creator/TweaksPanel, throwing hubspotProvider, dropped `visit` route)
141
+ and the `mockup/styles.css` escape-sequence re-port gotcha. (tcox)
142
+ </content>
143
+ </invoke>
@@ -6,22 +6,31 @@ project: AI-BDR
6
6
  client: shared
7
7
  type: feature
8
8
  status: draft
9
- updated: 2026-07-13
9
+ updated: 2026-07-14
10
10
  owners: [tcox]
11
11
  files:
12
12
  - bdr/src/content/schema.ts
13
13
  - bdr/src/content/hubspotProvider.ts
14
14
  - bdr/src/content/useCampaign.ts
15
15
  - bdr/src/content/default.ts
16
+ - bdr/src/lib/resolveCampaignId.ts
16
17
  - bdr/src/server/leadSink.ts
17
18
  - bdr/src/server/callbackService.ts
18
19
  - bdr/src/app/api/call-now/route.ts
19
20
  - bdr/src/app/api/call-later/route.ts
21
+ - bdr/src/app/api/contact/route.ts
20
22
  related:
21
23
  - ../architecture.md
22
24
  - call-orchestration.md
25
+ - web-funnel-app.md
23
26
  ---
24
27
 
28
+ > **Status note:** the content **contract** below (schema, provider seam, adapters)
29
+ > is now BUILT and QA'd (PLAN Phases 0–5). The built codebase — structure, stack,
30
+ > how to run — is documented in `web-funnel-app.md`. The one part still **not** built
31
+ > is the HubSpot content *sourcing* (`hubspotProvider` is a throwing stub gated on the
32
+ > §9.10 mechanism decision), which is why this doc stays `draft`.
33
+
25
34
  ## What this is
26
35
 
27
36
  The **public-facing web funnel** is the new UI / lead-capture front door of the **same
@@ -100,6 +109,24 @@ optional `build` (AutoBuild beats), `analytics`, and `features[]` capability fla
100
109
  `RichLine` supports the accent-inked span the mockup uses (e.g. `"<agent> is ready to
101
110
  work."`). The mapping layer is the seam between HubSpot storage and this contract.
102
111
 
112
+ **Schema calibrated by building (the §6 plan sketch was a sketch; `src/content/schema.ts`
113
+ is now THE contract).** Additions made while building the real screens: `chrome`
114
+ (wordmark / rail labels); capabilities `eyebrows` + qbank strings + `howLink`;
115
+ `connectNote` split to `{label, value}`; `earlyAccess` / `phone` / `recap` groups;
116
+ `build` made **required**. Bundle template tokens `<agent>` / `<date>` / `<tz>` /
117
+ `<time>` / `<detail>` are substituted **at render** by an `agentize()` helper (they are
118
+ plain strings in the bundle, so HubSpot only ever stores strings).
119
+
120
+ **`resolveCampaign` never throws** (built): an unknown slug or a provider failure
121
+ returns the `DEFAULT` bundle, so the funnel always renders. Entry resolution lives in
122
+ `src/lib/resolveCampaignId.ts` (`?campaign=<slug>` → mapped `?hsCampaignId=` →
123
+ `DEFAULT`; slug regex `^[a-z0-9][a-z0-9-]{0,63}$`).
124
+
125
+ **`hubspotProvider` is a throwing stub** explicitly gated on the §9.10 HubSpot-modeling
126
+ decision — it is not wired yet. Until HubSpot is seeded, **`DEFAULT_BUNDLE` carries the
127
+ mockup's copy verbatim as documented interim dev content**; once HubSpot is seeded it
128
+ slims to a bare engineering safety-net fallback (not authored marketing copy).
129
+
103
130
  ## Backend reuse (from the `info` repo; existing AI-BDR calls)
104
131
 
105
132
  The funnel's Call Now / Schedule screens submit to a real backend **ported from the
@@ -118,6 +145,16 @@ so only the impl changes later:
118
145
  - `CallbackService.requestCall(ref, whenOrNow, phone)` → Toga `requestCall`, immediate vs.
119
146
  scheduled mapped to `CONTACT_CALL_TYPE_UUID`.
120
147
 
148
+ **Built (PLAN Phase 5):** `info`'s `toga.ts` / `hubspot.ts` are ported into `src/server/`
149
+ behind those adapters with **all of `info`'s PII logging stripped** (identifier-only
150
+ logs; the Toga error paths **strip query strings** because the contact lookup carries
151
+ the email in the query). Route handlers `/api/call-now`, `/api/call-later`, `/api/contact`
152
+ **validate at the boundary** (16 KB body cap; phone / uuid / ISO checks) and always
153
+ return the `{isSuccess, status, error, messages, data}` envelope. The page primes the
154
+ lead server-side from `?hsContactId` and never fails rendering. `info`'s dead 501
155
+ `visit` route was deliberately dropped (attack surface, no caller). Verified by 67 green
156
+ Vitest tests. See `web-funnel-app.md` for the full built structure.
157
+
121
158
  Note on "campaign": `info` uses HubSpot **only to fetch the contact**; the URL's
122
159
  `hsCampaignId` is passed to Toga as the attribution campaign UUID
123
160
  (`addContactToCampaign`) — attribution-only in `info`. **Fetching marketing content by
@@ -158,6 +195,16 @@ right campaign for logging.
158
195
  `var(--accent)` (+ derived `--accent-fill`); never hardcode an accent value.
159
196
 
160
197
  ## Change history
198
+ - 2026-07-14 — Content contract BUILT + QA'd (PLAN Phases 0–5): schema calibrated by
199
+ building the real screens (`src/content/schema.ts` is now THE contract — chrome,
200
+ capabilities eyebrows/qbank/howLink, `connectNote` {label,value}, earlyAccess/phone/
201
+ recap groups, `build` required, `agentize()` token substitution); `resolveCampaign`
202
+ never throws (unknown slug/provider failure → DEFAULT); `hubspotProvider` is a throwing
203
+ stub gated on §9.10; `DEFAULT_BUNDLE` carries the mockup copy as interim dev content.
204
+ Backend ported from `info` behind LeadSink/CallbackService with PII logging stripped,
205
+ boundary-validated route handlers, dead `visit` route dropped; 67 green Vitest tests.
206
+ Built structure split out to `web-funnel-app.md`. Kept `draft` because HubSpot content
207
+ sourcing (§9.10) is still open. (tcox)
161
208
  - 2026-07-13 — Draft 2 resync to the current plan: framework locked to Next.js App Router
162
209
  (earlier React/Vite SPA idea dropped); recorded the **HubSpot-over-Contentful** content-store
163
210
  decision + rationale (marketing already in HubSpot, campaign model already exists, trivial
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.329",
3
+ "version": "1.0.331",
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",