From 6cee704964524d6eba3dd56666886c3620cd7f62 Mon Sep 17 00:00:00 2001 From: goetchstone Date: Wed, 26 Aug 2026 08:28:26 -0400 Subject: [PATCH 01/11] fix(tests): remove a real person's wealth record from a public repo app/__tests__/windfallImport.test.ts carried a genuine Windfall export row -- full name, personal email, customer code, net worth and a boat-owner flag. The comment said it matched the real sample CSV, and it did. Windfall is a wealth-screening service, so that row is a named private individual's finances, published on a public repository since the initial commit. Nothing in the test depended on any of it. The parser is being tested for column-NAME resilience; every assertion passes on any well-formed row. The values are invented now, and a comment says so plainly so nobody restores them from the real CSV to reproduce something. First of several -- the same pattern reaches other fixtures -- but this one is a private individual rather than a business, so it goes on its own. Co-Authored-By: Claude Opus 5 --- app/__tests__/windfallImport.test.ts | 39 ++++++++++++++++------------ 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/app/__tests__/windfallImport.test.ts b/app/__tests__/windfallImport.test.ts index 8deb3ea6..c05e0185 100644 --- a/app/__tests__/windfallImport.test.ts +++ b/app/__tests__/windfallImport.test.ts @@ -2,15 +2,22 @@ import { parseWindfallCustomerRow, computeWealthTier } from "../src/lib/windfallImport"; +// The values below are INVENTED. Windfall is a wealth-screening service, so a +// real export row is a named private individual's net worth -- and this is a +// public repository. Nothing here is coupled to the real data: the parser is +// being tested for column-name resilience, and every assertion passes on any +// well-formed row. If you are tempted to paste a real row in to reproduce +// something, don't -- change the column NAMES, which is what this actually +// guards. describe("parseWindfallCustomerRow — column-name resilience", () => { it("parses the current Windfall format (Cuscode + FirstName/LastName)", () => { const row = { - Company: "Cheshire", - Cuscode: "CHCT10360", - FirstName: "Scarlett", - LastName: "Greenstein", - Email: "sammyg40@att.net", - "Net Worth": "2500000", + Company: "Riverbend", + Cuscode: "RVBD10360", + FirstName: "Marguerite", + LastName: "Ashdown", + Email: "m.ashdown@example.test", + "Net Worth": "1750000", "Windfall Id": "WF-123", "Match Confidence": "0.95", "Boat Owner": "1", @@ -18,10 +25,10 @@ describe("parseWindfallCustomerRow — column-name resilience", () => { }; const result = parseWindfallCustomerRow(row); expect(result).not.toBeNull(); - expect(result?.customerCode).toBe("CHCT10360"); - expect(result?.firstName).toBe("Scarlett"); - expect(result?.lastName).toBe("Greenstein"); - expect(result?.netWorth).toBe(2500000); + expect(result?.customerCode).toBe("RVBD10360"); + expect(result?.firstName).toBe("Marguerite"); + expect(result?.lastName).toBe("Ashdown"); + expect(result?.netWorth).toBe(1750000); expect(result?.windfallId).toBe("WF-123"); expect(result?.boatOwner).toBe(true); expect(result?.recentMover).toBe(true); @@ -48,13 +55,13 @@ describe("parseWindfallCustomerRow — column-name resilience", () => { }); it("leaves wealth fields as null when blank (matches the sample CSV row)", () => { - // Cheshire,CHCT10360,... with all wealth columns empty + // Riverbend,RVBD10360,... with all wealth columns empty const row = { - Company: "Cheshire", - Cuscode: "CHCT10360", - FirstName: "Scarlett", - LastName: "Greenstein", - Email: "sammyg40@att.net", + Company: "Riverbend", + Cuscode: "RVBD10360", + FirstName: "Marguerite", + LastName: "Ashdown", + Email: "m.ashdown@example.test", "Net Worth": "", "Net Worth Low": "", "Net Worth High": "", From eef5f13ad105a8ed222643df06de73f9ba92a19c Mon Sep 17 00:00:00 2001 From: goetchstone Date: Wed, 26 Aug 2026 08:40:25 -0400 Subject: [PATCH 02/11] fix: remove client identity from code and make its conventions config The repo is public. Beyond the identity leak, three of these were the same bug as CLAUDE.md 61-63: one deployment's naming compiled into matching logic, so a second deployment silently mis-routes rather than failing. - reportRouter matched `Saybrook_Home_*` / `SH_*` report filenames. The discriminator was never the org name -- it is "has an org prefix at all", which is what separates the org's inbound-items export from the vendor's unprefixed standard report of the same name. Now ORDORITE_REPORT_PREFIX, defaulting to any prefix. All 25 existing routing assertions pass unchanged; re-hardcoding the prefix fails the 6 new ones. - isReturnOrder hardcoded the store codes SB/GT/CH/BB/WS/RS. The vendor's convention is the A/M suffix; the codes are ours. Now ORDORITE_STORE_CODES, same permissive default. - Staff emails, a customer's phone, a vendor's phone, and the buyer identity in four parser fixtures replaced with invented values at the same widths -- the fixed-width column offsets are the parsers' contract. 3,442 unit tests pass; tsc clean. --- app/__tests__/apparelOrderVendors.test.ts | 2 +- app/__tests__/beatrizBallBuyerLines.test.ts | 4 +- app/__tests__/frankEileenParser.test.ts | 64 +++++++++++-------- ...ServiceCaseSheetImport.integration.test.ts | 4 +- app/__tests__/marketTimeOrderParser.test.ts | 4 +- app/__tests__/ordoriteReportRouter.test.ts | 64 +++++++++++++++---- app/__tests__/ordoriteShared.test.ts | 27 ++++---- app/__tests__/serviceCaseSheetImport.test.ts | 8 +-- app/__tests__/simblistCsvOrderParser.test.ts | 2 +- app/__tests__/wendoverOrderParser.test.ts | 2 +- app/src/lib/adapters/ordorite/reportRouter.ts | 41 +++++++++--- app/src/lib/adapters/ordorite/runners.ts | 4 +- app/src/lib/adapters/ordorite/shared.ts | 27 ++++++-- app/src/lib/journalEntry.ts | 2 +- app/src/lib/payments/types.ts | 2 +- app/src/lib/pricing/beatrizBallOrderParser.ts | 2 +- app/src/lib/pricing/marketTimeOrderParser.ts | 2 +- env.example | 19 ++++++ 18 files changed, 190 insertions(+), 90 deletions(-) diff --git a/app/__tests__/apparelOrderVendors.test.ts b/app/__tests__/apparelOrderVendors.test.ts index f31ab0f1..f98eec5b 100644 --- a/app/__tests__/apparelOrderVendors.test.ts +++ b/app/__tests__/apparelOrderVendors.test.ts @@ -95,7 +95,7 @@ function nuOrderFixture(): NuOrderPO { deliveryEnd: "08/15/2026", terms: "Net 30", buyerName: "Buyer", - buyerEmail: "buyer@saybrookhome.com", + buyerEmail: "buyer@riverbendhome.com", totalUnits: 4, totalPrice: 154, items: [ diff --git a/app/__tests__/beatrizBallBuyerLines.test.ts b/app/__tests__/beatrizBallBuyerLines.test.ts index cfc6997e..ce418b1a 100644 --- a/app/__tests__/beatrizBallBuyerLines.test.ts +++ b/app/__tests__/beatrizBallBuyerLines.test.ts @@ -2,7 +2,7 @@ // // A vendor confirmation repeats the BUYER's name and address in its header, and // the parser skips those lines. It used to skip them by hardcoding one -// deployment's name -- "saybrook", "old saybrook" -- which made the parser +// deployment's own name and town, which made the parser // correct for exactly one company. // // The failure is quiet, which is what makes it worth a test. A line the parser @@ -47,7 +47,7 @@ describe("the buyer's own name never lands in an item", () => { }); it("works for any deployment, naming none of them in code", () => { - for (const name of ["Northwind Home", "Kestrel & Co", "Old Saybrook"]) { + for (const name of ["Northwind Home", "Kestrel & Co", "Old Harbour"]) { expect(itemNames(confirmation(name), buyerBoilerplate(name))).not.toContain( name.toLowerCase(), ); diff --git a/app/__tests__/frankEileenParser.test.ts b/app/__tests__/frankEileenParser.test.ts index 2acc525b..e7cd9d5c 100644 --- a/app/__tests__/frankEileenParser.test.ts +++ b/app/__tests__/frankEileenParser.test.ts @@ -1,10 +1,18 @@ // /app/__tests__/frankEileenParser.test.ts // -// Pins the Frank & Eileen order-acknowledgement parser against a fixture -// lifted VERBATIM (including whitespace) from the real Spring 2026 ack -// 1949021 pdf-parse extraction. The column-offset spacing is the contract: -// per-size quantities are right-aligned to their size labels, so editing -// the fixture's spaces breaks the thing the test exists to pin. +// Pins the Frank & Eileen order-acknowledgement parser against a fixture whose +// LAYOUT is taken from a real pdf-parse extraction and whose CONTENT is not. +// The column-offset spacing is the contract -- per-size quantities are +// right-aligned to their size labels -- so the whitespace, the concatenation +// artefacts and every field width are preserved character for character. +// +// The buyer, the reps, the document numbers and the unit prices are invented. +// This is a public repository: an ack carries a named business's address and a +// vendor's confidential dealer pricing, and neither is needed to test a parser. +// The parser never reads the address block at all (it looks for the size-column +// header, the item lines, and a season/date line), so swapping it costs nothing. +// +// If you need to reproduce a real ack, do it locally -- do not commit it. import { parseFrankEileenText } from "@/lib/pricing/frankEileenParser"; @@ -12,58 +20,58 @@ const HEADER = `FRANK & EILEEN 843 S. Los Angeles St. #500 Los Angeles CA 90014 -Phone 213-623-1345 +Phone 213-555-0134 *****REPRINT***** -194902104/13/26 +221044704/13/26 Page: 1 of 1 -SAYBROOK HOME -2 MAIN STREETSAYBROOK HOME -OLD SAYBROOK, CT 064752 MAIN STREET -OLD SAYBROOK, CT 06475 -Phone: 860-388-0891Fax: 860-388-3692 -SAYBROSpring 202610/07/2506/01/2606/15/26UPS GROUND -18573341PRE-PAID CREETT Erica TrinHOUS HOUSEEFFI Effie Vals`; - -// 4+3+5+7+2 = 21 units; 448+336+560+1057+286 = 2687.00 +RIVERBEND HOME +14 CANAL STREETRIVERBEND HOME +MILLBROOK FALLS, VT 0561414 CANAL STREET +MILLBROOK FALLS, VT 05614 +Phone: 802-555-0142Fax: 802-555-0143 +RIVERBSpring 202610/07/2506/01/2606/15/26UPS GROUND +18573341PRE-PAID CREETT Dana WhitlHOUS HOUSEROBN Robin Sant`; + +// 4+3+5+7+2 = 21 units; 512+384+640+1162+308 = 3006.00 const FIXTURE = `${HEADER} EILEEN PRBG Relaxed Button-Up Shirt Pink Red Blue Flowers XXS XS S M L XL 0010_ - 1 1 1 1 112.00 4 448.00 + 1 1 1 1 128.00 4 512.00 EILEEN WTQS Relaxed Button-Up Shirt White Turquoise Stripe XXS XS S M L XL 0250_ - 1 1 1 112.00 3 336.00 + 1 1 1 128.00 3 384.00 EILEEN SFBF Relaxed Button-Up Shirt Small Flowers Big Flowers XXS XS S M L XL 0670_ - 1 1 2 1 112.00 5 560.00 + 1 1 2 1 128.00 5 640.00 WVILLAGE CMTL West Village - NYC Trouser Cement 00 0 2 4 6 8 10 12 14 0710_ - 1 1 2 1 1 1 151.00 7 1057.00 + 1 1 2 1 1 1 166.00 7 1162.00 MEGAN F000 One-Size Maxi Shirtdress WHITE VOILE O/S 1420OS - 2 143.00 2 286.00 -Merchandise USD Total 21 2687.00 + 2 154.00 2 308.00 +Merchandise USD Total 21 3006.00 `; describe("parseFrankEileenText — header fields", () => { const order = parseFrankEileenText(FIXTURE); it("splits the concatenated ack number + date", () => { - expect(order.ackNumber).toBe("1949021"); + expect(order.ackNumber).toBe("2210447"); }); it("reads the customer P.O. off the terms line", () => { @@ -83,7 +91,7 @@ describe("parseFrankEileenText — header fields", () => { it("reads the merchandise total line", () => { expect(order.totalUnits).toBe(21); - expect(order.totalPrice).toBe(2687); + expect(order.totalPrice).toBe(3006); }); it("parses every line cleanly (no warnings on the real layout)", () => { @@ -129,8 +137,8 @@ describe("parseFrankEileenText — size-column alignment", () => { { size: "6", quantity: 1 }, { size: "8", quantity: 1 }, ]); - expect(byLine("WVILLAGE", "CMTL").unitPrice).toBe(151); - expect(byLine("WVILLAGE", "CMTL").totalPrice).toBe(1057); + expect(byLine("WVILLAGE", "CMTL").unitPrice).toBe(166); + expect(byLine("WVILLAGE", "CMTL").totalPrice).toBe(1162); }); it("handles the O/S one-size scale (1420OS)", () => { @@ -142,8 +150,8 @@ describe("parseFrankEileenText — refuses to guess", () => { it("drops a line whose quantities do not sum to its own UNITS column", () => { // Same 0250_ block but the UNITS column says 4 while only 3 map. const corrupted = FIXTURE.replace( - " 1 1 1 112.00 3 336.00", - " 1 1 1 112.00 4 448.00", + " 1 1 1 128.00 3 384.00", + " 1 1 1 128.00 4 512.00", ); const order = parseFrankEileenText(corrupted); expect(order.items).toHaveLength(4); diff --git a/app/__tests__/integration/runServiceCaseSheetImport.integration.test.ts b/app/__tests__/integration/runServiceCaseSheetImport.integration.test.ts index f9b14e73..b6cbfa9d 100644 --- a/app/__tests__/integration/runServiceCaseSheetImport.integration.test.ts +++ b/app/__tests__/integration/runServiceCaseSheetImport.integration.test.ts @@ -230,7 +230,7 @@ describe("runServiceCaseSheetImport — real-DB scenarios", () => { const customerId = await seedCustomer({ firstName: "Barbara", lastName: "Panagy", - phone: "860-470-3653", + phone: "860-555-0173", }); const buf = buildWorkbookBuffer({ @@ -238,7 +238,7 @@ describe("runServiceCaseSheetImport — real-DB scenarios", () => { { Timestamp: new Date("2025-10-03T00:00:00Z"), Name: "Barbara Panagy", - "Phone #": "860-470-3653", + "Phone #": "860-555-0173", "Preferred Contact Method": "Phone", Vendor: "Hallagan", Status: "Service Call", diff --git a/app/__tests__/marketTimeOrderParser.test.ts b/app/__tests__/marketTimeOrderParser.test.ts index 904d33aa..5aa4ae1a 100644 --- a/app/__tests__/marketTimeOrderParser.test.ts +++ b/app/__tests__/marketTimeOrderParser.test.ts @@ -23,7 +23,7 @@ const FIXTURE = [ 'Merino Wool 16" Laptop Computer ', "Sleeve - Granite V (Avail:07/10/26)", "84002724051149.00$294.00", - "PO # PON09057 (cont'd)Cust #MFR: Graf & Lantz IncCustomer: Saybrook Home", + "PO # PON09057 (cont'd)Cust #MFR: Graf & Lantz IncCustomer: Riverbend Home", " Page of 22", "10GL10WINO10-12AUTU", "Wine-O's Merino Wool Round Wine ", @@ -89,7 +89,7 @@ describe("parseMarketTimeOrderText", () => { }); it("finds the manufacturer mid-line in the run-together page header", () => { - // "...(cont'd)Cust #MFR: Graf & Lantz IncCustomer: Saybrook Home" — and + // "...(cont'd)Cust #MFR: Graf & Lantz IncCustomer: Riverbend Home" — and // that line is dropped by the item pass's page-furniture filter, so the // header is read before filtering. expect(order.vendorName).toBe("Graf & Lantz Inc"); diff --git a/app/__tests__/ordoriteReportRouter.test.ts b/app/__tests__/ordoriteReportRouter.test.ts index bda3b7a4..c7e51129 100644 --- a/app/__tests__/ordoriteReportRouter.test.ts +++ b/app/__tests__/ordoriteReportRouter.test.ts @@ -24,17 +24,17 @@ describe("resolveImportRoute", () => { expect((result as { importType: string }).importType).toBe("deposits"); }); - it("routes Saybrook_Home_Customers to customers", () => { - const result = resolveImportRoute("Saybrook_Home_Customers.csv"); + it("routes Riverbend_Home_Customers to customers", () => { + const result = resolveImportRoute("Riverbend_Home_Customers.csv"); expect(result).not.toBeNull(); expect((result as { importType: string }).importType).toBe("customers"); }); // 2026-05-20 rename: owner renamed the Ordorite customer export to // include "Prior_Day_" so it scopes to new data only. Router accepts - // both old + new names — regex is /Saybrook_Home_(Prior_Day_)?Customers/i. - it("routes Saybrook_Home_Prior_Day_Customers to customers (post-rename)", () => { - const result = resolveImportRoute("Saybrook_Home_Prior_Day_Customers.csv"); + // both old + new names — the org prefix is a config, so any prefix routes. + it("routes Riverbend_Home_Prior_Day_Customers to customers (post-rename)", () => { + const result = resolveImportRoute("Riverbend_Home_Prior_Day_Customers.csv"); expect(result).not.toBeNull(); expect((result as { importType: string }).importType).toBe("customers"); }); @@ -42,13 +42,13 @@ describe("resolveImportRoute", () => { describe("purchasing and receiving reports", () => { it("routes Prior_Day_Received_Items to received-items", () => { - const result = resolveImportRoute("Saybrook_Home_Prior_Day_Received_Items.csv"); + const result = resolveImportRoute("Riverbend_Home_Prior_Day_Received_Items.csv"); expect(result).not.toBeNull(); expect((result as { importType: string }).importType).toBe("received-items"); }); it("routes Prior_Day_Temp_Items to temp-items", () => { - const result = resolveImportRoute("Saybrook_Home_Prior_Day_Temp_Items.csv"); + const result = resolveImportRoute("Riverbend_Home_Prior_Day_Temp_Items.csv"); expect(result).not.toBeNull(); expect((result as { importType: string }).importType).toBe("temp-items"); }); @@ -57,7 +57,7 @@ describe("resolveImportRoute", () => { // "Temp_Items" to "Temp_Purchase_Orders". Router accepts both — // regex is /Prior_Day_Temp_(Items|Purchase_Orders)/i. it("routes Prior_Day_Temp_Purchase_Orders to temp-items (post-rename)", () => { - const result = resolveImportRoute("Saybrook_Home_Prior_Day_Temp_Purchase_Orders.csv"); + const result = resolveImportRoute("Riverbend_Home_Prior_Day_Temp_Purchase_Orders.csv"); expect(result).not.toBeNull(); expect((result as { importType: string }).importType).toBe("temp-items"); }); @@ -68,8 +68,8 @@ describe("resolveImportRoute", () => { expect((result as { importType: string }).importType).toBe("po-lines"); }); - it("routes Saybrook_Home_Inbound_Items to inbound-items (not purchase-orders)", () => { - const result = resolveImportRoute("Saybrook_Home_Inbound_Items.csv"); + it("routes Riverbend_Home_Inbound_Items to inbound-items (not purchase-orders)", () => { + const result = resolveImportRoute("Riverbend_Home_Inbound_Items.csv"); expect(result).not.toBeNull(); expect((result as { importType: string }).importType).toBe("inbound-items"); }); @@ -159,9 +159,47 @@ describe("resolveImportRoute", () => { }); }); - describe("route order (Saybrook_Home_Inbound_Items before Inbound_Items)", () => { - it("Saybrook_Home_Inbound_Items matches inbound-items, not purchase-orders", () => { - const result = resolveImportRoute("Saybrook_Home_Inbound_Items.csv"); + // The org prefix is a deployment fact, not a constant. Every fixture above + // uses one made-up org; these prove the router is not keyed to it -- a second + // org routes identically, and a deployment can pin its own prefix. + describe("org report prefix is not hardcoded", () => { + const ORIGINAL = process.env.ORDORITE_REPORT_PREFIX; + afterEach(() => { + if (ORIGINAL === undefined) delete process.env.ORDORITE_REPORT_PREFIX; + else process.env.ORDORITE_REPORT_PREFIX = ORIGINAL; + }); + + it("routes a different org's prefix identically", () => { + for (const [file, importType] of [ + ["Acme_Furniture_Inbound_Items.csv", "inbound-items"], + ["Acme_Furniture_Prior_Day_Customers.csv", "customers"], + ["ACME_Item_Export.csv", "products"], + ["ACME_Stock_by_Item.csv", "stock"], + ] as const) { + const result = resolveImportRoute(file); + expect(result).not.toBeNull(); + expect((result as { importType: string }).importType).toBe(importType); + } + }); + + it("honours a pinned ORDORITE_REPORT_PREFIX", () => { + process.env.ORDORITE_REPORT_PREFIX = "Acme_Furniture"; + expect( + (resolveImportRoute("Acme_Furniture_Inbound_Items.csv") as { importType: string }) + .importType, + ).toBe("inbound-items"); + // A pinned prefix narrows the match: another org's file falls through to + // the unprefixed vendor-standard route rather than the org-report one. + expect( + (resolveImportRoute("Other_Co_Inbound_Items.csv") as { importType: string }) + .importType, + ).toBe("purchase-orders"); + }); + }); + + describe("route order (Riverbend_Home_Inbound_Items before Inbound_Items)", () => { + it("Riverbend_Home_Inbound_Items matches inbound-items, not purchase-orders", () => { + const result = resolveImportRoute("Riverbend_Home_Inbound_Items.csv"); expect((result as { importType: string }).importType).toBe("inbound-items"); }); }); diff --git a/app/__tests__/ordoriteShared.test.ts b/app/__tests__/ordoriteShared.test.ts index 3cd5c6fe..176c4a69 100644 --- a/app/__tests__/ordoriteShared.test.ts +++ b/app/__tests__/ordoriteShared.test.ts @@ -263,11 +263,12 @@ describe("isUntrustedMergeEmail", () => { // wrongly cluster distinct customers. The guard blocks any email whose // DOMAIN contains COMPANY_EMAIL_DOMAIN. A short stem ("sayb") covers // the canonical company domain plus every typo variant seen in prod - // data (saybrookhome.com, saybrokkhome.com, saybrookhome.comf, ...). + // data. Domains here are invented -- the guard reads the stem from env and the + // test sets it, so nothing is coupled to any real company's address. const ORIGINAL_DOMAIN = process.env.COMPANY_EMAIL_DOMAIN; beforeAll(() => { - process.env.COMPANY_EMAIL_DOMAIN = "sayb"; + process.env.COMPANY_EMAIL_DOMAIN = "rive"; }); afterAll(() => { @@ -279,23 +280,23 @@ describe("isUntrustedMergeEmail", () => { }); it("flags canonical staff emails", () => { - expect(isUntrustedMergeEmail("joneil@saybrookhome.com")).toBe(true); - expect(isUntrustedMergeEmail("gstone@saybrookhome.com")).toBe(true); + expect(isUntrustedMergeEmail("jmoreau@riverbendhome.com")).toBe(true); + expect(isUntrustedMergeEmail("tcaldwell@riverbendhome.com")).toBe(true); }); it("flags case-insensitively", () => { - expect(isUntrustedMergeEmail("JONEIL@SAYBROOKHOME.COM")).toBe(true); - expect(isUntrustedMergeEmail("GStone@SaybrookHome.com")).toBe(true); + expect(isUntrustedMergeEmail("JMOREAU@RIVERBENDHOME.COM")).toBe(true); + expect(isUntrustedMergeEmail("TCaldwell@RiverbendHome.com")).toBe(true); }); it("flags known typo domains seen in prod", () => { - expect(isUntrustedMergeEmail("wcope@saybrokkhome.com")).toBe(true); - expect(isUntrustedMergeEmail("joneil@saybrookhome.comf")).toBe(true); + expect(isUntrustedMergeEmail("pnowak@riverbenndhome.com")).toBe(true); + expect(isUntrustedMergeEmail("jmoreau@riverbendhome.comf")).toBe(true); }); it("flags any company-like internal domain (defense in depth)", () => { - expect(isUntrustedMergeEmail("user@oldsaybrook-home.com")).toBe(true); - expect(isUntrustedMergeEmail("user@saybrookbarn.com")).toBe(true); + expect(isUntrustedMergeEmail("user@old-riverbend-home.com")).toBe(true); + expect(isUntrustedMergeEmail("user@riverbendbarn.com")).toBe(true); }); it("passes external customer emails through", () => { @@ -313,8 +314,8 @@ describe("isUntrustedMergeEmail", () => { it("does not flag external emails that mention the company in the local part", () => { // The stem appearing BEFORE the @ is fine — only the domain part is // checked (the guard slices on lastIndexOf("@") for this reason). - expect(isUntrustedMergeEmail("saybrook.fan@gmail.com")).toBe(false); - expect(isUntrustedMergeEmail("loves-saybrook@yahoo.com")).toBe(false); + expect(isUntrustedMergeEmail("riverbend.fan@gmail.com")).toBe(false); + expect(isUntrustedMergeEmail("loves-riverbend@yahoo.com")).toBe(false); }); it("is disabled entirely when COMPANY_EMAIL_DOMAIN is unset", () => { @@ -322,7 +323,7 @@ describe("isUntrustedMergeEmail", () => { // so deployments that never configure it keep plain email matching. delete process.env.COMPANY_EMAIL_DOMAIN; try { - expect(isUntrustedMergeEmail("joneil@saybrookhome.com")).toBe(false); + expect(isUntrustedMergeEmail("jmoreau@riverbendhome.com")).toBe(false); } finally { process.env.COMPANY_EMAIL_DOMAIN = "sayb"; } diff --git a/app/__tests__/serviceCaseSheetImport.test.ts b/app/__tests__/serviceCaseSheetImport.test.ts index 56aa67e3..f3834b94 100644 --- a/app/__tests__/serviceCaseSheetImport.test.ts +++ b/app/__tests__/serviceCaseSheetImport.test.ts @@ -163,10 +163,10 @@ describe("poNumberCandidates", () => { describe("normalizePhone", () => { it("strips formatting, drops leading 1 country code", () => { - expect(normalizePhone("860-470-3653")).toBe("8604703653"); - expect(normalizePhone("(860) 470-3653")).toBe("8604703653"); - expect(normalizePhone("1-860-470-3653")).toBe("8604703653"); - expect(normalizePhone("+18604703653")).toBe("8604703653"); + expect(normalizePhone("860-555-0173")).toBe("8605550173"); + expect(normalizePhone("(860) 555-0173")).toBe("8605550173"); + expect(normalizePhone("1-860-555-0173")).toBe("8605550173"); + expect(normalizePhone("+18605550173")).toBe("8605550173"); }); it("returns empty string for blank input", () => { diff --git a/app/__tests__/simblistCsvOrderParser.test.ts b/app/__tests__/simblistCsvOrderParser.test.ts index f232601c..c32b1658 100644 --- a/app/__tests__/simblistCsvOrderParser.test.ts +++ b/app/__tests__/simblistCsvOrderParser.test.ts @@ -9,7 +9,7 @@ import { parseSimblistCsvText } from "@/lib/pricing/simblistCsvOrderParser"; const FIXTURE = [ "RepGroup,Manufacturer,PO #,Order Date,Request Date,Ship Date,Cancel Date,Order Total,Customer Name", - "Simblist Group,MAISON ZOE FORD,PON09047,2026-06-11,2026-09-01,2026-09-01,,722.74,SAYBROOK HOME", + "Simblist Group,MAISON ZOE FORD,PON09047,2026-06-11,2026-09-01,2026-09-01,,722.74,RIVERBEND HOME", "Sequence #,Item Number,Name,Description,Quantity,Unit Price,Unit Qty,Item Discount,UPC,Unit of measure,Size,Color,Style,Notes,Retailer Item Number,List Price,Item Status,Extended Price,Total Price", '3,ZFUSA03-C,Big Time Brownie Mix - case pack of 6,,2,53.94,,0.0,10628678860152,,,,,"Only available to ship on September 1, 2026",,17.99,,,$107.88', "6,ZFUSA07-C,Speedy Cinnamon Roll Mix - case pack of 6,,2,41.94,,0.0,10628678860176,,,,,,,13.99,,,$83.88", diff --git a/app/__tests__/wendoverOrderParser.test.ts b/app/__tests__/wendoverOrderParser.test.ts index 1c938828..fc8c885a 100644 --- a/app/__tests__/wendoverOrderParser.test.ts +++ b/app/__tests__/wendoverOrderParser.test.ts @@ -40,7 +40,7 @@ const FIXTURE = [ // Page break: the NEXT item's name and price print together, ahead of // its SKU line, with the print's page furniture in between. "Patterned Dignity 4 3$745.20", - "7/16/26, 1:11 PMsaybrookhome.com Mail - Fwd: Your Wendover Art Group order confirmation", + "7/16/26, 1:11 PMriverbendhome.com Mail - Fwd: Your Wendover Art Group order confirmation", "Page 3 of 8https://mail.google.com/mail/u/1/?ik=77234f1af6", "SKU: WAN2555", "Medium", diff --git a/app/src/lib/adapters/ordorite/reportRouter.ts b/app/src/lib/adapters/ordorite/reportRouter.ts index 6e8e5ce2..bb1a4718 100644 --- a/app/src/lib/adapters/ordorite/reportRouter.ts +++ b/app/src/lib/adapters/ordorite/reportRouter.ts @@ -21,7 +21,14 @@ import { } from "@/lib/adapters/ordorite/runners"; interface RouteEntry { - pattern: RegExp; + // A report Ordorite names the same way for every customer. + pattern?: RegExp; + // A report the DEPLOYING ORG named after itself. Ordorite lets the owner + // pick export filenames, so the prefix is a deployment fact, not a vendor + // constant (CLAUDE.md 61-63) -- set ORDORITE_REPORT_PREFIX to pin it. + // Unset means "any prefix", which is what discriminates these from the + // unprefixed vendor-standard reports of the same name. + orgReport?: string; importType: string; runner: (data: Record[], createdBy?: string) => Promise; // stock-by-item wraps records in { records: [...] } -- the runner accepts @@ -46,7 +53,7 @@ const REPORT_ROUTES: RouteEntry[] = [ runner: runDepositsImport, }, { - pattern: /SH_Stock_by_Item/i, + orgReport: "Stock_by_Item", importType: "stock", runner: runStockByItemImport, }, @@ -66,12 +73,12 @@ const REPORT_ROUTES: RouteEntry[] = [ runner: runTempItemsImport, }, { - pattern: /SH_Purchase_Order_Line_Export/i, + orgReport: "Purchase_Order_Line_Export", importType: "po-lines", runner: runPOLineExportImport, }, { - pattern: /Saybrook_Home_Inbound_Items/i, + orgReport: "Inbound_Items", importType: "inbound-items", runner: runInboundItemsImport, }, @@ -96,12 +103,12 @@ const REPORT_ROUTES: RouteEntry[] = [ runner: runInvoicesImport, }, { - // Matches both legacy `Saybrook_Home_Customers` AND post-2026-05-20 - // rename to `Saybrook_Home_Prior_Day_Customers` — owner renamed the + // Matches both the legacy `_Customers` AND the post-2026-05-20 + // rename to `_Prior_Day_Customers` — the owner renamed the // Ordorite report so it scopes to new (prior-day) data only. // Both filenames carry the same data shape (customer master); // runCustomerImport handles both. - pattern: /Saybrook_Home_(Prior_Day_)?Customers/i, + orgReport: "(?:Prior_Day_)?Customers", importType: "customers", runner: runCustomerImport, }, @@ -109,10 +116,10 @@ const REPORT_ROUTES: RouteEntry[] = [ // Daily product master from Ordorite, added 2026-05-26. Replaces // the historical manual upload at /admin/import/ordorite-products // for routine refreshes — the manual page still exists for ad-hoc - // bulk imports. Filename: `SH_Item_Export.csv` (~100K rows). All + // bulk imports. Filename: `_Item_Export.csv` (~100K rows). All // rows in the export are Active=yes; discontinued products are // simply absent. The runner self-chunks 500 rows per batch. - pattern: /SH_Item_Export/i, + orgReport: "Item_Export", importType: "products", runner: runProductsImport, }, @@ -133,14 +140,28 @@ export interface ResolvedRoute { runner: (data: Record[], createdBy?: string) => Promise; } +// Regex fragment matching the deploying org's report-name prefix. Deliberately +// permissive when unconfigured: any prefix routes, which preserves the +// prefixed-vs-bare discrimination without any deployment naming itself in code. +function orgPrefixFragment(): string { + const configured = process.env.ORDORITE_REPORT_PREFIX?.trim(); + return configured ? configured.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : ".+"; +} + +function patternFor(route: RouteEntry, prefix: string): RegExp { + if (route.pattern) return route.pattern; + return new RegExp(`${prefix}_(?:${route.orgReport})`, "i"); +} + export function resolveImportRoute(filename: string): ResolvedRoute | "skip" | null { // Check if this is a known-redundant file for (const skip of SKIP_PATTERNS) { if (skip.test(filename)) return "skip"; } + const prefix = orgPrefixFragment(); for (const route of REPORT_ROUTES) { - if (route.pattern.test(filename)) { + if (patternFor(route, prefix).test(filename)) { return { importType: route.importType, runner: route.runner }; } } diff --git a/app/src/lib/adapters/ordorite/runners.ts b/app/src/lib/adapters/ordorite/runners.ts index bdb6120f..0ef79aea 100644 --- a/app/src/lib/adapters/ordorite/runners.ts +++ b/app/src/lib/adapters/ordorite/runners.ts @@ -2609,7 +2609,7 @@ export async function runCustomerImport( // customers in Ordorite. Those values aren't actually the // customer's email and propagating them caused 138 wrongly- // merged customers across ~20 records. isUntrustedMergeEmail - // covers `@saybrookhome.com`, known typos, and any future + // covers the deployment's own domain, known typos of it, and any future // internal-domain variant. if (email && !customer.email && !isUntrustedMergeEmail(email)) { const conflict = await prisma.customer.findUnique({ @@ -3070,7 +3070,7 @@ export async function runReceivedItemsImport( } // --------------------------------------------------------------------------- -// Inbound items import (Saybrook_Home_Inbound_Items) +// Inbound items import (`_Inbound_Items`) // Updates ESDs on POs and creates/updates items without POR numbers. // --------------------------------------------------------------------------- diff --git a/app/src/lib/adapters/ordorite/shared.ts b/app/src/lib/adapters/ordorite/shared.ts index 40169b17..6fc7f62d 100644 --- a/app/src/lib/adapters/ordorite/shared.ts +++ b/app/src/lib/adapters/ordorite/shared.ts @@ -213,10 +213,20 @@ export function isRefundPayment(paymentType: string, amount: number): boolean { const RETURN_ORDER_PREFIX = /^(R|CR)-?\d/i; -// Ordorite uses an "A" suffix on the store code for return/credit transactions: -// SBOA = Saybrook Old return, GTOA = Glastonbury return, CHOA = Cheshire return. -// The "M" suffix (SBOM, GTOM, CHOM) is for regular merchandise orders. -const RETURN_STORE_SUFFIX = /^(SB|GT|CH|BB|WS|RS)[A-Z]*A\d/i; +// Ordorite uses an "A" suffix on the store code for return/credit transactions +// and an "M" suffix for regular merchandise -- e.g. a store coded "AB" writes +// ABxxA1234 for a return and ABxxM1234 for an order. The A/M suffix is the +// vendor's convention; the STORE CODES are a deployment fact (CLAUDE.md 61-63), +// so they come from config and default to "any code". +function storeCodeFragment(): string { + const configured = process.env.ORDORITE_STORE_CODES?.trim(); + if (!configured) return "[A-Z]{2,}"; + return configured + .split(",") + .map((code) => code.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + .filter(Boolean) + .join("|"); +} // Rewrite-suffix matching. Ordorite rewrites replace the original order; the // new order number is " - A" (or B/C/D up to D). Everything that was on @@ -266,9 +276,12 @@ export function rewriteBaseOrderno(orderno: string): string | null { /** True when the order number follows Ordorite's return/credit convention. */ export function isReturnOrder(orderno: string): boolean { - // RS-prefixed orders are Returns Saybrook - if (/^RS\d/i.test(orderno)) return true; - return RETURN_ORDER_PREFIX.test(orderno) || RETURN_STORE_SUFFIX.test(orderno); + // "R" + a store initial (RS1234) is a return booked against that store -- + // distinct from RETURN_ORDER_PREFIX, which is "R"/"CR" followed by digits. + if (/^R[A-Z]\d/i.test(orderno)) return true; + const codes = storeCodeFragment(); + const returnStoreSuffix = new RegExp(`^(?:${codes})[A-Z]*A\\d`, "i"); + return RETURN_ORDER_PREFIX.test(orderno) || returnStoreSuffix.test(orderno); } /** diff --git a/app/src/lib/journalEntry.ts b/app/src/lib/journalEntry.ts index 6b404537..de24d0d8 100644 --- a/app/src/lib/journalEntry.ts +++ b/app/src/lib/journalEntry.ts @@ -968,7 +968,7 @@ export async function generateSalesJournal( // SERVER-LOCAL, so the journal's window depended on the host's TZ; it matched // the comment only because the containers set no TZ and default to UTC. The // reconciliation compared that window against its own UTC-day window, so the - // two could agree only on a UTC deployment -- and Saybrook is + // two could agree only on a UTC deployment -- and a US retailer is // America/New_York. const timeZone = await getBusinessTimeZone(); const dayKey = date.toISOString().slice(0, 10); diff --git a/app/src/lib/payments/types.ts b/app/src/lib/payments/types.ts index 168aff13..391f5ef8 100644 --- a/app/src/lib/payments/types.ts +++ b/app/src/lib/payments/types.ts @@ -43,7 +43,7 @@ export interface ProviderCapabilities { export interface CheckoutRequest { amount: number; currency: string; - /** Line-item label the customer sees, e.g. "Invoice INV-1042 — Saybrook Home". */ + /** Line-item label the customer sees, e.g. "Invoice INV-1042 — Riverbend Home". */ description: string; customerEmail?: string; /** Echoed back on the webhook. Holt routes structurally off Payment rows, so diff --git a/app/src/lib/pricing/beatrizBallOrderParser.ts b/app/src/lib/pricing/beatrizBallOrderParser.ts index 7ed05482..bcf068ac 100644 --- a/app/src/lib/pricing/beatrizBallOrderParser.ts +++ b/app/src/lib/pricing/beatrizBallOrderParser.ts @@ -57,7 +57,7 @@ const MONEY_TAIL = /^([\d,]+\.\d{2})([\d,]+\.\d{2})([\d,]+\.\d{2})(\d+)$/; // The VENDOR's own letterhead. These are safe to hardcode in a vendor-specific // parser: every Beatriz Ball confirmation carries them, whoever the buyer is. // -// What used to be here as well was OUR name -- "saybrook", "old saybrook" -- +// What used to be here as well was OUR OWN name and town -- // because the confirmation repeats the buyer's name and address in the header. // That made the parser correct for exactly one deployment: anyone else's name // appears in the same place and is read as an order line. Those come from diff --git a/app/src/lib/pricing/marketTimeOrderParser.ts b/app/src/lib/pricing/marketTimeOrderParser.ts index 922fce22..c0144231 100644 --- a/app/src/lib/pricing/marketTimeOrderParser.ts +++ b/app/src/lib/pricing/marketTimeOrderParser.ts @@ -59,7 +59,7 @@ export interface MarketTimeOrder { } // The manufacturer prints MID-LINE in a run-together page header -// ("...(cont'd)Cust #MFR: Graf & Lantz IncCustomer: Saybrook Home"), so it is +// ("...(cont'd)Cust #MFR: Graf & Lantz IncCustomer: Riverbend Home"), so it is // read out of the middle rather than anchored, and BEFORE the page-furniture // filter — which drops that very line. const MFR = /MFR:\s*(.+?)(?:Cust(?:omer)?\s*#?:?|$)/; diff --git a/env.example b/env.example index 848ad74e..215edf27 100644 --- a/env.example +++ b/env.example @@ -32,6 +32,25 @@ POSTGRES_DB=holt_dev # GMAIL_IMPERSONATE_EMAIL=you@example.com # AUTO_IMPORT_API_KEY= +# --- Source-system conventions (POS/ERP you import FROM) --- +# Your own naming inside the source system, not vendor constants. Each defaults +# to "match anything", so imports work unconfigured; set them to pin matching +# and stop a look-alike filename or order number routing somewhere unintended. +# +# Report-filename prefix -- `Acme_Home` matches `Acme_Home_Inbound_Items.csv`, +# which is what distinguishes YOUR inbound-items export from the vendor's +# unprefixed standard report of the same name. +# ORDORITE_REPORT_PREFIX=Acme_Home +# +# Store codes embedded in order numbers. The vendor's A/M suffix marks a return +# (ABxxA1234) vs an order (ABxxM1234); the codes themselves are yours. +# ORDORITE_STORE_CODES=AB,CD,EF +# +# Your staff email domain, or a short stem of it. Salespeople sometimes type +# their OWN address into a customer record; the import refuses to merge +# customers on any email in this domain. A stem ("acme") also covers typos. +# COMPANY_EMAIL_DOMAIN=acme + # --- Backups (scripts/backup-db.sh, installed by scripts/install-cron.sh) --- # BACKUP_REMOTE is the difference between a backup and a second copy of a # single point of failure. When set, a failed off-host copy FAILS the backup; From e7080df04a016e54042c934a22ddd8d3bb80b41f Mon Sep 17 00:00:00 2001 From: goetchstone Date: Wed, 26 Aug 2026 08:52:03 -0400 Subject: [PATCH 03/11] fix: scrub real client and personal data, add a tripwire to keep it out The repo is public and carried one retailer's production data as test fixtures. Removed: 19 real people's names (staff and customers, across 21 files), a customer's and a vendor's phone number, staff email local-parts, the company's email domain, its three store towns, and four real customer street addresses. Replaced with invented values at the same shapes -- the fixed-width column offsets are the parsers' contract. Two guards were the same bug rather than only a leak: - The seed's target check was a BLOCKLIST of database names. It failed open: any name nobody had listed seeded silently, and the list only ever grew after someone lost data. It is now an allowlist -- only a name that says it exists to be seeded (seed/demo/scratch/sandbox/sample) runs unattended. env.example's default moves holt_dev -> holt_demo to match, and setup.sh uses the same rule so it fails before migrating rather than after. This had no test at all; __tests__/seedTargetGuard.test.ts now covers it, and the cases that matter are the unfamiliar names. - restore-drill.sh and apply-preset already gated on allowlists, so their deny-lists were redundant as well as identifying. __tests__/clientDataTripwire.test.ts greps every tracked file for the identifiers found here and fails on a hit. The exemption list is checked in both directions, so a stale entry cannot quietly cover a new leak. Two applied migrations are exempt with the reason stated: editing them breaks their Prisma checksum on live deployments. CLAUDE.md rule 59 and docs/domains/seed-data.md rewritten to describe the allowlist, so the rule and the code say the same thing. 3,457 unit tests pass; tsc and prettier clean. --- .claude/skills/start-session/SKILL.md | 2 +- CLAUDE.md | 15 ++- app/__tests__/clientDataTripwire.test.ts | 101 ++++++++++++++++++ app/__tests__/commissionTiers.test.ts | 2 +- app/__tests__/config/dbConfigState.test.ts | 4 +- app/__tests__/homeAccessoryOrders.test.ts | 4 +- app/__tests__/imports/engine.test.ts | 4 +- ...ersCommittedStockSplit.integration.test.ts | 2 +- ...customerLedgerBackfill.integration.test.ts | 4 +- .../departmentReportRoles.integration.test.ts | 2 +- .../quotesReconcile.integration.test.ts | 8 +- .../runCommissionPayouts.integration.test.ts | 6 +- .../runQuotesImport.integration.test.ts | 18 ++-- .../runSalesImport.integration.test.ts | 38 +++---- ...ServiceCaseSheetImport.integration.test.ts | 14 +-- .../salesPersonFkBackfill.integration.test.ts | 10 +- ...aseInitialNoteBackfill.integration.test.ts | 2 +- app/__tests__/ordoriteShared.test.ts | 30 +++--- app/__tests__/parallelRunCompare.test.ts | 12 +-- app/__tests__/payPeriodLock.test.ts | 10 +- .../salesBySalesperson.helpers.test.ts | 26 ++--- app/__tests__/salesExplorerPivot.test.ts | 66 ++++++------ app/__tests__/salesExplorerQuery.test.ts | 8 +- app/__tests__/seedTargetGuard.test.ts | 65 +++++++++++ app/__tests__/serviceCaseSheetImport.test.ts | 20 ++-- app/__tests__/staffAttribution.test.ts | 8 +- app/__tests__/trafficSummary.test.ts | 36 +++---- app/__tests__/weeklySummaryRows.test.ts | 18 ++-- app/prisma/schema.prisma | 4 +- app/prisma/seed/demo/guard.ts | 37 ++++--- app/prisma/seed/demo/index.ts | 7 +- app/prisma/seed/demo/staff.ts | 2 +- app/scripts/apply-preset.impl.ts | 8 +- app/scripts/setup.sh | 14 ++- app/src/app/(dashboard)/app/HomeView.tsx | 4 +- app/src/lib/adapters/ordorite/runners.ts | 4 +- app/src/lib/config/applyPreset.ts | 2 +- app/src/lib/config/presetApiTypes.ts | 2 +- app/src/lib/config/presetFiles.ts | 6 +- app/src/lib/config/presetSchema.ts | 2 +- app/src/lib/reports/designerDashboard.ts | 2 +- app/src/lib/reports/salespersonDetail.ts | 2 +- app/src/lib/runServiceCaseSheetImport.ts | 2 +- app/src/lib/salesBySalesperson.ts | 4 +- .../pages/api/automations/source-import.ts | 2 +- config/presets/README.md | 4 +- docs/DEPLOYMENTS.md | 4 +- docs/PRODUCTION.md | 8 +- docs/RULE-PROVENANCE.md | 2 +- docs/TENANCY.md | 2 +- docs/domains/accounting.md | 4 +- docs/domains/config-presets.md | 10 +- docs/domains/import-pipeline.md | 8 +- docs/domains/imports-overview.md | 2 +- docs/domains/inventory.md | 2 +- docs/domains/legacy-archive.md | 2 +- docs/domains/sales-orders.md | 8 +- docs/domains/seed-data.md | 25 +++-- docs/tenant-literal-sweep.md | 30 +++--- env.example | 4 +- scripts/restore-drill.sh | 9 +- 61 files changed, 474 insertions(+), 289 deletions(-) create mode 100644 app/__tests__/clientDataTripwire.test.ts create mode 100644 app/__tests__/seedTargetGuard.test.ts diff --git a/.claude/skills/start-session/SKILL.md b/.claude/skills/start-session/SKILL.md index 2d4871c6..af5a46da 100644 --- a/.claude/skills/start-session/SKILL.md +++ b/.claude/skills/start-session/SKILL.md @@ -57,7 +57,7 @@ the planned tasks. | Validate command | `cd app && npm run validate` | | Test command | `cd app && npm test` (unit) / `npm run test:coverage` (combined gate) | | Local Sonar/Semgrep/OSV | `cd app && npm run check:local` | -| Test DB | `fbc_test_db` only (rule 59) — never `saybrook`, `holt_saybrook`, `akritos` | +| Test DB | `fbc_test_db` only (rule 59) — never a restored or curated database | | Structured logging | `logger.info/warn/error` — never `console.*` in `src/` | | Cancelled line rule | Every aggregation: `lineItemStatus: { not: "CANCELLED" }` (rule 33) | | Revenue status rule | `status: { in: SALES_REVENUE_STATUSES }` includes RETURNED (rule 47) | diff --git a/CLAUDE.md b/CLAUDE.md index f464979e..be6f2e6c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -196,11 +196,16 @@ Full playbook: → `.claude/skills/dependency-sweep/SKILL.md` ### Data safety -59. **`fbc_test_db` is the only database tests may write.** `saybrook`, - `holt_saybrook`, and `akritos` hold restored or seeded data and must never - be written by a test or script. The `DATABASE_URL must contain 'test'` guard - in `src/lib/testing/withTestDb.ts` is a floor, not a substitute for pointing - at the right database. +59. **`fbc_test_db` is the only database tests may write**, and the demo seed + writes only a database whose NAME says it exists to be seeded (`holt_demo`, + `holt_seed_demo`, anything carrying seed/demo/scratch/sandbox/sample). Every + other database is assumed to hold restored, curated or live local data and + needs an explicit `--force-unsafe-db`. Allowlist, not blocklist: a blocklist + of known-dangerous names fails open for the one nobody thought of, which is + always the one that costs someone their data. The `DATABASE_URL must contain + 'test'` guard in `src/lib/testing/withTestDb.ts` is a floor, not a substitute + for pointing at the right database. Enforced by + `prisma/seed/demo/guard.ts`, tested in `__tests__/seedTargetGuard.test.ts`. ## Stack and gates diff --git a/app/__tests__/clientDataTripwire.test.ts b/app/__tests__/clientDataTripwire.test.ts new file mode 100644 index 00000000..53f78738 --- /dev/null +++ b/app/__tests__/clientDataTripwire.test.ts @@ -0,0 +1,101 @@ +// /app/__tests__/clientDataTripwire.test.ts +// +// This repository is PUBLIC. It began as one retailer's internal system, and +// their production data leaked into it as test fixtures: staff and customer +// names, contact details, the company's own domain, its store and town names. +// That was scrubbed once. This test is what stops it coming back. +// +// It exists because the failure is silent and asymmetric. Committing a real +// customer's name breaks no test, blocks no build, and looks exactly like the +// invented fixture beside it -- but once pushed it is in the history and in +// every fork, and no later commit takes it back. +// +// SCOPE, deliberately narrow. This scans for the specific identifiers already +// found in this repo, not for "PII" in general -- an open-ended heuristic here +// would flag invented fixtures constantly and get silenced, which is worse than +// no test. When a new deployment's data lands, add ITS identifiers here. +// +// The one thing this test does NOT cover: the identifiers scrubbed at HEAD are +// still reachable in this repo's git history and in any existing fork. Removing +// them there means rewriting history, which is a separate decision. + +import { execFileSync } from "node:child_process"; +import { join } from "node:path"; + +const REPO_ROOT = join(__dirname, "..", ".."); + +/** + * Identifiers belonging to a real business or a real person. Each is a regex + * source, matched case-insensitively against every tracked file. + * + * Anything added here must be genuinely identifying. A generic word that merely + * appears in one deployment's data does not belong -- it would fire on ordinary + * code and train people to add exemptions. + */ +const FORBIDDEN: { pattern: string; what: string }[] = [ + { pattern: "saybrook", what: "the pilot deployment's company and town name" }, + { pattern: "saybrookhome", what: "the pilot deployment's email domain" }, + { pattern: "cheshire|glastonbury", what: "the pilot deployment's store towns" }, + { pattern: "sammyg40|joneil|gstone|wcope", what: "real staff email local-parts" }, + { pattern: "greenstein|panagy|dransfield|matheny|tenerow", what: "real people's surnames" }, + { pattern: "sorbo|nordquist|calkins|barnum|homan|favale|vantongeren", what: "real people's surnames" }, + { pattern: "860-470-3653|213-623-1345", what: "real phone numbers" }, +]; + +/** + * Files allowed to contain a hit, each with the reason it cannot be scrubbed. + * + * A path here is a standing exception, so the reason has to be a real + * constraint -- "it's only a comment" is not one. Prisma records a checksum of + * every migration when it applies it, so editing an applied migration makes + * `prisma migrate deploy` fail on every existing deployment until someone + * resolves it by hand. That is a genuine reason; there is no other. + */ +const ALLOWED: Record = { + "app/prisma/migrations/20260806163000_stock_location_holds_committed_stock/migration.sql": + "applied migration -- editing it breaks its Prisma checksum on live deployments", + "app/prisma/migrations/20260806180000_app_settings_source_adapter/migration.sql": + "applied migration -- editing it breaks its Prisma checksum on live deployments", + "app/__tests__/clientDataTripwire.test.ts": "this file lists the patterns it searches for", +}; + +function trackedHits(pattern: string): { file: string; line: string }[] { + let out = ""; + try { + out = execFileSync("git", ["grep", "-niE", pattern, "--", "."], { + cwd: REPO_ROOT, + encoding: "utf8", + maxBuffer: 32 * 1024 * 1024, + }); + } catch (e) { + // git grep exits 1 with no output when nothing matches -- that is the pass. + const err = e as { status?: number; stdout?: string }; + if (err.status === 1 && !err.stdout) return []; + if (err.stdout) out = err.stdout; + else throw e; + } + return out + .split("\n") + .filter(Boolean) + .map((l) => ({ file: l.slice(0, l.indexOf(":")), line: l })); +} + +describe("no real client or personal data in a public repo", () => { + for (const { pattern, what } of FORBIDDEN) { + it(`does not contain ${what}`, () => { + const unexplained = trackedHits(pattern).filter((h) => !(h.file in ALLOWED)); + expect(unexplained.map((h) => h.line)).toEqual([]); + }); + } + + // The exemption list is the part that rots: a file gets scrubbed or deleted, + // its entry stays, and the next real hit in a path someone copied from it + // passes silently. Checking BOTH directions is what makes the list trustworthy. + it("has no stale exemptions -- every allowed path still has a hit to explain", () => { + const allHits = new Set(FORBIDDEN.flatMap(({ pattern }) => trackedHits(pattern)).map((h) => h.file)); + const stale = Object.keys(ALLOWED).filter( + (f) => f !== "app/__tests__/clientDataTripwire.test.ts" && !allHits.has(f), + ); + expect(stale).toEqual([]); + }); +}); diff --git a/app/__tests__/commissionTiers.test.ts b/app/__tests__/commissionTiers.test.ts index bc53f4c1..6cb1e728 100644 --- a/app/__tests__/commissionTiers.test.ts +++ b/app/__tests__/commissionTiers.test.ts @@ -236,7 +236,7 @@ describe("no plan configured — empty tiers are an answer, not a crash", () => }); it("a configured plan is unaffected — the equivalence that matters", () => { - // Saybrook's numbers must not move. Same inputs, same output as before. + // the pilot deployment's numbers must not move. Same inputs, same output as before. const result = calculateMarginalCommission(0, 1_000_000, DEFAULT_COMMISSION_TIERS); expect(result.commission).toBeGreaterThan(0); expect(resolveTier(1_000_000, DEFAULT_COMMISSION_TIERS)?.label).toBe("$1M – $1.5M"); diff --git a/app/__tests__/config/dbConfigState.test.ts b/app/__tests__/config/dbConfigState.test.ts index dc035061..90af745b 100644 --- a/app/__tests__/config/dbConfigState.test.ts +++ b/app/__tests__/config/dbConfigState.test.ts @@ -167,7 +167,7 @@ describe("loadDbConfigState — traffic-store-mapping ownership grouping (item 8 changeLogs: [ { presetKind: "traffic-store-mapping", - presetName: "saybrook-traffic", + presetName: "riverbend-traffic", action: "APPLIED", summary: { ownedStores: ["Downtown"] }, created: new Date("2026-01-01"), @@ -187,7 +187,7 @@ describe("loadDbConfigState — traffic-store-mapping ownership grouping (item 8 (p): p is Extract => p.kind === "traffic-store-mapping", ); const byName = Object.fromEntries(traffic.map((p) => [p.name, p.stores.map((s) => s.storeLocation)])); - expect(byName["saybrook-traffic"]).toEqual(["Downtown"]); + expect(byName["riverbend-traffic"]).toEqual(["Downtown"]); expect(byName[TRAFFIC_STORE_MAPPING_PRESET_NAME]).toEqual(["Uptown"]); // Load-bearing: re-importing this exact bundle must not conflict with diff --git a/app/__tests__/homeAccessoryOrders.test.ts b/app/__tests__/homeAccessoryOrders.test.ts index 2551955b..aebb2682 100644 --- a/app/__tests__/homeAccessoryOrders.test.ts +++ b/app/__tests__/homeAccessoryOrders.test.ts @@ -476,10 +476,10 @@ describe("normalizeWendoverOrder", () => { it("flags Side Mark items as already sold to a customer", () => { const draft = normalizeWendoverOrder( wendoverOrder({ - items: [wendoverItem({ sku: "WFL1944", sideMark: "SBOM41649/Erin Kelly" })], + items: [wendoverItem({ sku: "WFL1944", sideMark: "SBOM41649/Dana Whitl" })], }), ); - expect(draft.warnings.some((w) => w.includes("SBOM41649/Erin Kelly"))).toBe(true); + expect(draft.warnings.some((w) => w.includes("SBOM41649/Dana Whitl"))).toBe(true); expect(draft.warnings.some((w) => w.includes("1 item(s) carry a Side Mark"))).toBe(true); }); diff --git a/app/__tests__/imports/engine.test.ts b/app/__tests__/imports/engine.test.ts index ac3a7908..1c9ef949 100644 --- a/app/__tests__/imports/engine.test.ts +++ b/app/__tests__/imports/engine.test.ts @@ -118,11 +118,11 @@ describe("value mapping", () => { importMode: "INSERT_ONLY", fieldMappings: [{ sourceColumn: "City", targetField: "city" }], valueMappings: [], - rows: [{ City: "Glastonbury" }], + rows: [{ City: "Wexbridge" }], }); expect(result.rows[0]).toMatchObject({ outcome: "would-create", - record: { city: "Glastonbury" }, + record: { city: "Wexbridge" }, }); }); }); diff --git a/app/__tests__/integration/buyersCommittedStockSplit.integration.test.ts b/app/__tests__/integration/buyersCommittedStockSplit.integration.test.ts index c7eb6eb2..0a67ded7 100644 --- a/app/__tests__/integration/buyersCommittedStockSplit.integration.test.ts +++ b/app/__tests__/integration/buyersCommittedStockSplit.integration.test.ts @@ -5,7 +5,7 @@ // `getBuyersPositions`) and nothing short of a database exercises it. // // Until 2026-08 that SQL asked `sl.name ILIKE 'customer%'` -- an Ordorite / -// Saybrook location-naming convention hardcoded into shared reporting code. +// one deployment's location-naming convention hardcoded into shared reporting code. // Any deployment that named its holding locations anything else had its // committed stock counted as available to sell (CLAUDE.md rule 61). It now // reads `StockLocation.holdsCommittedStock`. diff --git a/app/__tests__/integration/customerLedgerBackfill.integration.test.ts b/app/__tests__/integration/customerLedgerBackfill.integration.test.ts index d63fd0b7..d0241376 100644 --- a/app/__tests__/integration/customerLedgerBackfill.integration.test.ts +++ b/app/__tests__/integration/customerLedgerBackfill.integration.test.ts @@ -137,12 +137,12 @@ describe("customerLedgerBackfill (real DB)", () => { // ─── 2. Rewrite chain ───────────────────────────────────────────────── it("nets a rewrite chain correctly: base + SR-SAMPLE + rewrite, phantom payment absent", async () => { - // Sandy Favale shape: base $10K paid $5K deposit, customer modifies + // Sandy Fenwick shape: base $10K paid $5K deposit, customer modifies // order, the POS splits into base + accounting return + rewrite. // Phantom Gift Card payment on rewrite is FILTERED AT IMPORT and // therefore not in our DB — backfill must produce the right net // balance from what's left. - const customerId = await seedCustomer("Sandy", "Favale"); + const customerId = await seedCustomer("Sandy", "Fenwick"); const dayX = new Date("2024-10-04T10:00:00Z"); const dayY = new Date("2024-10-05T10:00:00Z"); diff --git a/app/__tests__/integration/departmentReportRoles.integration.test.ts b/app/__tests__/integration/departmentReportRoles.integration.test.ts index 807718ad..7f93a4f7 100644 --- a/app/__tests__/integration/departmentReportRoles.integration.test.ts +++ b/app/__tests__/integration/departmentReportRoles.integration.test.ts @@ -56,7 +56,7 @@ const OLD_TARGETS = [ "Home Acc", ]; -// Every department name the old code could meet: the real Saybrook taxonomy, +// Every department name the old code could meet: one real retailer's taxonomy, // the demo seed's names, and the cases that decide a branch. const NAMES = [ "Furniture", diff --git a/app/__tests__/integration/quotesReconcile.integration.test.ts b/app/__tests__/integration/quotesReconcile.integration.test.ts index 1f47f326..25a9489e 100644 --- a/app/__tests__/integration/quotesReconcile.integration.test.ts +++ b/app/__tests__/integration/quotesReconcile.integration.test.ts @@ -38,8 +38,8 @@ function csvRow(orderno: string, partNo: string, qty = 1, price = 100): Record { status: "QUOTE", orderDate: new Date("2026-04-21"), customerId: customer.id, - storeLocation: "Old Saybrook", - salesperson: "Kim Dransfield", + storeLocation: "Old Harbour", + salesperson: "Kim Draycott", lineItems: { create: Array.from({ length: lineCount }, (_, i) => ({ lineNumber: i + 1, diff --git a/app/__tests__/integration/runCommissionPayouts.integration.test.ts b/app/__tests__/integration/runCommissionPayouts.integration.test.ts index 361a38bc..567e8fc8 100644 --- a/app/__tests__/integration/runCommissionPayouts.integration.test.ts +++ b/app/__tests__/integration/runCommissionPayouts.integration.test.ts @@ -326,7 +326,7 @@ describe("previewPayoutsForPeriod (real DB)", () => { // aliases to additionally match a variant spelling. const customer = await seedCustomer(); const sandra = await seedDesigner({ - displayName: "Sandra Matheny", + displayName: "Sandra Merrick", aliases: ["Sandy"], }); @@ -338,7 +338,7 @@ describe("previewPayoutsForPeriod (real DB)", () => { orderDate: new Date("2026-05-18T00:00:00Z"), customerId: customer.id, salesPersonId: null, - salesperson: "Sandra Matheny", + salesperson: "Sandra Merrick", lineItems: { create: [ { @@ -376,7 +376,7 @@ describe("previewPayoutsForPeriod (real DB)", () => { }); const drafts = await previewPayoutsForPeriod(PERIOD_START, PERIOD_END); - const s = drafts.find((d) => d.displayName === "Sandra Matheny"); + const s = drafts.find((d) => d.displayName === "Sandra Merrick"); expect(s).toBeDefined(); expect(s?.staffMemberId).toBe(sandra.id); expect(s?.periodSalesAmount).toBe(15_000); diff --git a/app/__tests__/integration/runQuotesImport.integration.test.ts b/app/__tests__/integration/runQuotesImport.integration.test.ts index 95df1137..6f954cc3 100644 --- a/app/__tests__/integration/runQuotesImport.integration.test.ts +++ b/app/__tests__/integration/runQuotesImport.integration.test.ts @@ -12,7 +12,7 @@ // running orphan-cleanup that re-cancelled lines on rewrite-base orders // every time the auto-import ran. // -// Real prod incident: SBOM39275 (5/3 Old Saybrook, $7,819 missing from +// Real prod incident: SBOM39275 (5/3 Old Harbour, $7,819 missing from // the daily total). Caught the second time on 2026-05-07 — the FIRST // fix (PR #209 rewrite-freeze in runSalesImport) didn't cover the // quote-runner code path. See post-failure log. @@ -58,12 +58,12 @@ interface QuoteCsvRow extends Record { function quoteRow(overrides: Partial & { partNo: string; price: number }): QuoteCsvRow { return { - Company: "Old Saybrook", + Company: "Old Harbour", Orderno: ORDERNO, Status: "active", Salesperson: "Molly", Address: "", - Customer: "Sandy Favale", + Customer: "Sandy Fenwick", Email: "test@example.com", Orderdate: "2026-05-03", Quotecode: "SBQT32802", @@ -92,7 +92,7 @@ describe("runQuotesImport — promoted-order guard + rewrite-freeze", () => { // the Daily Quote Report. Before the fix: reconcileExistingQuoteOrder // overwrites netPrice with unit prices AND cancels orphan lines. const customer = await prisma.customer.create({ - data: { firstName: "Sandy", lastName: "Favale" }, + data: { firstName: "Sandy", lastName: "Fenwick" }, }); await prisma.salesOrder.create({ data: { @@ -100,7 +100,7 @@ describe("runQuotesImport — promoted-order guard + rewrite-freeze", () => { status: "ORDER", // promoted from QUOTE orderDate: new Date("2026-05-03"), customerId: customer.id, - storeLocation: "Old Saybrook", + storeLocation: "Old Harbour", salesperson: "Molly", quoteCode: "SBQT32802", // had a quote code at one point lineItems: { @@ -174,7 +174,7 @@ describe("runQuotesImport — promoted-order guard + rewrite-freeze", () => { status: "RETURNED", orderDate: new Date("2026-04-30"), customerId: customer.id, - storeLocation: "Old Saybrook", + storeLocation: "Old Harbour", quoteCode: "Q-1", lineItems: { create: [ @@ -221,7 +221,7 @@ describe("runQuotesImport — promoted-order guard + rewrite-freeze", () => { status: "QUOTE", orderDate: new Date("2026-04-30"), customerId: customer.id, - storeLocation: "Old Saybrook", + storeLocation: "Old Harbour", quoteCode: "Q-1", lineItems: { create: [1, 2, 3, 4, 5].map((n) => ({ @@ -241,7 +241,7 @@ describe("runQuotesImport — promoted-order guard + rewrite-freeze", () => { status: "QUOTE", orderDate: new Date("2026-05-01"), customerId: customer.id, - storeLocation: "Old Saybrook", + storeLocation: "Old Harbour", quoteCode: "Q-1A", }, }); @@ -274,7 +274,7 @@ describe("runQuotesImport — promoted-order guard + rewrite-freeze", () => { status: "QUOTE", orderDate: new Date("2026-04-30"), customerId: customer.id, - storeLocation: "Old Saybrook", + storeLocation: "Old Harbour", quoteCode: "Q-1", lineItems: { create: [1, 2, 3].map((n) => ({ diff --git a/app/__tests__/integration/runSalesImport.integration.test.ts b/app/__tests__/integration/runSalesImport.integration.test.ts index ef8845de..d20f253c 100644 --- a/app/__tests__/integration/runSalesImport.integration.test.ts +++ b/app/__tests__/integration/runSalesImport.integration.test.ts @@ -45,7 +45,7 @@ // regression.test.ts`) exercised this logic; neither goes // through Prisma/Postgres. See "Same-day rewrites — the // dropped-line edge case" in the domain doc for the full -// incident history (2026-05-12 Cheshire $1,109 delta, +// incident history (2026-05-12 Brookvale $1,109 delta, // 2026-05-15 SBOM39618 over-cancellation, 2026-05-22 SBOM39876 // return-lookup bug). // @@ -72,7 +72,7 @@ const ORDERNO = "SBOM38000"; // staff-email scenarios behave deterministically, and restore the // original value afterwards. const COMPANY_DOMAIN = "holtco.example"; -const STAFF_EMAIL = `joneil@${COMPANY_DOMAIN}`; +const STAFF_EMAIL = `jmoreau@${COMPANY_DOMAIN}`; const ORIGINAL_COMPANY_DOMAIN = process.env.COMPANY_EMAIL_DOMAIN; interface SalesCsvRow extends Record { @@ -100,8 +100,8 @@ function csvRow(overrides: Partial & { partNo: string }): SalesCsvR Customer: "Test Customer", Email: "test@example.com", Orderdate: "2026-04-21", - Company: "Old Saybrook", - Salesperson: "Kim Dransfield", + Company: "Old Harbour", + Salesperson: "Kim Draycott", "Part No": overrides.partNo, "Product Name": `Product ${overrides.partNo}`, "Barcode No": "", @@ -141,7 +141,7 @@ describe("runSalesImport — real-DB scenarios", () => { // captures whatever lines moved to a different cuscode/order on a // subsequent date. SBOM39275 hit this exact pattern in prod. const customer = await prisma.customer.create({ - data: { firstName: "Sandy", lastName: "Favale" }, + data: { firstName: "Sandy", lastName: "Fenwick" }, }); await prisma.salesOrder.create({ data: { @@ -149,7 +149,7 @@ describe("runSalesImport — real-DB scenarios", () => { status: "ORDER", orderDate: new Date("2026-05-03"), customerId: customer.id, - storeLocation: "Old Saybrook", + storeLocation: "Old Harbour", salesperson: "Molly", lineItems: { create: [1, 2, 3, 4, 5].map((n) => ({ @@ -172,15 +172,15 @@ describe("runSalesImport — real-DB scenarios", () => { status: "ORDER", orderDate: new Date("2026-05-04"), customerId: customer.id, - storeLocation: "Old Saybrook", + storeLocation: "Old Harbour", }, }); // Re-import the base with only 2 lines (CSV legitimately shrunk // because the POS split the order at rewrite time). const csv = [ - csvRow({ partNo: "BASE-1", Customer: "Sandy Favale" }), - csvRow({ partNo: "BASE-2", Customer: "Sandy Favale" }), + csvRow({ partNo: "BASE-1", Customer: "Sandy Fenwick" }), + csvRow({ partNo: "BASE-2", Customer: "Sandy Fenwick" }), ]; const result = await runSalesImport(csv); @@ -208,7 +208,7 @@ describe("runSalesImport — real-DB scenarios", () => { status: "ORDER", orderDate: new Date("2026-04-21"), customerId: customer.id, - storeLocation: "Old Saybrook", + storeLocation: "Old Harbour", lineItems: { create: [1, 2, 3, 4, 5].map((n) => ({ lineNumber: n, @@ -255,7 +255,7 @@ describe("runSalesImport — real-DB scenarios", () => { status: "ORDER", orderDate: new Date("2026-04-21"), customerId: customer.id, - storeLocation: "Old Saybrook", + storeLocation: "Old Harbour", lineItems: { create: [ { @@ -304,7 +304,7 @@ describe("runSalesImport — real-DB scenarios", () => { status: "ORDER", orderDate: new Date("2026-04-21"), customerId: customer.id, - storeLocation: "Old Saybrook", + storeLocation: "Old Harbour", lineItems: { create: [ { @@ -347,12 +347,12 @@ describe("runSalesImport — real-DB scenarios", () => { describe("findOrCreateCustomer guards (PR #210, #216)", () => { it("does NOT merge into existing customer when incoming email is a company-domain staff email", async () => { // Seed an existing customer with a company-domain email (= a - // historical merge seed, e.g. 'Sandy and David Favale' on a + // historical merge seed, e.g. 'Sandy and David Fenwick' on a // staff member's email). const seed = await prisma.customer.create({ data: { firstName: "Sandy and David", - lastName: "Favale", + lastName: "Fenwick", email: STAFF_EMAIL, }, }); @@ -544,7 +544,7 @@ describe("runSalesImport — real-DB scenarios", () => { csvRow({ partNo: "M-HYDRATE-3", Cuscode: "SBCT-PARTIAL", - Customer: "Reborn Ciccone", + Customer: "Reborn Calloway", Email: "", }), ]; @@ -751,10 +751,10 @@ describe("runSalesImport — real-DB scenarios", () => { return csvRow({ Orderno: orderno, Cuscode: CUSCODE, - Customer: "Brian Tenerow", + Customer: "Brian Thorne", Email: "", Orderdate: SAME_DAY, - Company: "Cheshire", + Company: "Brookvale", partNo, Orderqty: qty, netprice, @@ -822,7 +822,7 @@ describe("runSalesImport — real-DB scenarios", () => { // Lines 4-5 (dropped, beyond the rewrite's footprint, no return // or rewrite match): CANCELLED by the post-import sweep. This is // exactly the SO-1726/CHOM1726 shape from the post-failure log - // 2026-05-12 — the $1,109 Cheshire delta. + // 2026-05-12 — the $1,109 Brookvale delta. expect(statuses.slice(3, 5)).toEqual(["CANCELLED", "CANCELLED"]); // Cross-check against the pure helper directly — the runner's @@ -953,7 +953,7 @@ describe("runSalesImport — real-DB scenarios", () => { Customer: "Keep Case Customer", Email: "", Orderdate: day, - Company: "Cheshire", + Company: "Brookvale", partNo, Orderqty: qty, netprice, diff --git a/app/__tests__/integration/runServiceCaseSheetImport.integration.test.ts b/app/__tests__/integration/runServiceCaseSheetImport.integration.test.ts index b6cbfa9d..b8532506 100644 --- a/app/__tests__/integration/runServiceCaseSheetImport.integration.test.ts +++ b/app/__tests__/integration/runServiceCaseSheetImport.integration.test.ts @@ -229,7 +229,7 @@ describe("runServiceCaseSheetImport — real-DB scenarios", () => { it("creates one ServiceCase + one initial-issue note per row", async () => { const customerId = await seedCustomer({ firstName: "Barbara", - lastName: "Panagy", + lastName: "Pallant", phone: "860-555-0173", }); @@ -237,7 +237,7 @@ describe("runServiceCaseSheetImport — real-DB scenarios", () => { inProcess: [ { Timestamp: new Date("2025-10-03T00:00:00Z"), - Name: "Barbara Panagy", + Name: "Barbara Pallant", "Phone #": "860-555-0173", "Preferred Contact Method": "Phone", Vendor: "Hallagan", @@ -285,7 +285,7 @@ describe("runServiceCaseSheetImport — real-DB scenarios", () => { inProcess: [ { Timestamp: new Date("2025-10-03T00:00:00Z"), - Name: "Karen Dwyer", + Name: "Karen Dunmore", "Order #": "PONO6239/ SO-28978-A", "Initial Issue, Status Update, and Notes": "Replacing the seat cushion.", }, @@ -302,12 +302,12 @@ describe("runServiceCaseSheetImport — real-DB scenarios", () => { }); it("is idempotent — re-running the same buffer creates / updates nothing new", async () => { - await seedCustomer({ firstName: "Alan", lastName: "Nordquist" }); + await seedCustomer({ firstName: "Alan", lastName: "Nordlund" }); const buf = buildWorkbookBuffer({ inProcess: [ { Timestamp: new Date("2024-05-17T00:00:00Z"), - Name: "Alan Nordquist", + Name: "Alan Nordlund", Vendor: "Durham", Status: "Needs Attention", "Initial Issue, Status Update, and Notes": "Bed RAF side won't latch.", @@ -878,13 +878,13 @@ describe("runServiceCaseSheetImport — real-DB scenarios", () => { { ref: "K2", // K = column 11 (Initial Issue), row 2 (first data row) dt: "2025-12-03T19:19:55.00", - author: "Rebecca Warren", + author: "Rebecca Wexford", text: "First comment — actual case start", }, { ref: "K2", dt: "2026-02-04T10:00:00.00", - author: "Rebecca Warren", + author: "Rebecca Wexford", text: "Later follow-up", }, ], diff --git a/app/__tests__/integration/salesPersonFkBackfill.integration.test.ts b/app/__tests__/integration/salesPersonFkBackfill.integration.test.ts index 6a4a326e..2e8ea0cd 100644 --- a/app/__tests__/integration/salesPersonFkBackfill.integration.test.ts +++ b/app/__tests__/integration/salesPersonFkBackfill.integration.test.ts @@ -26,12 +26,12 @@ describe("backfillSalesPersonFk — real-DB", () => { it("sets salesPersonId when salesperson string matches displayName", async () => { const staff = await prisma.staffMember.create({ - data: { displayName: "Cheryl Homan", role: "DESIGNER" }, + data: { displayName: "Cheryl Holloway", role: "DESIGNER" }, }); const order = await prisma.salesOrder.create({ data: { orderno: "SO-MATCH-NAME", - salesperson: "Cheryl Homan", + salesperson: "Cheryl Holloway", salesPersonId: null, }, }); @@ -47,14 +47,14 @@ describe("backfillSalesPersonFk — real-DB", () => { const sandy = await prisma.staffMember.create({ data: { displayName: "Sandy", - aliases: ["Sandra Matheny"], + aliases: ["Sandra Merrick"], role: "MANAGER", }, }); const order = await prisma.salesOrder.create({ data: { orderno: "SO-MATCH-ALIAS", - salesperson: "Sandra Matheny", + salesperson: "Sandra Merrick", salesPersonId: null, }, }); @@ -68,7 +68,7 @@ describe("backfillSalesPersonFk — real-DB", () => { it("matches case-insensitively and trims whitespace", async () => { const staff = await prisma.staffMember.create({ - data: { displayName: "Karen West", role: "DESIGNER" }, + data: { displayName: "Karen Weston", role: "DESIGNER" }, }); const order = await prisma.salesOrder.create({ data: { diff --git a/app/__tests__/integration/serviceCaseInitialNoteBackfill.integration.test.ts b/app/__tests__/integration/serviceCaseInitialNoteBackfill.integration.test.ts index 52b60848..43314cf6 100644 --- a/app/__tests__/integration/serviceCaseInitialNoteBackfill.integration.test.ts +++ b/app/__tests__/integration/serviceCaseInitialNoteBackfill.integration.test.ts @@ -105,7 +105,7 @@ async function seedWrongDatedCase(opts: { externalSource: "cs-sheet", externalSourceId: `cs-sheet-note:guid-${opts.caseRowKey}-${i}`, created: opts.threadedDates[i], - authorDisplayName: "Rebecca Warren", + authorDisplayName: "Rebecca Wexford", }, }); } diff --git a/app/__tests__/ordoriteShared.test.ts b/app/__tests__/ordoriteShared.test.ts index 176c4a69..299d57a4 100644 --- a/app/__tests__/ordoriteShared.test.ts +++ b/app/__tests__/ordoriteShared.test.ts @@ -336,16 +336,16 @@ describe("splitCustomerName", () => { // Used by findOrCreateCustomer's name-and-email match guard. it("splits 'First Last' into firstName + lastName", () => { - expect(splitCustomerName("Aimee Sorbo")).toEqual({ + expect(splitCustomerName("Aimee Solano")).toEqual({ firstName: "Aimee", - lastName: "Sorbo", + lastName: "Solano", }); }); it("treats everything after the first token as lastName", () => { - expect(splitCustomerName("Sandy and David Favale")).toEqual({ + expect(splitCustomerName("Sandy and David Fenwick")).toEqual({ firstName: "Sandy", - lastName: "and David Favale", + lastName: "and David Fenwick", }); }); @@ -405,10 +405,10 @@ describe("parseOrdoriteAddress", () => { }); it("parses without country (3 parts)", () => { - const result = parseOrdoriteAddress("45 Oak Ave, Glastonbury, CT"); + const result = parseOrdoriteAddress("45 Oak Ave, Wexbridge, CT"); expect(result).toEqual({ address1: "45 Oak Ave", - city: "Glastonbury", + city: "Wexbridge", state: "CT", }); }); @@ -433,10 +433,10 @@ describe("parseOrdoriteAddress", () => { }); it("handles apartment/unit prefix in address", () => { - const result = parseOrdoriteAddress("Apt B, 298 Highland Avenue, Cheshire, CT, United States"); + const result = parseOrdoriteAddress("Apt B, 112 Ferncliff Avenue, Brookvale, CT, United States"); expect(result).toEqual({ - address1: "Apt B, 298 Highland Avenue", - city: "Cheshire", + address1: "Apt B, 112 Ferncliff Avenue", + city: "Brookvale", state: "CT", }); }); @@ -469,19 +469,19 @@ describe("parseOrdoriteAddress", () => { }); it("strips zip code merged into state field", () => { - const result = parseOrdoriteAddress("57 Princeton Lane, Glastonbury, CT 06033"); + const result = parseOrdoriteAddress("61 Larkfield Lane, Wexbridge, CT 06099"); expect(result).toEqual({ - address1: "57 Princeton Lane", - city: "Glastonbury", + address1: "61 Larkfield Lane", + city: "Wexbridge", state: "CT", }); }); it("drops trailing zip code as separate part", () => { - const result = parseOrdoriteAddress("57 Sunrise Dr., Glastonbury, CT, 06033"); + const result = parseOrdoriteAddress("61 Sunrise Dr., Wexbridge, CT, 06099"); expect(result).toEqual({ - address1: "57 Sunrise Dr.", - city: "Glastonbury", + address1: "61 Sunrise Dr.", + city: "Wexbridge", state: "CT", }); }); diff --git a/app/__tests__/parallelRunCompare.test.ts b/app/__tests__/parallelRunCompare.test.ts index 171927eb..9b8c5970 100644 --- a/app/__tests__/parallelRunCompare.test.ts +++ b/app/__tests__/parallelRunCompare.test.ts @@ -72,17 +72,17 @@ describe("diffDayTotals", () => { describe("diffStores", () => { it("aligns stores present on either side and reports only drifted ones", () => { const holt = [ - { store: "Old Saybrook", revenue: 1000 }, - { store: "Glastonbury", revenue: 500 }, + { store: "Old Harbour", revenue: 1000 }, + { store: "Wexbridge", revenue: 500 }, ]; const legacy = [ - { store: "Old Saybrook", revenue: 1000 }, - { store: "Cheshire", revenue: 75 }, + { store: "Old Harbour", revenue: 1000 }, + { store: "Brookvale", revenue: 75 }, ]; const rows = diffStores(holt, legacy, 0.01); expect(rows).toEqual([ - { store: "Cheshire", holt: 0, legacy: 75, drift: -75 }, - { store: "Glastonbury", holt: 500, legacy: 0, drift: 500 }, + { store: "Brookvale", holt: 0, legacy: 75, drift: -75 }, + { store: "Wexbridge", holt: 500, legacy: 0, drift: 500 }, ]); }); diff --git a/app/__tests__/payPeriodLock.test.ts b/app/__tests__/payPeriodLock.test.ts index 5ee3a517..e3b09978 100644 --- a/app/__tests__/payPeriodLock.test.ts +++ b/app/__tests__/payPeriodLock.test.ts @@ -106,7 +106,7 @@ describe("isOrderLockedByNameOrFk", () => { periodStart: PERIOD_START, periodEnd: PERIOD_END, reopenedAt: null, - names: ["Kim Dransfield"], + names: ["Kim Draycott"], ...over, }; } @@ -117,7 +117,7 @@ describe("isOrderLockedByNameOrFk", () => { orderDate: inPeriod, salesPersonId: null, splitWithId: null, - salesperson: "Kim Dransfield", + salesperson: "Kim Draycott", }; expect(isOrderLockedByNameOrFk(order, [confWithNames()])).toBe(true); }); @@ -127,7 +127,7 @@ describe("isOrderLockedByNameOrFk", () => { orderDate: inPeriod, salesPersonId: null, splitWithId: null, - salesperson: "kim dransfield", + salesperson: "kim draycott", }; expect(isOrderLockedByNameOrFk(order, [confWithNames()])).toBe(true); }); @@ -157,7 +157,7 @@ describe("isOrderLockedByNameOrFk", () => { orderDate: new Date("2026-07-01T12:00:00Z"), salesPersonId: null, splitWithId: null, - salesperson: "Kim Dransfield", + salesperson: "Kim Draycott", }; expect(isOrderLockedByNameOrFk(order, [confWithNames()])).toBe(false); }); @@ -167,7 +167,7 @@ describe("isOrderLockedByNameOrFk", () => { orderDate: inPeriod, salesPersonId: null, splitWithId: null, - salesperson: "Kim Dransfield", + salesperson: "Kim Draycott", }; expect(isOrderLockedByNameOrFk(order, [confWithNames({ reopenedAt: new Date() })])).toBe(false); }); diff --git a/app/__tests__/salesBySalesperson.helpers.test.ts b/app/__tests__/salesBySalesperson.helpers.test.ts index a7e682da..8a1d6a53 100644 --- a/app/__tests__/salesBySalesperson.helpers.test.ts +++ b/app/__tests__/salesBySalesperson.helpers.test.ts @@ -180,25 +180,25 @@ describe("applySalesPersonFilter", () => { it("matches orders by salesperson name string (case-insensitive)", () => { const where: Prisma.SalesOrderWhereInput = {}; - applySalesPersonFilter(where, { ids: [], names: ["Cheryl Homan"] }); - expect(where.OR).toEqual([{ salesperson: { equals: "Cheryl Homan", mode: "insensitive" } }]); + applySalesPersonFilter(where, { ids: [], names: ["Cheryl Holloway"] }); + expect(where.OR).toEqual([{ salesperson: { equals: "Cheryl Holloway", mode: "insensitive" } }]); }); it("layers id-match AND name-match together (the canonical use)", () => { const where: Prisma.SalesOrderWhereInput = {}; - applySalesPersonFilter(where, { ids: [3], names: ["Cheryl Homan"] }); + applySalesPersonFilter(where, { ids: [3], names: ["Cheryl Holloway"] }); expect(where.OR).toEqual([ { salesPersonId: { in: [3] } }, { splitWithId: { in: [3] } }, - { salesperson: { equals: "Cheryl Homan", mode: "insensitive" } }, + { salesperson: { equals: "Cheryl Holloway", mode: "insensitive" } }, ]); }); it("emits one OR clause per name (Prisma `in` is case-sensitive on strings)", () => { const where: Prisma.SalesOrderWhereInput = {}; - applySalesPersonFilter(where, { ids: [], names: ["Cheryl Homan", "Sarah Smith"] }); + applySalesPersonFilter(where, { ids: [], names: ["Cheryl Holloway", "Sarah Smith"] }); expect(where.OR).toEqual([ - { salesperson: { equals: "Cheryl Homan", mode: "insensitive" } }, + { salesperson: { equals: "Cheryl Holloway", mode: "insensitive" } }, { salesperson: { equals: "Sarah Smith", mode: "insensitive" } }, ]); }); @@ -207,7 +207,7 @@ describe("applySalesPersonFilter", () => { describe("staffMemberFilter", () => { // Issue #274 / ROADMAP Short-Term #12. Sandy's StaffMember row has // displayName='Sandy' but every imported SalesOrder for her carries - // salesperson='Sandra Matheny'. Without aliases, a dashboard query + // salesperson='Sandra Merrick'. Without aliases, a dashboard query // for "Sandy" finds zero of her 15 orders. it("returns empty filter when staff is null/undefined", () => { @@ -216,19 +216,19 @@ describe("staffMemberFilter", () => { }); it("includes displayName when no aliases set (back-compat)", () => { - const result = staffMemberFilter({ id: 5, displayName: "Cheryl Homan" }); - expect(result).toEqual({ ids: [5], names: ["Cheryl Homan"] }); + const result = staffMemberFilter({ id: 5, displayName: "Cheryl Holloway" }); + expect(result).toEqual({ ids: [5], names: ["Cheryl Holloway"] }); }); it("expands aliases into the names list (the Sandy case)", () => { const result = staffMemberFilter({ id: 30, displayName: "Sandy", - aliases: ["Sandra Matheny"], + aliases: ["Sandra Merrick"], }); expect(result).toEqual({ ids: [30], - names: ["Sandy", "Sandra Matheny"], + names: ["Sandy", "Sandra Merrick"], }); }); @@ -251,13 +251,13 @@ describe("staffMemberFilter", () => { const where: Prisma.SalesOrderWhereInput = {}; applySalesPersonFilter( where, - staffMemberFilter({ id: 30, displayName: "Sandy", aliases: ["Sandra Matheny"] }), + staffMemberFilter({ id: 30, displayName: "Sandy", aliases: ["Sandra Merrick"] }), ); expect(where.OR).toEqual([ { salesPersonId: { in: [30] } }, { splitWithId: { in: [30] } }, { salesperson: { equals: "Sandy", mode: "insensitive" } }, - { salesperson: { equals: "Sandra Matheny", mode: "insensitive" } }, + { salesperson: { equals: "Sandra Merrick", mode: "insensitive" } }, ]); }); }); diff --git a/app/__tests__/salesExplorerPivot.test.ts b/app/__tests__/salesExplorerPivot.test.ts index 8aa1c733..fba547fa 100644 --- a/app/__tests__/salesExplorerPivot.test.ts +++ b/app/__tests__/salesExplorerPivot.test.ts @@ -20,8 +20,8 @@ function cell(netSales: number, cost: number, itemCount = 1) { describe("splitCellKey", () => { it("splits a store|dept|cat|vendor key into its four dimensions", () => { - expect(splitCellKey("Old Saybrook|Furniture|Sofas|Wesley Hall")).toEqual({ - storeLocation: "Old Saybrook", + expect(splitCellKey("Old Harbour|Furniture|Sofas|Wesley Hall")).toEqual({ + storeLocation: "Old Harbour", department: "Furniture", category: "Sofas", vendor: "Wesley Hall", @@ -43,26 +43,26 @@ describe("variancePct", () => { describe("buildSalesExplorerTree", () => { const cellsP1: SalesExplorerCellMap = { - "Old Saybrook|Furniture|Sofas|Wesley Hall": cell(1000, 400, 2), - "Old Saybrook|Furniture|Chairs|Wesley Hall": cell(500, 300, 1), + "Old Harbour|Furniture|Sofas|Wesley Hall": cell(1000, 400, 2), + "Old Harbour|Furniture|Chairs|Wesley Hall": cell(500, 300, 1), "Madison|Furniture|Sofas|Vanguard": cell(200, 100, 1), }; const cellsP2: SalesExplorerCellMap = { - "Old Saybrook|Furniture|Sofas|Wesley Hall": cell(800, 320, 2), + "Old Harbour|Furniture|Sofas|Wesley Hall": cell(800, 320, 2), "Madison|Rugs|Area Rugs|Surya": cell(300, 150, 1), }; it("rolls every cell into every ancestor along the store axis (store -> dept -> category -> vendor)", () => { const { tree } = buildSalesExplorerTree(cellsP1, cellsP2, "store"); - const oldSaybrook = tree.find((n) => n.name === "Old Saybrook")!; + const oldHarbour = tree.find((n) => n.name === "Old Harbour")!; // 1000 (Sofas) + 500 (Chairs) in period1; only the Sofas cell has a // period2 value (800). - expect(oldSaybrook.period1.netSales).toBe(1500); - expect(oldSaybrook.period2.netSales).toBe(800); + expect(oldHarbour.period1.netSales).toBe(1500); + expect(oldHarbour.period2.netSales).toBe(800); // Store's immediate children are department-level (both cells share // department "Furniture"); category-level Sofas/Chairs are grandchildren. - expect(oldSaybrook.children.map((c) => c.name)).toEqual(["Furniture"]); - const furniture = oldSaybrook.children[0]; + expect(oldHarbour.children.map((c) => c.name)).toEqual(["Furniture"]); + const furniture = oldHarbour.children[0]; expect(furniture.children.map((c) => c.name).sort()).toEqual(["Chairs", "Sofas"]); }); @@ -79,16 +79,16 @@ describe("buildSalesExplorerTree", () => { it("computes variance and variancePct at every node", () => { const { tree } = buildSalesExplorerTree(cellsP1, cellsP2, "store"); - const oldSaybrook = tree.find((n) => n.name === "Old Saybrook")!; - expect(oldSaybrook.variance).toBe(700); // 1500 - 800 - expect(oldSaybrook.variancePct).toBeCloseTo(0.875); // 700 / 800 + const oldHarbour = tree.find((n) => n.name === "Old Harbour")!; + expect(oldHarbour.variance).toBe(700); // 1500 - 800 + expect(oldHarbour.variancePct).toBeCloseTo(0.875); // 700 / 800 }); it("computes margin % per period, null when netSales is 0", () => { const { tree } = buildSalesExplorerTree(cellsP1, cellsP2, "store"); - const oldSaybrook = tree.find((n) => n.name === "Old Saybrook")!; + const oldHarbour = tree.find((n) => n.name === "Old Harbour")!; // period1: netSales 1500, cost 400+300=700 -> margin 800/1500 - expect(oldSaybrook.marginPct1).toBeCloseTo(800 / 1500); + expect(oldHarbour.marginPct1).toBeCloseTo(800 / 1500); const madison = tree.find((n) => n.name === "Madison")!; const rugsChild = madison.children.find((c) => c.name === "Rugs")!; expect(rugsChild.marginPct1).toBeNull(); // sold nothing in period1 @@ -96,7 +96,7 @@ describe("buildSalesExplorerTree", () => { it("blank category buckets display as (No Category)", () => { const cells: SalesExplorerCellMap = { - "Old Saybrook|Furniture||Wesley Hall": cell(100, 50, 1), + "Old Harbour|Furniture||Wesley Hall": cell(100, 50, 1), }; const { tree } = buildSalesExplorerTree(cells, {}, "department"); const furniture = tree.find((n) => n.name === "Furniture")!; @@ -107,10 +107,10 @@ describe("buildSalesExplorerTree", () => { it("keeps an orphan Unknown store visible (unlike comparativeSales.ts) so totals equal the sum of visible rows", () => { const cells: SalesExplorerCellMap = { "Unknown|Furniture|Sofas|Wesley Hall": cell(100, 50, 1), - "Old Saybrook|Furniture|Sofas|Wesley Hall": cell(200, 80, 1), + "Old Harbour|Furniture|Sofas|Wesley Hall": cell(200, 80, 1), }; const { tree, totals } = buildSalesExplorerTree(cells, {}, "store"); - expect(tree.map((n) => n.name).sort()).toEqual(["Old Saybrook", "Unknown"]); + expect(tree.map((n) => n.name).sort()).toEqual(["Old Harbour", "Unknown"]); expect(totals.period1.netSales).toBe(300); }); @@ -126,8 +126,8 @@ describe("buildSalesExplorerTree", () => { it("category pivot rolls a category name up across every department it appears in", () => { const cells: SalesExplorerCellMap = { - "Old Saybrook|Furniture|Accessories|VendorA": cell(100, 40, 1), - "Old Saybrook|Home Shop|Accessories|VendorB": cell(50, 20, 1), + "Old Harbour|Furniture|Accessories|VendorA": cell(100, 40, 1), + "Old Harbour|Home Shop|Accessories|VendorB": cell(50, 20, 1), }; const { tree } = buildSalesExplorerTree(cells, {}, "category"); expect(tree).toHaveLength(1); @@ -138,26 +138,26 @@ describe("buildSalesExplorerTree", () => { it("store nodes attach orderCount/visitors/conversion from storeMeta; non-store nodes do not", () => { const { tree } = buildSalesExplorerTree(cellsP1, cellsP2, "store", { - "Old Saybrook": { orderCount1: 10, orderCount2: 8, visitors1: 100, visitors2: 80 }, + "Old Harbour": { orderCount1: 10, orderCount2: 8, visitors1: 100, visitors2: 80 }, }); - const oldSaybrook = tree.find((n) => n.name === "Old Saybrook")!; - expect(oldSaybrook.period1.orderCount).toBe(10); - expect(oldSaybrook.conversion1).toBeCloseTo(0.1); // 10/100 - expect(oldSaybrook.children[0].conversion1).toBeUndefined(); + const oldHarbour = tree.find((n) => n.name === "Old Harbour")!; + expect(oldHarbour.period1.orderCount).toBe(10); + expect(oldHarbour.conversion1).toBeCloseTo(0.1); // 10/100 + expect(oldHarbour.children[0].conversion1).toBeUndefined(); }); it("a store with zero visitors gets null conversion, not a divide-by-zero", () => { const { tree } = buildSalesExplorerTree(cellsP1, cellsP2, "store", { - "Old Saybrook": { orderCount1: 5, orderCount2: 0, visitors1: 0, visitors2: 0 }, + "Old Harbour": { orderCount1: 5, orderCount2: 0, visitors1: 0, visitors2: 0 }, }); - const oldSaybrook = tree.find((n) => n.name === "Old Saybrook")!; - expect(oldSaybrook.conversion1).toBeNull(); + const oldHarbour = tree.find((n) => n.name === "Old Harbour")!; + expect(oldHarbour.conversion1).toBeNull(); }); it("children are sorted by period1 net sales descending", () => { const { tree } = buildSalesExplorerTree(cellsP1, cellsP2, "store"); - const oldSaybrook = tree.find((n) => n.name === "Old Saybrook")!; - const furniture = oldSaybrook.children[0]; + const oldHarbour = tree.find((n) => n.name === "Old Harbour")!; + const furniture = oldHarbour.children[0]; // Sofas (1000) outsold Chairs (500) in period1. expect(furniture.children.map((c) => c.name)).toEqual(["Sofas", "Chairs"]); }); @@ -165,12 +165,12 @@ describe("buildSalesExplorerTree", () => { describe("resolveNodeFilters", () => { it("resolves a top-level store node to just a store filter", () => { - expect(resolveNodeFilters("store", "Old Saybrook")).toEqual({ store: "Old Saybrook" }); + expect(resolveNodeFilters("store", "Old Harbour")).toEqual({ store: "Old Harbour" }); }); it("resolves a fully-drilled store node to all four filters, in axis order", () => { - expect(resolveNodeFilters("store", "Old Saybrook||Furniture||Sofas||Wesley Hall")).toEqual({ - store: "Old Saybrook", + expect(resolveNodeFilters("store", "Old Harbour||Furniture||Sofas||Wesley Hall")).toEqual({ + store: "Old Harbour", department: "Furniture", category: "Sofas", vendor: "Wesley Hall", diff --git a/app/__tests__/salesExplorerQuery.test.ts b/app/__tests__/salesExplorerQuery.test.ts index 776cec0e..8a530fc6 100644 --- a/app/__tests__/salesExplorerQuery.test.ts +++ b/app/__tests__/salesExplorerQuery.test.ts @@ -49,8 +49,8 @@ describe("buildSalesExplorerOrderWhere", () => { }); it("uses a positive `in:` allow-list for the store filter, never a `not`", () => { - const where = buildSalesExplorerOrderWhere({}, ["Old Saybrook", "Madison"]); - expect(where.storeLocation).toEqual({ in: ["Old Saybrook", "Madison"] }); + const where = buildSalesExplorerOrderWhere({}, ["Old Harbour", "Madison"]); + expect(where.storeLocation).toEqual({ in: ["Old Harbour", "Madison"] }); }); it("omits the store filter entirely when no stores are selected (does not drop unfiltered rows)", () => { @@ -84,7 +84,7 @@ describe("getSalesExplorerItems — orphan sentinel normalization", () => { it("passes real department/category/vendor names straight through unmodified", async () => { await getSalesExplorerItems({} as never, { - store: "Old Saybrook", + store: "Old Harbour", department: "Furniture", category: "Sofas", vendor: "Wesley Hall", @@ -94,7 +94,7 @@ describe("getSalesExplorerItems — orphan sentinel normalization", () => { expect(mockedGetDetailedSalesItems).toHaveBeenCalledWith( {}, { - store: "Old Saybrook", + store: "Old Harbour", department: "Furniture", category: "Sofas", vendor: "Wesley Hall", diff --git a/app/__tests__/seedTargetGuard.test.ts b/app/__tests__/seedTargetGuard.test.ts new file mode 100644 index 00000000..a951308f --- /dev/null +++ b/app/__tests__/seedTargetGuard.test.ts @@ -0,0 +1,65 @@ +// /app/__tests__/seedTargetGuard.test.ts +// +// The seed writes thousands of rows outside a transaction, so the target check +// is the only thing between a mistyped DATABASE_URL and someone's real data. +// It had no test until this file. +// +// The guard used to be a BLOCKLIST of specific database names, which failed +// open -- a name nobody had listed seeded silently, and the list only grew +// after someone lost data. It is now an allowlist, so the cases below that +// matter most are the unfamiliar names: they must be refused precisely +// BECAUSE nobody thought of them. + +import { assertSafeSeedTarget, UnsafeSeedTargetError } from "../prisma/seed/demo/guard"; + +const url = (db: string) => `postgresql://user:secret@localhost:5432/${db}`; +const safe = { forceUnsafe: false }; +const forced = { forceUnsafe: true }; + +describe("assertSafeSeedTarget", () => { + it("allows a purpose-built scratch database", () => { + for (const db of ["holt_seed_demo", "demo", "scratch_db", "holt_sandbox", "sample_data"]) { + expect(assertSafeSeedTarget(url(db), safe)).toBe(db); + } + }); + + it("refuses an unfamiliar name -- the case a blocklist misses", () => { + for (const db of ["holt_prod", "acme_restored", "holt_2026_backup", "postgres"]) { + expect(() => assertSafeSeedTarget(url(db), safe)).toThrow(UnsafeSeedTargetError); + } + }); + + it("lets an explicit override reach a non-scratch database", () => { + expect(assertSafeSeedTarget(url("holt_prod"), forced)).toBe("holt_prod"); + }); + + it("never allows the integration-test database, override or not", () => { + expect(() => assertSafeSeedTarget(url("fbc_test_db"), safe)).toThrow(UnsafeSeedTargetError); + expect(() => assertSafeSeedTarget(url("fbc_test_db"), forced)).toThrow(UnsafeSeedTargetError); + }); + + it("refuses an unset DATABASE_URL rather than defaulting somewhere", () => { + expect(() => assertSafeSeedTarget("", safe)).toThrow(UnsafeSeedTargetError); + expect(() => assertSafeSeedTarget("", forced)).toThrow(UnsafeSeedTargetError); + }); + + it("does not leak the password into the refusal message", () => { + expect(() => assertSafeSeedTarget(url("holt_prod"), safe)).toThrow(/:\*\*\*\*@/); + try { + assertSafeSeedTarget(url("holt_prod"), safe); + } catch (e) { + expect((e as Error).message).not.toContain("secret"); + } + }); + + it("matches on the database name, not the rest of the URL", () => { + // A host or user containing "demo" must not make a prod database look safe. + expect(() => assertSafeSeedTarget("postgresql://demo:p@demo-host/holt_prod", safe)).toThrow( + UnsafeSeedTargetError, + ); + // Query parameters are not part of the name. + expect(assertSafeSeedTarget(url("holt_seed_demo") + "?schema=public", safe)).toBe( + "holt_seed_demo", + ); + }); +}); diff --git a/app/__tests__/serviceCaseSheetImport.test.ts b/app/__tests__/serviceCaseSheetImport.test.ts index f3834b94..17c51b9a 100644 --- a/app/__tests__/serviceCaseSheetImport.test.ts +++ b/app/__tests__/serviceCaseSheetImport.test.ts @@ -23,12 +23,12 @@ import { describe("parsePersonXml", () => { it("extracts displayName + userId for every ", () => { const xml = ` - + `; const map = parsePersonXml(xml); expect(map.size).toBe(2); - expect(map.get("aaaaaaa1-bbbb-cccc-dddd-eeeeeeeeeeee")?.displayName).toBe("Rebecca Warren"); + expect(map.get("aaaaaaa1-bbbb-cccc-dddd-eeeeeeeeeeee")?.displayName).toBe("Rebecca Wexford"); expect(map.get("fffffff2-aaaa-bbbb-cccc-dddddddddddd")?.userId).toBe("rwarren@example.com"); }); @@ -179,17 +179,17 @@ describe("normalizePhone", () => { describe("resolveAuthor", () => { const staffByEmail = new Map([["alex@example.com", 100]]); const staffByName = new Map([ - ["rebecca warren", 101], + ["rebecca wexford", 101], ["alex robertson", 100], ]); it("matches by email when userId is set", () => { const r = resolveAuthor( - { displayName: "Alex Robertson", userId: "alex@example.com" }, + { displayName: "Alex Rowntree", userId: "alex@example.com" }, staffByEmail, staffByName, ); - expect(r).toEqual({ authorId: 100, authorDisplayName: "Alex Robertson" }); + expect(r).toEqual({ authorId: 100, authorDisplayName: "Alex Rowntree" }); }); it("treats an @-containing displayName as the email hint", () => { @@ -198,7 +198,7 @@ describe("resolveAuthor", () => { }); it("falls back to displayName lookup case-insensitively", () => { - const r = resolveAuthor({ displayName: "Rebecca Warren" }, staffByEmail, staffByName); + const r = resolveAuthor({ displayName: "Rebecca Wexford" }, staffByEmail, staffByName); expect(r.authorId).toBe(101); }); @@ -216,12 +216,12 @@ describe("resolveAuthor", () => { describe("computeRowKey", () => { it("is stable across calls with the same inputs", () => { const a = computeRowKey({ - name: "Barbara Panagy", + name: "Barbara Pallant", ordernoRaw: "SO12345", sheetName: "C.S. In process", }); const b = computeRowKey({ - name: "Barbara Panagy", + name: "Barbara Pallant", ordernoRaw: "SO12345", sheetName: "C.S. In process", }); @@ -231,12 +231,12 @@ describe("computeRowKey", () => { it("normalizes whitespace + case so trivial edits don't break idempotency", () => { const a = computeRowKey({ - name: "Barbara Panagy", + name: "Barbara Pallant", ordernoRaw: "SO12345", sheetName: "C.S. In process", }); const b = computeRowKey({ - name: " barbara panagy ", + name: " barbara pallant ", ordernoRaw: " so12345 ", sheetName: "c.s. in process", }); diff --git a/app/__tests__/staffAttribution.test.ts b/app/__tests__/staffAttribution.test.ts index e8d76e88..f334fc3e 100644 --- a/app/__tests__/staffAttribution.test.ts +++ b/app/__tests__/staffAttribution.test.ts @@ -36,7 +36,7 @@ describe("terminal logins are never people", () => { it("does not swallow people whose names merely contain a keyword", () => { // Erasing a real seller's attribution is the costlier mistake, so the // patterns anchor rather than match anywhere in the string. - for (const n of ["Sarah", "Allison", "Mary Goodwin", "Adam Calkins", "Reginald Adams"]) { + for (const n of ["Sarah", "Allison", "Mary Goddard", "Adam Caldwell", "Reginald Ashby"]) { expect(isTerminalName(n)).toBe(false); } }); @@ -63,9 +63,9 @@ describe("people are archived only once they have really gone", () => { }); it("keeps someone who sold last week active", () => { - // Bridget Barnum's shape: small volume, selling this month. + // Bridget Barlow's shape: small volume, selling this month. const c = classifySalesperson( - { name: "Bridget Barnum", orderCount: 57, lastOrderDate: daysAgo(6) }, + { name: "Bridget Barlow", orderCount: 57, lastOrderDate: daysAgo(6) }, TODAY, ); expect(c.kind).toBe("active-person"); @@ -94,7 +94,7 @@ describe("new records never land in designer reporting", () => { // them into commission reports they were never part of. for (const days of [10, 400]) { const c = classifySalesperson( - { name: "Madison Baker", orderCount: 1, lastOrderDate: daysAgo(days) }, + { name: "Madison Barrow", orderCount: 1, lastOrderDate: daysAgo(days) }, TODAY, ); const rec = staffRecordFor(c); diff --git a/app/__tests__/trafficSummary.test.ts b/app/__tests__/trafficSummary.test.ts index 358ce53b..bfa2c703 100644 --- a/app/__tests__/trafficSummary.test.ts +++ b/app/__tests__/trafficSummary.test.ts @@ -38,10 +38,10 @@ describe("rollupByDay", () => { it("sums visitors by calendar day across stores", () => { const rows = [ - row("2026-05-27T10:00:00", "Glastonbury", 5), - row("2026-05-27T10:15:00", "Glastonbury", 7), + row("2026-05-27T10:00:00", "Wexbridge", 5), + row("2026-05-27T10:15:00", "Wexbridge", 7), row("2026-05-27T10:00:00", "NB", 3), - row("2026-05-28T11:00:00", "Glastonbury", 10), + row("2026-05-28T11:00:00", "Wexbridge", 10), ]; expect(rollupByDay(rows)).toEqual([ { date: "2026-05-27", visitors: 15, exits: null }, @@ -51,10 +51,10 @@ describe("rollupByDay", () => { it("sums exits when present, returns null when all rows have null exits", () => { const rows = [ - row("2026-05-27T10:00:00", "Glastonbury", 5, 4), - row("2026-05-27T10:15:00", "Glastonbury", 7, 6), - row("2026-05-28T10:00:00", "Glastonbury", 3, null), // mixed - row("2026-05-28T10:15:00", "Glastonbury", 2, 1), + row("2026-05-27T10:00:00", "Wexbridge", 5, 4), + row("2026-05-27T10:15:00", "Wexbridge", 7, 6), + row("2026-05-28T10:00:00", "Wexbridge", 3, null), // mixed + row("2026-05-28T10:15:00", "Wexbridge", 2, 1), ]; const out = rollupByDay(rows); expect(out[0].exits).toBe(10); // 4 + 6 @@ -78,22 +78,22 @@ describe("rollupByDay", () => { describe("rollupByStore", () => { it("sums by store across all days, sorted busiest first", () => { const rows = [ - row("2026-05-27T10:00:00", "Glastonbury", 10), - row("2026-05-28T10:00:00", "Glastonbury", 5), + row("2026-05-27T10:00:00", "Wexbridge", 10), + row("2026-05-28T10:00:00", "Wexbridge", 5), row("2026-05-27T10:00:00", "NB", 20), - row("2026-05-27T10:00:00", "Cheshire", 2), + row("2026-05-27T10:00:00", "Brookvale", 2), ]; expect(rollupByStore(rows)).toEqual([ { sourceStoreName: "NB", storeLocationId: null, visitors: 20, exits: null }, - { sourceStoreName: "Glastonbury", storeLocationId: null, visitors: 15, exits: null }, - { sourceStoreName: "Cheshire", storeLocationId: null, visitors: 2, exits: null }, + { sourceStoreName: "Wexbridge", storeLocationId: null, visitors: 15, exits: null }, + { sourceStoreName: "Brookvale", storeLocationId: null, visitors: 2, exits: null }, ]); }); it("preserves storeLocationId from the first row seen for that store", () => { const rows = [ - row("2026-05-27T10:00:00", "Glastonbury", 5, null, 1), - row("2026-05-27T10:15:00", "Glastonbury", 7, null, 1), + row("2026-05-27T10:00:00", "Wexbridge", 5, null, 1), + row("2026-05-27T10:15:00", "Wexbridge", 7, null, 1), ]; expect(rollupByStore(rows)[0].storeLocationId).toBe(1); }); @@ -102,9 +102,9 @@ describe("rollupByStore", () => { describe("rollupByDayAndStore", () => { it("produces one row per (day, store) sorted by date asc + visitors desc within day", () => { const rows = [ - row("2026-05-27T10:00:00", "Glastonbury", 10), + row("2026-05-27T10:00:00", "Wexbridge", 10), row("2026-05-27T10:00:00", "NB", 20), - row("2026-05-28T10:00:00", "Glastonbury", 30), + row("2026-05-28T10:00:00", "Wexbridge", 30), row("2026-05-28T10:00:00", "NB", 5), ]; expect(rollupByDayAndStore(rows)).toEqual([ @@ -117,14 +117,14 @@ describe("rollupByDayAndStore", () => { }, { date: "2026-05-27", - sourceStoreName: "Glastonbury", + sourceStoreName: "Wexbridge", storeLocationId: null, visitors: 10, exits: null, }, { date: "2026-05-28", - sourceStoreName: "Glastonbury", + sourceStoreName: "Wexbridge", storeLocationId: null, visitors: 30, exits: null, diff --git a/app/__tests__/weeklySummaryRows.test.ts b/app/__tests__/weeklySummaryRows.test.ts index 54d443ec..d9162660 100644 --- a/app/__tests__/weeklySummaryRows.test.ts +++ b/app/__tests__/weeklySummaryRows.test.ts @@ -44,9 +44,9 @@ describe("buildRows", () => { it("computes YoY $ and % against last year", () => { const rows = buildRows( base({ - entityNames: new Set(["Cheshire"]), - thisWeek: new Map([["Cheshire", 1200]]), - lastYear: new Map([["Cheshire", 1000]]), + entityNames: new Set(["Brookvale"]), + thisWeek: new Map([["Brookvale", 1200]]), + lastYear: new Map([["Brookvale", 1000]]), }), ); expect(rows[0].lastYear).toBe(1000); @@ -109,12 +109,12 @@ describe("buildRows", () => { const rows = buildRows( base({ typeParam: "company", - entityNames: new Set(["Cheshire"]), - thisWeek: new Map([["Cheshire", 5000]]), - trafficThis: { Cheshire: 200 }, - trafficLast: { Cheshire: 160 }, - transThis: { Cheshire: 50 }, // 50/200 = 25% - transLast: { Cheshire: 32 }, // 32/160 = 20% + entityNames: new Set(["Brookvale"]), + thisWeek: new Map([["Brookvale", 5000]]), + trafficThis: { Brookvale: 200 }, + trafficLast: { Brookvale: 160 }, + transThis: { Brookvale: 50 }, // 50/200 = 25% + transLast: { Brookvale: 32 }, // 32/160 = 20% }), ); expect(rows[0].conversionPct).toBeCloseTo(25, 5); diff --git a/app/prisma/schema.prisma b/app/prisma/schema.prisma index 17d90ab4..9fd4d65f 100644 --- a/app/prisma/schema.prisma +++ b/app/prisma/schema.prisma @@ -3028,7 +3028,7 @@ model StaffMember { displayName String // Name shown on up-board // Alternate names this person is known by in the POS's `salesperson` // field. Reports OR-match on (displayName + every alias) so Sandy can - // be linked to "Sandra Matheny" without renaming the up-board record. + // be linked to "Sandra Merrick" without renaming the up-board record. // Populated via admin UI or one-off SQL when a mismatch is discovered. // Origin: Issue #274, ROADMAP Short-Term #12. aliases String[] @default([]) @@ -5726,7 +5726,7 @@ model ConfigChangeLog { // FAILED (validation or database error, nothing written). action String - // Where it came from: "cli:config/local/saybrook.yaml", "gui", "seed". + // Where it came from: "cli:config/local/riverbend.yaml", "gui", "seed". // Keeps the GitOps and GUI paths distinguishable in an audit. source String diff --git a/app/prisma/seed/demo/guard.ts b/app/prisma/seed/demo/guard.ts index cfad78c5..1d98362b 100644 --- a/app/prisma/seed/demo/guard.ts +++ b/app/prisma/seed/demo/guard.ts @@ -1,13 +1,13 @@ // app/prisma/seed/demo/guard.ts // -// Target-database safety guard (CLAUDE.md rule 59: "fbc_test_db is the only -// database tests may write. saybrook, holt_saybrook, and akritos hold -// restored or seeded data and must never be written by a test or script."). +// Target-database safety guard (CLAUDE.md rule 59: the integration-test +// database is the only one tests may write; databases holding restored or +// hand-curated data must never be written by a test or script). // This script writes thousands of rows outside a transaction, so it is even // more dangerous than a test run against the wrong database -- there's no -// TRUNCATE-and-retry safety net. Refuse by default; require an explicit, -// separately-named opt-in for the "confirm-only" names, and never allow the -// hard-blocked one at all. +// TRUNCATE-and-retry safety net. Refuse by default; require an explicit +// opt-in for anything that is not a purpose-built scratch database, and never +// allow the hard-blocked one at all. // // This mirrors (but does not import) `src/lib/testing/withTestDb.ts`'s // "DATABASE_URL must contain 'test'" pattern -- that guard protects the @@ -26,13 +26,15 @@ const HARD_BLOCKED_DB_NAMES = ["fbc_test_db"]; /** - * Blocked unless the caller passes an explicit override. These hold - * restored-from-backup or hand-curated data (saybrook / holt_saybrook / - * akritos) or are the shared local dev database every `~/holt` session - * points at (fbc_dev_db) -- clobbering any of them destroys real work with - * no way back. + * Only a name that reads as a purpose-built scratch database seeds without an + * override. Everything else -- a restored client database, the shared local + * dev database, a colleague's copy -- needs --force-unsafe-db. + * + * This is an allowlist deliberately. It replaced a blocklist of specific + * database names, which failed OPEN: a name nobody had thought to list seeded + * silently, and the list only ever grew by someone losing data first. */ -const CONFIRM_BLOCKED_DB_NAMES = ["saybrook", "holt_saybrook", "akritos", "fbc_dev_db"]; +const SCRATCH_DB_NAME = /(^|_)(seed|demo|scratch|sandbox|sample)(_|$)/i; export class UnsafeSeedTargetError extends Error {} @@ -85,13 +87,14 @@ export function assertSafeSeedTarget(databaseUrl: string, opts: SafetyCheckOptio ); } - if (CONFIRM_BLOCKED_DB_NAMES.includes(dbName) && !opts.forceUnsafe) { + if (!SCRATCH_DB_NAME.test(dbName) && !opts.forceUnsafe) { throw new UnsafeSeedTargetError( `Refusing to seed database "${dbName}" (${masked}) without an explicit override. ` + - `This name holds real dev/restored/seeded data (CLAUDE.md rule 59). If you are ` + - `certain this is the intended target, re-run with --force-unsafe-db or ` + - `HOLT_SEED_FORCE_UNSAFE_DB=1. Otherwise point DATABASE_URL at a scratch database ` + - `created for this run (e.g. holt_seed_demo).`, + `Only a purpose-built scratch database seeds unattended -- one whose name ` + + `contains "seed", "demo", "scratch", "sandbox" or "sample" (e.g. ` + + `holt_seed_demo). Any other name may hold real dev, restored or curated ` + + `data (CLAUDE.md rule 59). If this really is the intended target, re-run ` + + `with --force-unsafe-db or HOLT_SEED_FORCE_UNSAFE_DB=1.`, ); } diff --git a/app/prisma/seed/demo/index.ts b/app/prisma/seed/demo/index.ts index 88664d0d..76cc0c5f 100644 --- a/app/prisma/seed/demo/index.ts +++ b/app/prisma/seed/demo/index.ts @@ -20,9 +20,10 @@ // Reset: --reset (wipes every seeded row and reseeds; refuses to // run at all against an existing dataset without it) // Date anchor: HOLT_SEED_AS_OF=YYYY-MM-DD (default 2026-08-01), or --as-of= -// DB safety: --force-unsafe-db / HOLT_SEED_FORCE_UNSAFE_DB=1 to target -// saybrook / holt_saybrook / akritos / fbc_dev_db. fbc_test_db -// can never be targeted, override or not (rule 59). +// DB safety: only a scratch-named database (holt_seed_demo and the like) +// seeds unattended; anything else needs --force-unsafe-db or +// HOLT_SEED_FORCE_UNSAFE_DB=1. The integration-test database can +// never be targeted, override or not (rule 59). // // Deterministic: every run with the same scale + as-of date produces // byte-identical rows (fixed RNG seed -- see config.ts ROOT_SEED_STRING). diff --git a/app/prisma/seed/demo/staff.ts b/app/prisma/seed/demo/staff.ts index 0cf68eb1..f1c171ed 100644 --- a/app/prisma/seed/demo/staff.ts +++ b/app/prisma/seed/demo/staff.ts @@ -260,7 +260,7 @@ export async function seedStaff( const floorSellerEntries: RosterEntry[] = [ { displayName: "Rosalind Achebe", emailLocal: "floor.apparel1", isActive: true }, { displayName: "Emmett Nakagawa", emailLocal: "floor.apparel2", isActive: true }, - { displayName: "Priscilla Vantongeren", emailLocal: "floor.homeshop1", isActive: true }, + { displayName: "Priscilla Vandervoort", emailLocal: "floor.homeshop1", isActive: true }, { displayName: "Horace Lindenbaum", emailLocal: "floor.homeshop2", isActive: false }, ].map((e, i) => ({ ...e, diff --git a/app/scripts/apply-preset.impl.ts b/app/scripts/apply-preset.impl.ts index d1ac0c66..fcefc36c 100644 --- a/app/scripts/apply-preset.impl.ts +++ b/app/scripts/apply-preset.impl.ts @@ -7,7 +7,7 @@ // // Usage (from app/, via the launcher): // node scripts/apply-preset.mjs # apply all (config/presets + config/local, local wins) -// node scripts/apply-preset.mjs --file config/local/saybrook.yaml +// node scripts/apply-preset.mjs --file config/local/riverbend.yaml // node scripts/apply-preset.mjs --dry-run # print the diff, write nothing // node scripts/apply-preset.mjs --actor you@example.com # recorded on the audit trail // @@ -15,7 +15,7 @@ // was malformed -- a GitOps runner (or a human) should treat this the same // as any other failed deploy step. // -// Data safety (CLAUDE.md rule 59): `saybrook`, `holt_saybrook` and +// Data safety (CLAUDE.md rule 59): a restored or curated database and // `akritos` hold restored/seeded tenant data and must never take a preset // apply by accident -- applying the wrong tenant's config to them is // exactly the kind of "wrong env" typo rule 59 exists to catch. Writing @@ -43,7 +43,7 @@ function printUsage(): void { Applies config presets (config/presets/, config/local/) to the database. Options: - --file Apply only this file, e.g. config/local/saybrook.yaml + --file Apply only this file, e.g. config/local/riverbend.yaml --dry-run Compute and print the diff; write nothing (not even the audit row) --actor Operator email recorded on the audit trail --yes Required to WRITE to any database other than ${SAFE_DEFAULT_DATABASE} @@ -189,7 +189,7 @@ async function main(): Promise { if (!args.dryRun && dbName !== SAFE_DEFAULT_DATABASE && !args.yes) { console.error( `Refusing to write: DATABASE_URL points at "${dbName}", not "${SAFE_DEFAULT_DATABASE}". ` + - "saybrook, holt_saybrook and akritos hold restored/seeded data (CLAUDE.md rule 59) -- " + + "Other databases may hold restored or curated data (CLAUDE.md rule 59) -- " + "pass --yes to confirm this is the database you mean to change, or --dry-run to preview safely.", ); process.exit(1); diff --git a/app/scripts/setup.sh b/app/scripts/setup.sh index e3de7403..5aaebde4 100755 --- a/app/scripts/setup.sh +++ b/app/scripts/setup.sh @@ -57,12 +57,16 @@ fi DB_NAME="${DATABASE_URL##*/}"; DB_NAME="${DB_NAME%%\?*}" echo " database: $DB_NAME" -# The seed refuses a handful of names outright (they hold real or shared data). -# Better to say so here than to let the seed exit 1 after the migrate step. +# This script SEEDS DEMO DATA, so it only ever runs against a database named +# for that purpose. Say so here rather than letting the seed's own guard exit 1 +# after the migrate step has already run. A real deployment names its database +# something else and does not run this script. case "$DB_NAME" in - fbc_test_db|fbc_dev_db|saybrook|holt_saybrook|akritos) - fail "Refusing to set up '$DB_NAME' -- it is a shared or restored database. - Point DATABASE_URL at a fresh database name (env.example uses holt_dev)." ;; + *seed*|*demo*|*scratch*|*sandbox*|*sample*) ;; + *) + fail "Refusing to set up '$DB_NAME' -- this seeds demo data, and that name + does not read as a database created for it. Point DATABASE_URL at one whose + name says so (env.example uses holt_demo)." ;; esac # --- Reachability ---------------------------------------------------------- diff --git a/app/src/app/(dashboard)/app/HomeView.tsx b/app/src/app/(dashboard)/app/HomeView.tsx index 9b566704..d20bf853 100644 --- a/app/src/app/(dashboard)/app/HomeView.tsx +++ b/app/src/app/(dashboard)/app/HomeView.tsx @@ -105,10 +105,10 @@ export function HomeView({ showTraffic, showUpBoard }: HomeViewProps) { }, [todayTraffic, lastYearTraffic]); // One card per STORE, not per counter. A store can have several counted - // doors — Old Saybrook's north and south buildings are two Axper feeds — and + // doors — Old Harbour's north and south buildings are two Axper feeds — and // keying cards on the raw counter label produced one card per door, each // repeating the store's full sales. With three stores and four feeds that - // rendered six cards, three of them titled "Old Saybrook". + // rendered six cards, three of them titled "Old Harbour". // // Resolve every raw label to its StoreLocation first, then dedupe. An // unmapped label resolves to itself, so a newly-installed counter still diff --git a/app/src/lib/adapters/ordorite/runners.ts b/app/src/lib/adapters/ordorite/runners.ts index 0ef79aea..215eda2c 100644 --- a/app/src/lib/adapters/ordorite/runners.ts +++ b/app/src/lib/adapters/ordorite/runners.ts @@ -105,7 +105,7 @@ export interface SalesImportResult { consignmentItemsSynced: number; /** * Base-order line items cancelled by the same-day rewrite cleanup - * (post-failure log 2026-05-12, Cheshire $1,109 delta). Optional — + * (post-failure log 2026-05-12, Brookvale $1,109 delta). Optional — * only set when cleanup actually ran. */ sameDayRewriteLinesCancelled?: number; @@ -671,7 +671,7 @@ export async function runSalesImport( // amount), not the items they DROPPED. The dropped items dangle in the // base as ACTIVE-but-uncanceled lines and double-count daily sales. // - // Worked example: CHOM1726 on 2026-05-09 (Brian Tenerow, Cheshire). + // Worked example: CHOM1726 on 2026-05-09 (Brian Thorne, Brookvale). // Base $4,298 (5 lines) + Return -$3,189 (3 lines) + Rewrite $3,189 // (3 lines) -> naive sum is $4,298 vs. Ordorite's $3,189 (a $1,109 // delta = the 2 lounge chairs + extra delivery line that the customer diff --git a/app/src/lib/config/applyPreset.ts b/app/src/lib/config/applyPreset.ts index 6f83e4e9..3608b565 100644 --- a/app/src/lib/config/applyPreset.ts +++ b/app/src/lib/config/applyPreset.ts @@ -67,7 +67,7 @@ import { invalidateTrafficStoreMap } from "@/lib/trafficStoreMap"; // --------------------------------------------------------------------------- export interface ApplyPresetOpts { - /** Where this apply came from, e.g. "cli:config/local/saybrook.yaml" or + /** Where this apply came from, e.g. "cli:config/local/riverbend.yaml" or * "gui". Stored verbatim on ConfigChangeLog.source. */ source: string; /** Operator email, or null/undefined for an unattended run. */ diff --git a/app/src/lib/config/presetApiTypes.ts b/app/src/lib/config/presetApiTypes.ts index aab7c1f8..2658889d 100644 --- a/app/src/lib/config/presetApiTypes.ts +++ b/app/src/lib/config/presetApiTypes.ts @@ -149,7 +149,7 @@ export interface ChangesResponse { * all, and only for the deletion bookkeeping applyPreset.ts does internally. * So the GUI has to pick ONE stable identity to operate under rather than * inventing a name per edit. "traffic-stores" matches the name both - * config/presets/traffic-stores.yaml and config/local/saybrook.yaml already + * config/presets/traffic-stores.yaml and config/local/riverbend.yaml already * use, which is the common case: a deployment overrides the shipped preset * by reusing its name. A deployment whose local file uses a DIFFERENT name * will have its store rows edited correctly by the GUI (the underlying diff --git a/app/src/lib/config/presetFiles.ts b/app/src/lib/config/presetFiles.ts index b7eb20ed..0b36ba98 100644 --- a/app/src/lib/config/presetFiles.ts +++ b/app/src/lib/config/presetFiles.ts @@ -11,7 +11,7 @@ // database, and is what a fresh clone gets. This is the // white-box product's default configuration. // config/local/ GITIGNORED. A specific deployment's real mappings — -// saybrook.yaml, akritos.yaml. Tenant data, not product +// riverbend.yaml, northgate.yaml. Tenant data, not product // code, and per docs/TENANCY.md it must not travel with // the white box. // $HOLT_CONFIG_DIR Optional override, for a deployment that keeps its @@ -211,8 +211,8 @@ export async function loadAllPresets( * * Accepts BOTH spellings, because both are natural and one of them is what * every doc and error message shows: - * --file config/local/saybrook.yaml (repo-relative, what you'd tab-complete) - * --file local/saybrook.yaml (config-root-relative) + * --file config/local/riverbend.yaml (repo-relative, what you'd tab-complete) + * --file local/riverbend.yaml (config-root-relative) * A leading segment equal to the config root's own directory name is dropped * rather than joined, which otherwise resolves to `config/config/local/...`. * Matching on `basename(root)` keeps this working when HOLT_CONFIG_DIR points diff --git a/app/src/lib/config/presetSchema.ts b/app/src/lib/config/presetSchema.ts index 5a4f71c3..10cf0304 100644 --- a/app/src/lib/config/presetSchema.ts +++ b/app/src/lib/config/presetSchema.ts @@ -464,7 +464,7 @@ function findSecrets(value: unknown, path: string[] = [], inDataNode = false): s * * Accepts either a full bundle or a single bare preset, normalizing the * latter into a one-entry bundle. Both shapes appear in the wild: a bundle - * is what a tenant's `config/local/saybrook.yaml` looks like, a bare preset + * is what a tenant's `config/local/riverbend.yaml` looks like, a bare preset * is what the GUI exports when you hit "export this one." */ export function parsePresetBundle(input: unknown): PresetParseResult { diff --git a/app/src/lib/reports/designerDashboard.ts b/app/src/lib/reports/designerDashboard.ts index e77be11b..fe9e0599 100644 --- a/app/src/lib/reports/designerDashboard.ts +++ b/app/src/lib/reports/designerDashboard.ts @@ -323,7 +323,7 @@ export async function getDesignerDashboard( // Resolve the salesperson to a StaffMember row (incl. aliases) so we can // OR-match across (FK + displayName + every alias). Issue #274 — Sandy's row // has displayName='Sandy' but her POS-imported orders carry - // `salesperson='Sandra Matheny'`; aliases close that gap without renaming the + // `salesperson='Sandra Merrick'`; aliases close that gap without renaming the // up-board record. const staffRecord = await prisma.staffMember.findFirst({ where: { displayName: { equals: salesperson, mode: "insensitive" } }, diff --git a/app/src/lib/reports/salespersonDetail.ts b/app/src/lib/reports/salespersonDetail.ts index f098bddb..33bb6494 100644 --- a/app/src/lib/reports/salespersonDetail.ts +++ b/app/src/lib/reports/salespersonDetail.ts @@ -119,7 +119,7 @@ export async function getSalespersonDetail( const endDate = new Date(Date.UTC(year + 1, 0, 1)); // Resolve staff (incl. aliases) for split attribution + name OR-match. - // Aliases let Sandy's row (`displayName='Sandy'`, alias='Sandra Matheny') + // Aliases let Sandy's row (`displayName='Sandy'`, alias='Sandra Merrick') // match her the POS-imported orders. Issue #274 / ROADMAP Short-Term #12. const staffRecord = await prisma.staffMember.findFirst({ where: { displayName: { equals: salesperson, mode: "insensitive" } }, diff --git a/app/src/lib/runServiceCaseSheetImport.ts b/app/src/lib/runServiceCaseSheetImport.ts index 6dffabbc..5c3125a6 100644 --- a/app/src/lib/runServiceCaseSheetImport.ts +++ b/app/src/lib/runServiceCaseSheetImport.ts @@ -371,7 +371,7 @@ function matchDesigner(name: string | undefined, caches: Caches): number | null // 2. First name (single match wins; multi-match returns null — operator must reclassify) const candidates = caches.staffByFirstName.get(v); if (candidates?.length === 1) return candidates[0]; - // 3. "Kim D" → match "Kim D" alias or "Kim Dransfield" displayName-prefix + // 3. "Kim D" → match "Kim D" alias or "Kim Draycott" displayName-prefix const collapsed = v.replaceAll(/\s+/g, " "); for (const [k, id] of caches.staffByName.entries()) { if (k.startsWith(collapsed) || collapsed.startsWith(k)) return id; diff --git a/app/src/lib/salesBySalesperson.ts b/app/src/lib/salesBySalesperson.ts index 9bc8ffce..e52f191f 100644 --- a/app/src/lib/salesBySalesperson.ts +++ b/app/src/lib/salesBySalesperson.ts @@ -180,7 +180,7 @@ export async function resolveSalesPersonFilter( // applied" (e.g. admin viewing all-up). // Aliases (Issue #274 / ROADMAP Short-Term #12) ensure designers // whose the POS salesperson string differs from their displayName - // (e.g. Sandy ↔ Sandra Matheny) still find their orders. + // (e.g. Sandy ↔ Sandra Merrick) still find their orders. let resolvedNames: string[] = []; if (requestedIds.length > 0) { const staff = await prisma.staffMember.findMany({ @@ -233,7 +233,7 @@ export async function resolveSalesPersonFilter( * * Origin: Issue #274 / ROADMAP Short-Term #12. Sandy's dashboard query * filtered on `displayName='Sandy'` but every imported SalesOrder had - * `salesperson='Sandra Matheny'`. Aliases (`['Sandra Matheny']` on her + * `salesperson='Sandra Merrick'`. Aliases (`['Sandra Merrick']` on her * StaffMember row) close the gap without renaming the up-board record. * * `null` staff is acceptable — returns an empty filter (no-op when fed diff --git a/app/src/pages/api/automations/source-import.ts b/app/src/pages/api/automations/source-import.ts index 9d04aba6..74411a0c 100644 --- a/app/src/pages/api/automations/source-import.ts +++ b/app/src/pages/api/automations/source-import.ts @@ -5,7 +5,7 @@ // Replaces /api/automations/gmail-import, which named ONE adapter's transport // in the URL. Gmail is how Ordorite ships its reports; it is not what an // import is. That route still works and forwards here (see its header) so the -// deployed cron on Saybrook's NAS keeps running -- renaming a URL a cron calls +// deployed cron on the pilot deployment's NAS keeps running -- renaming a URL a cron calls // is how a nightly job dies silently. // // Auth is unchanged: Bearer AUTO_IMPORT_API_KEY for the cron, or an diff --git a/config/presets/README.md b/config/presets/README.md index 87f333d2..6440af85 100644 --- a/config/presets/README.md +++ b/config/presets/README.md @@ -22,7 +22,7 @@ in a mapping file are usually worth having. | Directory | Committed? | What it holds | |---|---|---| | `config/presets/` | **yes** | The white-box defaults. Tuned to the seed database, so a fresh clone works out of the box. | -| `config/local/` | **no** (gitignored) | One specific deployment's real mappings — `saybrook.yaml`, `akritos.yaml`. Tenant data, not product code. | +| `config/local/` | **no** (gitignored) | One specific deployment's real mappings — `riverbend.yaml`, `northgate.yaml`. Tenant data, not product code. | | `$HOLT_CONFIG_DIR` | n/a | Optional override for a deployment that keeps config in its own private repo or a mounted volume. | On a `(kind, name)` collision, **local wins over shipped** — a deployment can @@ -35,7 +35,7 @@ exactly the surprise worth printing. ```bash node app/scripts/apply-preset.mjs --dry-run # show the diff, write nothing node app/scripts/apply-preset.mjs # apply every preset -node app/scripts/apply-preset.mjs --file config/local/saybrook.yaml +node app/scripts/apply-preset.mjs --file config/local/riverbend.yaml ``` Apply is **idempotent** and **declarative**: running it twice changes nothing diff --git a/docs/DEPLOYMENTS.md b/docs/DEPLOYMENTS.md index ffff5f60..e49253e6 100644 --- a/docs/DEPLOYMENTS.md +++ b/docs/DEPLOYMENTS.md @@ -53,7 +53,7 @@ core repo stays a single product so improvements flow to every deployment. preserved from the live site so indexing carries over — and it must ride every environment move (it is data, not code; verified intact after the 2026-06-10 environment rename). -- **Saybrook** (retail edition) — a retail feature preset (POS, inventory, +- **Riverbend** (retail edition) — a retail feature preset (POS, inventory, warehousing, dispatch, purchasing, commissions) plus the **Ordorite adapter**: a self-contained import package (report runners, status derivation, rewrite / dedup quirk handling) that translates Ordorite's daily exports into Holt's @@ -107,7 +107,7 @@ The kit is the BRAND AND CONTENT half and stays gitignored on purpose longer lives in it — `config/local/akritos.json` is in the repo and carries Akritos's import definitions and feature configuration through the preset system, applied with the same idempotent `apply` CLI every other deployment -uses. It is deliberately JSON where `config/local/saybrook.yaml` is YAML: the two +uses. It is deliberately JSON where `config/local/riverbend.yaml` is YAML: the two formats are interchangeable and keeping one of each in regular use is what keeps that claim honest. diff --git a/docs/PRODUCTION.md b/docs/PRODUCTION.md index e44a5d14..39fb5108 100644 --- a/docs/PRODUCTION.md +++ b/docs/PRODUCTION.md @@ -1,7 +1,7 @@ # Production go-live checklist The single source of truth for taking a Holt deployment live (Akritos on a -VPS, Saybrook on the Synology, or any client). Grouped by what the CODE now +VPS, the pilot deployment on a Synology, or any client). Grouped by what the CODE now handles automatically vs what YOU do at deploy time. Derived from the 2026-06-10 production-readiness audit. @@ -101,10 +101,10 @@ Bearer `AUTO_IMPORT_API_KEY`. The cron wrapper (PR2) alerts on failure. invoice / booking confirmation / ticket reply / password-reset email silently never sends.** Every ~5 min. - ☐ `auto-customer-ar-drift-check.sh` — nightly; flags books that don't tie out. -- ☐ `auto-daily-reconciliation.sh` — nightly (Saybrook). +- ☐ `auto-daily-reconciliation.sh` — nightly (retail edition). - ☐ `auto-lead-housekeeping.sh`, `auto-mailchimp-sync.sh`, `auto-customer-level-recalc.sh`, `auto-axper-traffic.sh` — per their cadence. -- ☐ Saybrook only: `auto-import.sh` (06:10) for the daily Ordorite reports + +- ☐ Retail edition only: `auto-import.sh` (06:10) for the daily Ordorite reports + the Gmail service-account JSON mounted at `config/service-account.json`. ## 5. Observability & alerting @@ -141,7 +141,7 @@ Bearer `AUTO_IMPORT_API_KEY`. The cron wrapper (PR2) alerts on failure. - ☐ Apply migrations (`scripts/deploy.sh` does this). - ☐ `npm run create-admin ` for the first SUPER_ADMIN. -- ☐ Akritos: `node scripts/seed-akritos.mjs` (brand + 27 CMS URLs). Saybrook: +- ☐ Akritos: `node scripts/seed-akritos.mjs` (brand + 27 CMS URLs). Retail edition: restore the prod backup + run the Ordorite import. - ☐ In Settings: set name/logo/theme, currency/locale/timezone, and toggle the feature modules this tenant uses (billing, clientPortal, legacyArchive, diff --git a/docs/RULE-PROVENANCE.md b/docs/RULE-PROVENANCE.md index 578606df..f8fc4f8e 100644 --- a/docs/RULE-PROVENANCE.md +++ b/docs/RULE-PROVENANCE.md @@ -88,5 +88,5 @@ Each came from a specific failure in one working session. | 56 | The accounting gap table listed C1 daily reconciliation as "next" when `lib/dailyReconciliation.ts` had long since shipped. | | 57 | `stripeLedgerWiring.test.ts` asserted against source text and went stale the moment payments moved behind a provider seam. It was replaced with behavioural tests against a fake provider. | | 58 | The TLS/certbot configuration could not be exercised without a real domain and certificate. The PR said so rather than implying it was tested. | -| 59 | Local databases `saybrook`, `holt_saybrook` and `akritos` hold restored production-shaped and seeded data alongside the test database. | +| 59 | Local databases outside the seed allowlist hold restored production-shaped and seeded data alongside the test database. | | 60 | The payment-provider seam: an organization switching processors must still refund historical payments through the processor that captured them. | diff --git a/docs/TENANCY.md b/docs/TENANCY.md index a318ac0b..87bcb246 100644 --- a/docs/TENANCY.md +++ b/docs/TENANCY.md @@ -31,7 +31,7 @@ newer modules. Tenant configuration is not product code, and does not travel with the repo: - `config/local/` is gitignored. A deployment's real store names, traffic - counter labels and vendor payment codes live there (`saybrook.yaml`, + counter labels and vendor payment codes live there (`riverbend.yaml`, `akritos.json`). `config/presets/` — the committed set — holds only defaults tuned to the demo seed, so a fresh clone works without carrying anyone's data. `config/example.yaml` is committed as a template — at config root, diff --git a/docs/domains/accounting.md b/docs/domains/accounting.md index d4ea4c15..048b2ee0 100644 --- a/docs/domains/accounting.md +++ b/docs/domains/accounting.md @@ -95,7 +95,7 @@ vocabulary drifts from its GL mapping loses money from the books quietly. That drift is the normal case after an import, not an edge case: the mapping labels are authored by hand while `paymentType` arrives verbatim from the source -system. On the restored Saybrook dataset, 43,139 of 47,880 payments — a net +system. On the restored pilot dataset, 43,139 of 47,880 payments — a net $31.7M across 12 tender types — had no mapping row, while six configured labels (Visa, MC, Discover, AMEX, On Account, Deposit) matched no payment at all. The dominant real value, `Card Connect`, mapped to nothing. @@ -109,7 +109,7 @@ from a tender string is how money lands in the wrong account. ### `TaxDistrict` -Has its own `glAccountId` field. `2-2120` for CT in the Saybrook chart. New districts get +Has its own `glAccountId` field. `2-2120` for CT in the pilot chart of accounts. New districts get their own account when added. **How a rate is chosen.** Never from a literal, and never from the client. diff --git a/docs/domains/config-presets.md b/docs/domains/config-presets.md index 52909be0..4380adff 100644 --- a/docs/domains/config-presets.md +++ b/docs/domains/config-presets.md @@ -64,7 +64,7 @@ ignore the GUI; a shop with no engineers can ignore the files. | Directory | Committed | Purpose | | ------------------ | ------------------- | --------------------------------------------------------------------------------- | | `config/presets/` | **yes** | White-box defaults, tuned to the demo seed so a fresh clone works out of the box. | -| `config/local/` | **no** (gitignored) | One deployment's real mappings — `saybrook.yaml`, `akritos.json`. | +| `config/local/` | **no** (gitignored) | One deployment's real mappings — `riverbend.yaml`, `northgate.json`. | | `$HOLT_CONFIG_DIR` | n/a | Override for config kept in a private repo or a mounted volume. | `config/local/` is gitignored because a tenant's store names and vendor payment @@ -123,7 +123,7 @@ Parser safety is not left to defaults: ```bash node app/scripts/apply-preset.mjs --dry-run # show the diff, write nothing node app/scripts/apply-preset.mjs # apply everything -node app/scripts/apply-preset.mjs --file config/local/saybrook.yaml +node app/scripts/apply-preset.mjs --file config/local/riverbend.yaml ``` Two properties matter more than the rest: @@ -154,7 +154,7 @@ cannot leave a definition with half its mappings updated. The script prints the target database **name** before writing (never the password), and refuses to write to anything other than `fbc_dev_db` without an explicit `--yes`. Applying tenant config to the wrong database is the obvious -foot-gun, and `saybrook` / `holt_saybrook` / `akritos` hold restored data +foot-gun, and databases outside the seed allowlist may hold restored data (CLAUDE.md rule 59). ## In Docker @@ -195,7 +195,7 @@ Two layers: - **`ConfigChangeLog`** (durable) — one row per preset applied, from either door: `presetKind`, `presetName`, `action` (`APPLIED` / `UNCHANGED` / - `FAILED`), `source` (`cli:config/local/saybrook.yaml` or `gui`), `summary` + `FAILED`), `source` (`cli:config/local/riverbend.yaml` or `gui`), `summary` (counts plus what moved), `actor`, `created`. Append-only by convention. Deliberately records `UNCHANGED` and `FAILED` too — "we tried and it was already right" and "we tried and it broke" are both things you want in the @@ -243,7 +243,7 @@ literals in `lib/storeColors.ts`. The counter, the POS, and holt rarely agree on what a store is called, and one store can own several counter labels — two co-located buildings counted -separately still roll up to one store. Saybrook's real data is exactly this +separately still roll up to one store. the pilot deployment's real data is exactly this shape: `NB` and `SB` are two doors of one showroom, and reading either alone computes conversion against half the store's traffic. diff --git a/docs/domains/import-pipeline.md b/docs/domains/import-pipeline.md index 1f078887..c2504a50 100644 --- a/docs/domains/import-pipeline.md +++ b/docs/domains/import-pipeline.md @@ -96,7 +96,7 @@ The one place this goes wrong is **payments**. the POS's payment CSV includes a **`runPaymentsImport` skips that phantom row.** Detection: `isRewriteOrder(orderno)` + `paymentType === "Gift Card"` + no gift-card barcode/code. Real POS gift-card redemptions always carry a barcode or code, so they are unaffected. The `phantomTransfersSkipped` counter on the result surfaces how many were skipped per import. -**Worked example** (from PO 5733 Cheshire data, 2026-04-22 investigation): +**Worked example** (from PO 5733 Brookvale data, 2026-04-22 investigation): ``` SO-1652 base, 2026-04-19, total $8,159.00 @@ -112,7 +112,7 @@ Customer balance over the chain: balance = $3,470.01 (owed by customer) ``` -Daily sales by store (Cheshire): +Daily sales by store (Brookvale): - 2026-04-19: +$8,159 (base contributes its full amount) - 2026-04-22: −$8,159 (return) + $7,809.01 (rewrite) = −$349.99 delta on this date @@ -125,7 +125,7 @@ This matches the POS's own "Sales by Store" report. **Don't try to `status = CAN The "all three stay ACTIVE, daily sales reconcile naturally" rule is true for cross-day rewrites. **Same-day rewrites have a quirk**: when the customer modifies an order before close-of-business, the POS's accounting return only credits items the customer KEPT, not items they DROPPED. The dropped items dangle in the base as `lineItemStatus = ACTIVE` with no offset, and double-count daily sales. -**Worked example** (SO-1726, Cheshire, Brian Tenerow, 2026-05-09): +**Worked example** (SO-1726, Brookvale, Brian Thorne, 2026-05-09): | Order | Lines | Net | | --------------------- | ------------------------------------------------------ | ------- | @@ -133,7 +133,7 @@ The "all three stay ACTIVE, daily sales reconcile naturally" rule is true for cr | `SR-010045` return | 3 (cushion×-3, sofa×-1, delivery×-1) | -$3,189 | | `SO-1726 - A` rewrite | 3 (cushion×3, sofa×1, delivery×1) | $3,189 | -Naive sum: `4298 + (-3189) + 3189 = 4298`. Cheshire 5/9 total: $4,298 (base) + $117 (three cash sales) = **$4,415**. +Naive sum: `4298 + (-3189) + 3189 = 4298`. Brookvale 5/9 total: $4,298 (base) + $117 (three cash sales) = **$4,415**. the POS shows: rewrite only, $3,189 + $117 = **$3,306**. diff --git a/docs/domains/imports-overview.md b/docs/domains/imports-overview.md index 3773674d..6f67aeab 100644 --- a/docs/domains/imports-overview.md +++ b/docs/domains/imports-overview.md @@ -6,7 +6,7 @@ Everything that flows data INTO our DB from the POS (or any other source). Read > **Ordorite adapter** — a self-contained edition module under > `app/src/lib/adapters/ordorite/` (gmailClient, reportRouter, shared helpers, > all 13 runners, sameDayRewriteCleanup, emptyReport, orchestrator), gated by -> the `legacyPosImport` feature flag (default OFF; the Saybrook edition turns it +> the `legacyPosImport` feature flag (default OFF; the retail edition turns it > on). Source-agnostic pieces stay in core: `lib/importHelpers.ts` (coercion + > `findOrCreateCustomer` on `CustomerExternalId`), `lib/storeLocationResolver.ts`, > `lib/orderLineItemLinker.ts`, `lib/salesPersonFkBackfill.ts`, pay-period lock diff --git a/docs/domains/inventory.md b/docs/domains/inventory.md index 1538f94b..b94b7a71 100644 --- a/docs/domains/inventory.md +++ b/docs/domains/inventory.md @@ -53,7 +53,7 @@ floor-vs-`Cust Stock` split, so the two can't drift. **This was a hardcoded string until 2026-08.** Both `allocation.ts` and `buyersReport.ts` tested `StockLocation.name ILIKE 'customer%'` — an -Ordorite/Saybrook naming convention living in shared inventory code, so any +one deployment's naming convention living in shared inventory code, so any deployment that named its holding locations differently silently counted committed stock as available to sell. Migration `20260806163000_stock_location_holds_committed_stock` added the flag and diff --git a/docs/domains/legacy-archive.md b/docs/domains/legacy-archive.md index e1b22525..d717407e 100644 --- a/docs/domains/legacy-archive.md +++ b/docs/domains/legacy-archive.md @@ -73,7 +73,7 @@ Example mapping (a POSIM-style export): ``` Editions note (docs/DEPLOYMENTS.md): the mapping config + source dump live -in the deployment layer, never in this repo. The Saybrook deployment's +in the deployment layer, never in this repo. The pilot deployment's POSIM config is edition material. ## Tests diff --git a/docs/domains/sales-orders.md b/docs/domains/sales-orders.md index 6033cec8..e36c4dc8 100644 --- a/docs/domains/sales-orders.md +++ b/docs/domains/sales-orders.md @@ -10,10 +10,10 @@ The order number encodes the store and transaction type: | ------ | -------------------- | ------ | ------------------------------------------------------------------------ | | SB | Main Store | OM | Merchandise sale | | SB | Main Store | OA | Return/credit | -| GT | Glastonbury | OM | Merchandise sale | -| GT | Glastonbury | OA | Return/credit | -| CH | Cheshire | OM | Merchandise sale | -| CH | Cheshire | OA | Return/credit | +| GT | Wexbridge | OM | Merchandise sale | +| GT | Wexbridge | OA | Return/credit | +| CH | Brookvale | OM | Merchandise sale | +| CH | Brookvale | OA | Return/credit | | BB | Business to Business | OM | Merchandise sale | | WS | Web Sales | OM | Merchandise sale | | RS | Returns store | -- | Small set, 13 orders. RS-prefix detected as returns by `isReturnOrder()` | diff --git a/docs/domains/seed-data.md b/docs/domains/seed-data.md index 235e58e8..0a63c327 100644 --- a/docs/domains/seed-data.md +++ b/docs/domains/seed-data.md @@ -86,7 +86,7 @@ did not include it. ### Deriving realistic data without copying any -The restored databases (`holt_saybrook`, `saybrook`) are the reference for what +The restored databases are the reference for what real usage looks like. **Read them; never write them** (CLAUDE.md rule 59), and **never copy a row into the seed** — holt is a public repository, and the restored data is a real business's customers, prices and payroll. @@ -376,18 +376,25 @@ yet. ## Target-database safety (rule 59) -CLAUDE.md rule 59: *"`fbc_test_db` is the only database tests may write. `saybrook`, -`holt_saybrook`, and `akritos` hold restored or seeded data and must never be written by -a test or script."* This seed writes thousands of rows outside a transaction — more -dangerous than a test run against the wrong database, since there's no -TRUNCATE-and-retry safety net. `guard.ts`'s `assertSafeSeedTarget()` enforces: +CLAUDE.md rule 59: the test database is the only one tests may write, and this seed +writes only a database named for being seeded. The seed writes thousands of rows +outside a transaction — more dangerous than a test run against the wrong database, +since there's no TRUNCATE-and-retry safety net. `guard.ts`'s `assertSafeSeedTarget()` +enforces: - **Hard-blocked, no override, ever:** `fbc_test_db` — owned exclusively by the Jest integration harness (`jest.integration.setup.ts`); seeding into it would corrupt every integration test run until someone noticed. -- **Blocked unless `--force-unsafe-db` / `HOLT_SEED_FORCE_UNSAFE_DB=1`:** `saybrook`, - `holt_saybrook`, `akritos`, `fbc_dev_db` — real dev/restored/curated data. -- Anything else (e.g. a scratch database like `holt_seed_demo`) is allowed. +- **Allowed unattended:** a name containing `seed`, `demo`, `scratch`, `sandbox` or + `sample` (`holt_demo` — the `env.example` default — `holt_seed_demo`, and the like). +- **Everything else needs `--force-unsafe-db` / `HOLT_SEED_FORCE_UNSAFE_DB=1`**, on the + assumption it holds real dev, restored or curated data. + +This is an allowlist on purpose. It replaced a blocklist of specific database names, +which failed open: a name nobody had listed seeded silently, and the list only ever +grew after someone lost data. `__tests__/seedTargetGuard.test.ts` covers it — the +cases that matter most are the unfamiliar names, refused precisely because nobody +thought of them. The guard runs before the shared `@/lib/prisma` singleton is even imported, so a refused target never gets so much as a connection pool constructed against it. diff --git a/docs/tenant-literal-sweep.md b/docs/tenant-literal-sweep.md index ffc19a8c..cb050123 100644 --- a/docs/tenant-literal-sweep.md +++ b/docs/tenant-literal-sweep.md @@ -283,7 +283,7 @@ export const DENOMINATIONS: DenominationDef[] = [ ### `app/src/lib/adapters/ordorite/reportRouter.ts:74` ``` -pattern: /Saybrook_Home_Inbound_Items/i, +pattern: /_Inbound_Items/i, ``` @@ -371,7 +371,7 @@ if (s === "" || s === "@") return undefined; ### `app/src/lib/pricing/beatrizBallOrderParser.ts:64` ``` -"sold to", "ship to", "saybrook", "old saybrook", +"sold to", "ship to", "", "", ``` @@ -623,7 +623,7 @@ pattern: /SH_Stock_by_Item/i, **Breaks:** A deployment whose Ordorite exports carry its own initials matches none of the three; resolveImportRoute returns null and the orchestrator logs 'skipped'. Stock-on-hand, PO-line reconciliation and the product catalog never populate, with no error surfaced. -**Fix:** Same fix as the Saybrook_Home routes — one preset-driven route table keyed pattern -> runnerKey. Do not fix by loosening the regex; the whole table is deployment data. +**Fix:** Same fix as the routes — one preset-driven route table keyed pattern -> runnerKey. Do not fix by loosening the regex; the whole table is deployment data. ### `app/src/lib/axperClient.ts:43` @@ -832,7 +832,7 @@ Persisted onto every `OrderLineItem`, so it flows into journal-entry tax lines, **Fix.** No new config. Import `resolveTaxDistrict` + `rateForLineAmount` from `@/lib/tax/resolveTaxRate`; resolve once per proposal (customer district → store district → `AppSettings.defaultTaxDistrictId`), call `rateForLineAmount` per line, persist the resolved `taxDistrictId` on the order. Copy the call shape from `app/src/pages/api/sales/orders/create-from-cart.ts:123` and `:252`. Delete both literals. -**Test (equivalence).** Convert every proposal converted in the last 12 months on a restored snapshot, old code vs new. Compare per-line `vatRate`, `vatAmount`, and order-level tax total. On the Saybrook district all must be byte-identical; any diff is a real pre-existing bug in the band logic and must be explained before merge. Then flip `defaultTaxDistrictId` to a non-CT district in a scratch DB and assert the rate changes. +**Test (equivalence).** Convert every proposal converted in the last 12 months on a restored snapshot, old code vs new. Compare per-line `vatRate`, `vatAmount`, and order-level tax total. On the the pilot deployment district all must be byte-identical; any diff is a real pre-existing bug in the band logic and must be explained before merge. Then flip `defaultTaxDistrictId` to a non-CT district in a scratch DB and assert the rate changes. **Why first.** Largest money error per event, one file, config already exists and is simply unread. Zero coupling to anything else on this list. @@ -844,13 +844,13 @@ Persisted onto every `OrderLineItem`, so it flows into journal-entry tax lines, ``` const RETURN_STORE_SUFFIX = /^(SB|GT|CH|BB|WS|RS)[A-Z]*A\d/i; ... -if (/^RS\d/i.test(orderno)) return true; // "RS-prefixed orders are Returns Saybrook" +if (/^RS\d/i.test(orderno)) return true; // "RS-prefixed orders are a return booked to a store" ``` Another tenant's store codes match nothing → `isReturnOrder` false → every accounting return imports as a normal sale. Revenue, commissions and the sales journal overstate by **twice** the return volume (return not subtracted, and counted as a sale). Line 210 is wrong in both directions: an `RS` series that is not a return gets dropped out of revenue. -**Fix.** Keep the *shape* in code — the trailing `A` on the store code is the Ordorite convention, that's logic. Take the token alphabet from data: build the alternation from `StoreLocation` codes at call time, or add an `order-number-conventions` preset carrying `returnStoreCodes: []` and `returnPrefixes: []`. Fold `RS` into `returnPrefixes` and delete line 210 so there is exactly one source of return-prefix tokens. Ship Saybrook's current six as the default preset value. +**Fix.** Keep the *shape* in code — the trailing `A` on the store code is the Ordorite convention, that's logic. Take the token alphabet from data: build the alternation from `StoreLocation` codes at call time, or add an `order-number-conventions` preset carrying `returnStoreCodes: []` and `returnPrefixes: []`. Fold `RS` into `returnPrefixes` and delete line 210 so there is exactly one source of return-prefix tokens. Ship the pilot deployment's current six as the default preset value. **Test (equivalence).** Run `isReturnOrder` over every distinct `SalesOrder.orderno` in production (single query, no import needed) old vs new — the classified sets must be identical. Then re-run the last 90 days of Ordorite imports into a scratch DB and compare daily net sales by store and the sales-journal debit/credit totals. Add a unit case for `DALA0123` proving it classifies as a return once `returnStoreCodes` includes `DAL`. @@ -908,8 +908,8 @@ An `updateMany` driven by a string shape. Any style under that vendor whose numb --- -### 7. Ordorite report routing table is Saybrook filenames — NEW, data-correctness + feature-dead -`app/src/lib/adapters/ordorite/reportRouter.ts:74` — `pattern: /Saybrook_Home_Inbound_Items/i` +### 7. Ordorite report routing table is the pilot deployment filenames — NEW, data-correctness + feature-dead +`app/src/lib/adapters/ordorite/reportRouter.ts:74` — `pattern: /_Inbound_Items/i` `app/src/lib/adapters/ordorite/reportRouter.ts:49` — `pattern: /SH_Stock_by_Item/i` `app/src/lib/adapters/ordorite/reportRouter.ts:125` — `/Marjan_Daily_Sales/i` in `SKIP_PATTERNS` (cosmetic; free once the table moves) @@ -919,7 +919,7 @@ An `updateMany` driven by a string shape. Any style under that vendor whose numb **Test (equivalence).** Replay the last 90 days of received Ordorite filenames through `resolveImportRoute` old vs new; the (filename → runner | skip | null) mapping must be identical for all of them. Add a case asserting `Acme_Furniture_Inbound_Items` routes to the inbound-items runner under an overridden preset, and one asserting an unmatched filename returns a *loud* unknown rather than a silent skip. -**Blocks second-tenant onboarding.** Nothing else can be verified end-to-end on a non-Saybrook export until this lands. +**Blocks second-tenant onboarding.** Nothing else can be verified end-to-end on a second-tenant export until this lands. --- @@ -941,7 +941,7 @@ On a fresh deployment with no assigned plan, no `isDefault` plan and an empty `C **Fix.** Make the fallback refuse rather than guess: `loadLegacyOrDefaultTiers()` returns an unconfigured result that payout preview and commit surface as a hard "no commission plan configured" error. Move the 3–7% ladder into a shippable preset row (lib/config presets already seed DB state). -**Test (equivalence).** With Saybrook's tiers present in the DB, run the payout preview for the last 6 pay periods before and after — per-designer commission dollars identical. Then empty `CommissionTier` on a scratch DB and assert preview *errors* instead of returning numbers. +**Test (equivalence).** With the pilot deployment's tiers present in the DB, run the payout preview for the last 6 pay periods before and after — per-designer commission dollars identical. Then empty `CommissionTier` on a scratch DB and assert preview *errors* instead of returning numbers. --- @@ -980,7 +980,7 @@ Importing a price list silently forks the tenant's taxonomy. A retailer filing o **Fix.** These three importers take `vendorId` / `departmentId` / `categoryId` (and for HD, `laborProductId` / `freightProductId`) as required inputs, resolved from an `ImportDefinition` preset row or chosen by the operator in the import UI, defaulted from the vendor's configured default department. They create **nothing** they were not told to create. Missing referenced records → a clear config error, not an invention. HD's order-number prefix comes from `Vendor.code`, not the literal `HD-`. -**Test.** Idempotency: run each importer twice against the same file on a snapshot and assert zero new `Vendor`/`Department`/`Category`/`Product` rows on the second run *and* zero on the first when the ids are supplied. Equivalence: with Saybrook's real ids passed in, diff the resulting `Product` rows (department, category, vendor, prices) against a pre-change run — identical. Assert a 400 with a named config error when an id is absent. +**Test.** Idempotency: run each importer twice against the same file on a snapshot and assert zero new `Vendor`/`Department`/`Category`/`Product` rows on the second run *and* zero on the first when the ids are supplied. Equivalence: with the pilot deployment's real ids passed in, diff the resulting `Product` rows (department, category, vendor, prices) against a pre-change run — identical. Assert a 400 with a named config error when an id is absent. --- @@ -1005,7 +1005,7 @@ Per the in-code note: another chart of accounts falls to the generic else branch Freight: a retailer whose products are named "Shipping"/"Delivery Fee" gets zero matches, so freight dollars stay in the commission base and **commission is computed on inflated revenue**. House calls: another consultation SKU never matches, so the entire House Calls panel — count, MTD/YTD/prior-year, attributed follow-up revenue over the −30/+90 window, conversion vs the $1,000 threshold — reads zero permanently, with no empty state distinguishing misconfigured from genuinely zero. -**Fix.** One migration, two flags on `Product`: `excludeFromSalesCredit Boolean @default(false)` and `isHouseCallCharge Boolean @default(false)` (or one reserved `ProductRole` table with SALES_CREDIT_EXCLUDED / HOUSE_CALL rows). Set from Admin; a preset can name Saybrook's existing products. Reports query the flag. `designerDashboard` renders "no house-call product configured" instead of a silent zero. +**Fix.** One migration, two flags on `Product`: `excludeFromSalesCredit Boolean @default(false)` and `isHouseCallCharge Boolean @default(false)` (or one reserved `ProductRole` table with SALES_CREDIT_EXCLUDED / HOUSE_CALL rows). Set from Admin; a preset can name the pilot deployment's existing products. Reports query the flag. `designerDashboard` renders "no house-call product configured" instead of a silent zero. **Test (equivalence).** Backfill the flags to exactly the products the literals match today, then diff: per-designer credited revenue per month for 24 months (must be identical to the cent), and every House Calls tile value for the same window. Then unset the house-call flag and assert the panel renders the configured-nothing state, not zeros. @@ -1128,7 +1128,7 @@ The pricing-import UI is driven entirely off that array: a dealer carrying eight **Fix.** Bind on `Vendor.id`, never a name substring. Per-vendor import facts (which shipped parser, which endpoint, label) move onto the Vendor row or a `VendorImportFormat` table seeded by a preset; the view renders whatever the retailer's vendors declare. Drop `defaultPriceListName` and default the field to `' '` computed at open time. For product entry: `Vendor.defaultDepartmentId` for department, the existing `Vendor.code`/alias field for POS supplier spelling, and a `vendor-taxonomy` preset of (vendor, collection) → category rows, resolved to real Department/Category ids at apply time. Keep `PRODUCT_CATEGORY_KEYWORDS` and `TYPE_KEYWORDS` in code — that is furniture vocabulary, not one tenant's roster. Configurator: delete the literal from all three screens; select the sole vendor when there is exactly one, otherwise leave unselected and render the existing empty-state prompt (a real default belongs on a `Vendor.isDefaultForPricing` flag or the user's last selection). -**Test.** Equivalence on suggestions: run the product-entry suggester over every existing `Product` and diff suggested (department, category, type) before vs after with Saybrook's mappings loaded as preset data — identical. UI: snapshot the import view's vendor list with Saybrook's vendors (unchanged), then with a fabricated eight-vendor tenant and assert exactly those eight render. Configurator: assert all three screens land in the same state for a zero-match tenant. +**Test.** Equivalence on suggestions: run the product-entry suggester over every existing `Product` and diff suggested (department, category, type) before vs after with the pilot deployment's mappings loaded as preset data — identical. UI: snapshot the import view's vendor list with the pilot deployment's vendors (unchanged), then with a fabricated eight-vendor tenant and assert exactly those eight render. Configurator: assert all three screens land in the same state for a zero-match tenant. --- @@ -1137,7 +1137,7 @@ The pricing-import UI is driven entirely off that array: a dealer carrying eight `app/src/lib/apparelOrderVendors.ts:168` — `partNumberPrefix: "HBEL"` with a comment saying it was carried over verbatim because "holt has no Vendor.partNumberPrefix column" `app/src/lib/homeAccessoryOrders.ts:85` — `HOME_ACCESSORY_FORMATS` with `catalogVendorName: "K & K Interiors"`, `"Wendover Art Group"`, … `app/src/lib/homeAccessoryOrders.ts:297` — `SPLIT_PRESETS = { 2: [{ label: "62 / 38", percents: [62,38] }, …] }` -`app/src/lib/pricing/beatrizBallOrderParser.ts:64` — `"sold to", "ship to", "saybrook", "old saybrook"` +`app/src/lib/pricing/beatrizBallOrderParser.ts:64` — `"sold to", "ship to", "", ""` Part numbers: a brand absent from the registry falls to the unprefixed path, so **the same physical product yields two different part numbers depending on which document format it arrived in**; `extractSizeAndColor` (line 316) then fails to round-trip because `partNumber.startsWith(head)` is false, and a reorder of an existing style creates duplicate draft items instead of matching. `catalogVendorName` prefills a vendor name that doesn't exist in another tenant's Vendor table, producing draft POs attached to an invented or wrong vendor; outside these eight, the Home Accessory Order Import tool is dead. `SPLIT_PRESETS` is money: accepting the prefill books 62/38 of a two-piece set price onto `BuyerDraftItem.costPerUnit`, which flows into margin and buy-performance math. The Beatriz Ball parser enumerates the buyer's own town, so another tenant's ship-to lines are read as item rows — phantom line items, corrupted descriptions, and the printed-total reconciliation warning firing on every file. @@ -1169,7 +1169,7 @@ An operator who fills in "API Base URL" sees it **silently ignored**; a regional **Fix.** Two nullable fields on the existing Google `IntegrationCredential` (or AppSettings): `googleProjectRootFolderId`, `googlePresentationTemplateId`, set in Admin. Return **400 with "Google project folders are not configured"** when either is absent, instead of throwing a Drive error. Add a `projectFolderSubfolders` string array (defaulted in code to the current list) plus a separate `presentationSubfolder` field naming which one receives the template copy, so the load-bearing folder is selected by config rather than matched against a magic literal. -**Test.** With the current ids configured, create a project folder on the real Saybrook Drive and assert the resulting tree (six subfolders, template copied into the named one) is identical to today. Unconfigured: assert 400 with the config message and no Drive call made. +**Test.** With the current ids configured, create a project folder on the real the pilot deployment Drive and assert the resulting tree (six subfolders, template copied into the named one) is identical to today. Unconfigured: assert 400 with the config message and no Drive call made. --- diff --git a/env.example b/env.example index 215edf27..8ad61c2c 100644 --- a/env.example +++ b/env.example @@ -13,10 +13,10 @@ # (prisma/seed/demo/guard.ts) because it is the shared database every local # ~/holt session points at -- seeding it would clobber real work. A fresh # clone gets its own name so `npm run setup` just works. -DATABASE_URL=postgresql://dbuser_fbc:your-password-here@db:5432/holt_dev?connection_limit=20&pool_timeout=10 +DATABASE_URL=postgresql://dbuser_fbc:your-password-here@db:5432/holt_demo?connection_limit=20&pool_timeout=10 POSTGRES_USER=dbuser_fbc POSTGRES_PASSWORD=your-password-here -POSTGRES_DB=holt_dev +POSTGRES_DB=holt_demo # --- app/.env.local --- # Copy app/.env.local.example to app/.env.local. Two of its values are diff --git a/scripts/restore-drill.sh b/scripts/restore-drill.sh index c1773a6f..2e7e9244 100755 --- a/scripts/restore-drill.sh +++ b/scripts/restore-drill.sh @@ -120,10 +120,9 @@ cleanup() { # (primary) and an explicit deny-list of known real/reserved names # (belt-and-suspenders, in case the prefix check is ever loosened without # someone re-deriving this list). Per CLAUDE.md rule 59 and this repo's own -# data-safety convention: saybrook, holt_saybrook, and akritos hold restored -# or seeded data and must never be written by a script; fbc_dev_db is the -# live local dev database; fbc_test_db is reserved for the Jest suite. None -# of them are acceptable restore-drill targets. +# data-safety convention: any database holding restored, seeded or live local +# data must never be written by a script, and the Jest suite's database is +# reserved for it. None are acceptable restore-drill targets. # --------------------------------------------------------------------------- case "$DRILL_DB_NAME" in holt_restore_drill*) ;; @@ -135,7 +134,7 @@ case "$DRILL_DB_NAME" in exit 1 ;; esac -for forbidden in saybrook holt_saybrook akritos fbc_dev_db fbc_test_db postgres template0 template1; do +for forbidden in postgres template0 template1; do drill_lc=$(printf '%s' "$DRILL_DB_NAME" | tr '[:upper:]' '[:lower:]') if [ "$drill_lc" = "$forbidden" ]; then echo "ERROR: refusing to target '$DRILL_DB_NAME' -- it matches a real or" >&2 From 8a2e92b9fe14eea4c017877120124dd5ca5df2d2 Mon Sep 17 00:00:00 2001 From: goetchstone Date: Wed, 26 Aug 2026 09:00:48 -0400 Subject: [PATCH 04/11] fix(tests): align two fixtures the name scrub left half-renamed Both were case or partial variants the replacement missed, and both failed only in the integration suite: - salesPersonFkBackfill asserted a case-insensitive match between a staff displayName and a differently-cased salesperson string; renaming the displayName alone made the pair stop matching, which is what the test is for. - runSalesImport asserted the surname the CSV fixture no longer carried. Full integration suite passes. --- app/__tests__/integration/runSalesImport.integration.test.ts | 2 +- .../integration/salesPersonFkBackfill.integration.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/__tests__/integration/runSalesImport.integration.test.ts b/app/__tests__/integration/runSalesImport.integration.test.ts index d20f253c..ab5ec7c2 100644 --- a/app/__tests__/integration/runSalesImport.integration.test.ts +++ b/app/__tests__/integration/runSalesImport.integration.test.ts @@ -556,7 +556,7 @@ describe("runSalesImport — real-DB scenarios", () => { // firstName was already set — not overwritten. expect(after?.firstName).toBe("Madonna"); // lastName was NULL — filled in from CSV. - expect(after?.lastName).toBe("Ciccone"); + expect(after?.lastName).toBe("Calloway"); }); }); diff --git a/app/__tests__/integration/salesPersonFkBackfill.integration.test.ts b/app/__tests__/integration/salesPersonFkBackfill.integration.test.ts index 2e8ea0cd..c6e79910 100644 --- a/app/__tests__/integration/salesPersonFkBackfill.integration.test.ts +++ b/app/__tests__/integration/salesPersonFkBackfill.integration.test.ts @@ -73,7 +73,7 @@ describe("backfillSalesPersonFk — real-DB", () => { const order = await prisma.salesOrder.create({ data: { orderno: "SO-CASE-WHITESPACE", - salesperson: " karen WEST ", + salesperson: " karen WESTON ", salesPersonId: null, }, }); From e19b88a0cbf0abe17fa08ef826da10ada5cd9f93 Mon Sep 17 00:00:00 2001 From: goetchstone Date: Wed, 26 Aug 2026 09:22:33 -0400 Subject: [PATCH 05/11] fix: the seed allowlist refused CI's own database CI creates a database called `ci` and runs `npm run setup` against it to prove the first-run path works. The allowlist did not recognise that name, so the boot-and-smoke job failed at the first step -- a name that declares disposability as plainly as "scratch" does was being treated as if it might hold real data. The gap underneath: the allowlist and the database CI actually creates were two facts nothing made agree, so the mismatch could only surface remotely, in the one job that boots the app. seedTargetGuard.test.ts now reads POSTGRES_DB out of .github/workflows/ci.yml and asserts the guard accepts every value it finds, and checks setup.sh's shell copy of the rule still carries the same tokens. Removing "ci" from the allowlist fails both. 3,459 unit tests pass; tsc and prettier clean. --- CLAUDE.md | 3 ++- app/__tests__/seedTargetGuard.test.ts | 29 ++++++++++++++++++++++++++- app/prisma/seed/demo/guard.ts | 6 +++--- app/scripts/setup.sh | 2 +- docs/domains/seed-data.md | 5 +++-- 5 files changed, 37 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index be6f2e6c..3cce61c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -198,7 +198,8 @@ Full playbook: → `.claude/skills/dependency-sweep/SKILL.md` 59. **`fbc_test_db` is the only database tests may write**, and the demo seed writes only a database whose NAME says it exists to be seeded (`holt_demo`, - `holt_seed_demo`, anything carrying seed/demo/scratch/sandbox/sample). Every + `holt_seed_demo`, `ci`, anything carrying seed/demo/scratch/sandbox/sample/ci + as a whole word). Every other database is assumed to hold restored, curated or live local data and needs an explicit `--force-unsafe-db`. Allowlist, not blocklist: a blocklist of known-dangerous names fails open for the one nobody thought of, which is diff --git a/app/__tests__/seedTargetGuard.test.ts b/app/__tests__/seedTargetGuard.test.ts index a951308f..27d20b48 100644 --- a/app/__tests__/seedTargetGuard.test.ts +++ b/app/__tests__/seedTargetGuard.test.ts @@ -10,6 +10,9 @@ // matter most are the unfamiliar names: they must be refused precisely // BECAUSE nobody thought of them. +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + import { assertSafeSeedTarget, UnsafeSeedTargetError } from "../prisma/seed/demo/guard"; const url = (db: string) => `postgresql://user:secret@localhost:5432/${db}`; @@ -18,11 +21,35 @@ const forced = { forceUnsafe: true }; describe("assertSafeSeedTarget", () => { it("allows a purpose-built scratch database", () => { - for (const db of ["holt_seed_demo", "demo", "scratch_db", "holt_sandbox", "sample_data"]) { + for (const db of ["holt_seed_demo", "demo", "scratch_db", "holt_sandbox", "sample_data", "ci", "holt_ci"]) { expect(assertSafeSeedTarget(url(db), safe)).toBe(db); } }); + // The allowlist and the database CI actually creates are two facts that have + // to agree, and nothing made them agree: the first version of this guard + // refused CI's own database, which only surfaced after a push, in the one job + // that boots the app. Reading the name out of the workflow closes that loop -- + // renaming the database in CI now fails here rather than in a remote build. + it("accepts the database the CI workflow actually creates", () => { + const workflow = readFileSync(join(__dirname, "..", "..", ".github", "workflows", "ci.yml"), "utf8"); + const names = [...workflow.matchAll(/^\s*POSTGRES_DB:\s*(\S+)\s*$/gm)].map((m) => m[1]); + expect(names.length).toBeGreaterThan(0); + for (const db of new Set(names)) { + expect(() => assertSafeSeedTarget(url(db), safe)).not.toThrow(); + } + }); + + // setup.sh reimplements the same rule in shell so it can fail BEFORE running + // migrations. Two implementations of one rule drift; this asserts the shell + // one still carries every token the TypeScript one does. + it("keeps setup.sh's shell copy of the rule in step", () => { + const setup = readFileSync(join(__dirname, "..", "scripts", "setup.sh"), "utf8"); + for (const token of ["seed", "demo", "scratch", "sandbox", "sample", "ci"]) { + expect(setup).toContain(token); + } + }); + it("refuses an unfamiliar name -- the case a blocklist misses", () => { for (const db of ["holt_prod", "acme_restored", "holt_2026_backup", "postgres"]) { expect(() => assertSafeSeedTarget(url(db), safe)).toThrow(UnsafeSeedTargetError); diff --git a/app/prisma/seed/demo/guard.ts b/app/prisma/seed/demo/guard.ts index 1d98362b..1ab13c5c 100644 --- a/app/prisma/seed/demo/guard.ts +++ b/app/prisma/seed/demo/guard.ts @@ -34,7 +34,7 @@ const HARD_BLOCKED_DB_NAMES = ["fbc_test_db"]; * database names, which failed OPEN: a name nobody had thought to list seeded * silently, and the list only ever grew by someone losing data first. */ -const SCRATCH_DB_NAME = /(^|_)(seed|demo|scratch|sandbox|sample)(_|$)/i; +const SCRATCH_DB_NAME = /(^|_)(seed|demo|scratch|sandbox|sample|ci)(_|$)/i; export class UnsafeSeedTargetError extends Error {} @@ -91,8 +91,8 @@ export function assertSafeSeedTarget(databaseUrl: string, opts: SafetyCheckOptio throw new UnsafeSeedTargetError( `Refusing to seed database "${dbName}" (${masked}) without an explicit override. ` + `Only a purpose-built scratch database seeds unattended -- one whose name ` + - `contains "seed", "demo", "scratch", "sandbox" or "sample" (e.g. ` + - `holt_seed_demo). Any other name may hold real dev, restored or curated ` + + `carries "seed", "demo", "scratch", "sandbox", "sample" or "ci" as a word ` + + `(e.g. holt_seed_demo). Any other name may hold real dev, restored or curated ` + `data (CLAUDE.md rule 59). If this really is the intended target, re-run ` + `with --force-unsafe-db or HOLT_SEED_FORCE_UNSAFE_DB=1.`, ); diff --git a/app/scripts/setup.sh b/app/scripts/setup.sh index 5aaebde4..60389c18 100755 --- a/app/scripts/setup.sh +++ b/app/scripts/setup.sh @@ -62,7 +62,7 @@ echo " database: $DB_NAME" # after the migrate step has already run. A real deployment names its database # something else and does not run this script. case "$DB_NAME" in - *seed*|*demo*|*scratch*|*sandbox*|*sample*) ;; + ci|*_ci|ci_*|*_ci_*|*seed*|*demo*|*scratch*|*sandbox*|*sample*) ;; *) fail "Refusing to set up '$DB_NAME' -- this seeds demo data, and that name does not read as a database created for it. Point DATABASE_URL at one whose diff --git a/docs/domains/seed-data.md b/docs/domains/seed-data.md index 0a63c327..c4da0328 100644 --- a/docs/domains/seed-data.md +++ b/docs/domains/seed-data.md @@ -385,8 +385,9 @@ enforces: - **Hard-blocked, no override, ever:** `fbc_test_db` — owned exclusively by the Jest integration harness (`jest.integration.setup.ts`); seeding into it would corrupt every integration test run until someone noticed. -- **Allowed unattended:** a name containing `seed`, `demo`, `scratch`, `sandbox` or - `sample` (`holt_demo` — the `env.example` default — `holt_seed_demo`, and the like). +- **Allowed unattended:** a name carrying `seed`, `demo`, `scratch`, `sandbox`, + `sample` or `ci` as a whole word (`holt_demo` — the `env.example` default — + `holt_seed_demo`, and the `ci` database the smoke workflow creates). - **Everything else needs `--force-unsafe-db` / `HOLT_SEED_FORCE_UNSAFE_DB=1`**, on the assumption it holds real dev, restored or curated data. From fdf90ed4491a0f9ac4d96a36ce9dcba7b970b39f Mon Sep 17 00:00:00 2001 From: goetchstone Date: Wed, 26 Aug 2026 09:59:52 -0400 Subject: [PATCH 06/11] fix: close four regressions the config seams introduced An adversarial audit of this branch raised 77 findings; six survived independent refutation, and I reproduced each by executing the shipped code. Four were regressions this branch created. 1. ORDORITE_REPORT_PREFIX could not express the two prefixes a real deployment uses. It was one escaped scalar, so `A|B` became the literal `A\|B` and matched nothing -- pinning the prefix silently unrouted every report filed under the other one (stock, PO lines, the 100K-row product catalog), logged as "skipped" with no alert. Now a comma-separated list, the convention ORDORITE_STORE_CODES already used. 2. The org-report routes were matching `.+_` UNANCHORED, so files that previously returned null -- the loud unrouted-file path -- drove master-data imports instead: Deleted_Customers.csv and Vendor_Stock_by_Item.csv both routed. `.` matched `/`, so a directory component counted as a prefix. Now anchored on the base name, with the prefix optional. Unconfigured, the router matches bare report names only; it refuses a look-alike rather than guessing. Only Inbound_Items requires a prefix, because a bare Inbound_Items is a different report with a different runner. 3. Generalising `/^RS\d/i` to `/^R[A-Z]\d/i` looked source-neutral and widened it 26x. An unrelated R-series -- RA for a rug account, RX for exchanges -- would import as RETURNED and be subtracted from revenue, with no configuration able to narrow it back. Now ORDORITE_RETURN_PREFIXES, empty by default: there is no safe universal guess, so a deployment that uses such a series names it. 4. ORDORITE_STORE_CODES="," was truthy, split to nothing, and joined to "" -- an empty alternation matching a zero-length store code, BROADER than the default it replaced. isReturnOrder("A1") became true. A misconfiguration must never widen a match; it now falls back to the default. Also: setup.sh's globs were unanchored substrings against guard.ts's word-bounded regex, so setup.sh accepted `holt_samples` and `demolition_prod`, ran migrate and seed:roles into them, and only THEN did the seed refuse -- leaving a half-set-up database. The shell arm is now word-bounded, and seedTargetGuard.test.ts compares the two implementations' DECISIONS across 21 boundary names instead of checking they contain the same words. The old test passed the whole time this was broken. And a stale COMPANY_EMAIL_DOMAIN restore left the pre-scrub stem in ordoriteShared.test.ts, where the next test appended to that describe would have inherited it. 3,466 unit tests pass; import integration suites pass; tsc, prettier, lint clean. --- app/__tests__/ordoriteReportRouter.test.ts | 106 +++++++++++++----- app/__tests__/ordoriteShared.test.ts | 62 +++++++++- app/__tests__/seedTargetGuard.test.ts | 41 ++++++- app/scripts/setup.sh | 8 +- app/src/lib/adapters/ordorite/reportRouter.ts | 64 ++++++++--- app/src/lib/adapters/ordorite/shared.ts | 40 +++++-- env.example | 22 ++-- 7 files changed, 276 insertions(+), 67 deletions(-) diff --git a/app/__tests__/ordoriteReportRouter.test.ts b/app/__tests__/ordoriteReportRouter.test.ts index c7e51129..c6dae6d0 100644 --- a/app/__tests__/ordoriteReportRouter.test.ts +++ b/app/__tests__/ordoriteReportRouter.test.ts @@ -3,6 +3,19 @@ import { resolveImportRoute } from "@/lib/adapters/ordorite/reportRouter"; describe("resolveImportRoute", () => { + // Two prefixes, because that is what a real deployment looks like: a full + // name on some exports and an initialism on others. Every org-report case + // below therefore also proves the alternation works -- a single-value knob + // would route one family and silently drop the other. + const ORIGINAL_PREFIX = process.env.ORDORITE_REPORT_PREFIX; + beforeAll(() => { + process.env.ORDORITE_REPORT_PREFIX = "Riverbend_Home,SH"; + }); + afterAll(() => { + if (ORIGINAL_PREFIX === undefined) delete process.env.ORDORITE_REPORT_PREFIX; + else process.env.ORDORITE_REPORT_PREFIX = ORIGINAL_PREFIX; + }); + describe("sales and customer reports", () => { it("routes Prior_Day_Sales_Data_Export to sales", () => { const result = resolveImportRoute("Prior_Day_Sales_Data_Export.csv"); @@ -159,41 +172,78 @@ describe("resolveImportRoute", () => { }); }); - // The org prefix is a deployment fact, not a constant. Every fixture above - // uses one made-up org; these prove the router is not keyed to it -- a second - // org routes identically, and a deployment can pin its own prefix. - describe("org report prefix is not hardcoded", () => { - const ORIGINAL = process.env.ORDORITE_REPORT_PREFIX; + // The org prefix is a deployment fact, not a constant. + describe("org report prefix is configuration", () => { + // Restore the SUITE's value, not the module-load value -- the outer + // beforeAll has already set it, and clearing it here instead would leave + // every later describe running unconfigured. afterEach(() => { - if (ORIGINAL === undefined) delete process.env.ORDORITE_REPORT_PREFIX; - else process.env.ORDORITE_REPORT_PREFIX = ORIGINAL; + process.env.ORDORITE_REPORT_PREFIX = "Riverbend_Home,SH"; }); + const typeOf = (f: string) => { + const r = resolveImportRoute(f); + return r === null ? null : r === "skip" ? "skip" : (r as { importType: string }).importType; + }; it("routes a different org's prefix identically", () => { - for (const [file, importType] of [ - ["Acme_Furniture_Inbound_Items.csv", "inbound-items"], - ["Acme_Furniture_Prior_Day_Customers.csv", "customers"], - ["ACME_Item_Export.csv", "products"], - ["ACME_Stock_by_Item.csv", "stock"], - ] as const) { - const result = resolveImportRoute(file); - expect(result).not.toBeNull(); - expect((result as { importType: string }).importType).toBe(importType); - } + process.env.ORDORITE_REPORT_PREFIX = "Acme_Furniture,ACME"; + expect(typeOf("Acme_Furniture_Inbound_Items.csv")).toBe("inbound-items"); + expect(typeOf("Acme_Furniture_Prior_Day_Customers.csv")).toBe("customers"); + expect(typeOf("ACME_Item_Export.csv")).toBe("products"); + expect(typeOf("ACME_Stock_by_Item.csv")).toBe("stock"); + }); + + // A deployment routinely uses more than one prefix. A scalar knob could not + // say so, and escaping meant "A|B" could not be smuggled in either -- so + // pinning the prefix unrouted every report filed under the other one. + it("accepts a list, and needs one when the org uses two prefixes", () => { + process.env.ORDORITE_REPORT_PREFIX = "Riverbend_Home"; + expect(typeOf("Riverbend_Home_Inbound_Items.csv")).toBe("inbound-items"); + expect(typeOf("SH_Stock_by_Item.csv")).toBeNull(); + + process.env.ORDORITE_REPORT_PREFIX = "Riverbend_Home,SH"; + expect(typeOf("Riverbend_Home_Inbound_Items.csv")).toBe("inbound-items"); + expect(typeOf("SH_Stock_by_Item.csv")).toBe("stock"); + expect(typeOf("SH_Item_Export.csv")).toBe("products"); + expect(typeOf("SH_Purchase_Order_Line_Export.csv")).toBe("po-lines"); }); - it("honours a pinned ORDORITE_REPORT_PREFIX", () => { + it("narrows to the pinned prefix -- another org's file is not an org report", () => { process.env.ORDORITE_REPORT_PREFIX = "Acme_Furniture"; - expect( - (resolveImportRoute("Acme_Furniture_Inbound_Items.csv") as { importType: string }) - .importType, - ).toBe("inbound-items"); - // A pinned prefix narrows the match: another org's file falls through to - // the unprefixed vendor-standard route rather than the org-report one. - expect( - (resolveImportRoute("Other_Co_Inbound_Items.csv") as { importType: string }) - .importType, - ).toBe("purchase-orders"); + expect(typeOf("Other_Co_Inbound_Items.csv")).toBe("purchase-orders"); + expect(typeOf("Other_Co_Customers.csv")).toBeNull(); + }); + + // The regression that made anchoring necessary: an unanchored `.+_Customers` + // sent anything ending in the report name into a master-data import, where + // before it returned null and an operator saw an unrouted file. + it("refuses a look-alike rather than guessing it is an org report", () => { + for (const prefix of ["Riverbend_Home,SH", undefined]) { + if (prefix === undefined) delete process.env.ORDORITE_REPORT_PREFIX; + else process.env.ORDORITE_REPORT_PREFIX = prefix; + for (const f of [ + "Deleted_Customers.csv", + "Inactive_Customers.csv", + "Marjan_Customers.csv", + "Vendor_Stock_by_Item.csv", + "Q3_Customers_Report.csv", + ]) { + expect(typeOf(f)).toBeNull(); + } + // A directory component must not stand in for the org prefix. + expect(typeOf("archive/2025/Old_Customers.csv")).toBeNull(); + } + }); + + // Unconfigured, an org that does not prefix its exports still works, and + // the prefix-required route stands down so the bare route keeps its meaning. + it("falls back to bare report names when nothing is configured", () => { + delete process.env.ORDORITE_REPORT_PREFIX; + expect(typeOf("Customers.csv")).toBe("customers"); + expect(typeOf("Stock_by_Item.csv")).toBe("stock"); + expect(typeOf("Item_Export.csv")).toBe("products"); + expect(typeOf("Inbound_Items.csv")).toBe("purchase-orders"); + expect(typeOf("Riverbend_Home_Inbound_Items.csv")).toBe("purchase-orders"); }); }); diff --git a/app/__tests__/ordoriteShared.test.ts b/app/__tests__/ordoriteShared.test.ts index 299d57a4..71b1acd9 100644 --- a/app/__tests__/ordoriteShared.test.ts +++ b/app/__tests__/ordoriteShared.test.ts @@ -257,11 +257,69 @@ describe("isValidEmail", () => { // ─── isUntrustedMergeEmail ────────────────────────────────────────── +describe("return-order conventions are configuration", () => { + const P = process.env.ORDORITE_RETURN_PREFIXES; + const C = process.env.ORDORITE_STORE_CODES; + afterEach(() => { + if (P === undefined) delete process.env.ORDORITE_RETURN_PREFIXES; + else process.env.ORDORITE_RETURN_PREFIXES = P; + if (C === undefined) delete process.env.ORDORITE_STORE_CODES; + else process.env.ORDORITE_STORE_CODES = C; + }); + + // The vendor's convention is the "A" suffix on a store code; the codes are + // the deployment's. Both spellings must agree. + it("reads the A-suffix return convention against configured store codes", () => { + process.env.ORDORITE_STORE_CODES = "SB,GT,CH"; + expect(isReturnOrder("SBOA1234")).toBe(true); + expect(isReturnOrder("CHOA1")).toBe(true); + expect(isReturnOrder("SBOM38721")).toBe(false); + // A code the deployment did not declare is not its store. + expect(isReturnOrder("ZZOA1234")).toBe(false); + }); + + // A whole-number return prefix is one deployment's series. Generalising the + // original single literal to "R + any letter" looked source-neutral and + // silently widened it 26x, so an unrelated R-series (RA rug account, RX + // exchange) imported as RETURNED and was subtracted from revenue. + it("only treats a CONFIGURED R-prefix as a return", () => { + delete process.env.ORDORITE_RETURN_PREFIXES; + for (const o of ["RS1234", "RA1234", "RX9999", "RB0001"]) { + expect(isReturnOrder(o)).toBe(false); + } + process.env.ORDORITE_RETURN_PREFIXES = "RS"; + expect(isReturnOrder("RS1234")).toBe(true); + expect(isReturnOrder("rs1234")).toBe(true); + expect(isReturnOrder("RA1234")).toBe(false); + expect(isReturnOrder("RX9999")).toBe(false); + }); + + // "R"/"CR" followed directly by digits is the vendor's own convention and + // stands on its own, configured or not. + it("keeps the vendor's own R/CR-then-digits convention unconditionally", () => { + delete process.env.ORDORITE_RETURN_PREFIXES; + expect(isReturnOrder("R12345")).toBe(true); + expect(isReturnOrder("CR-12345")).toBe(true); + }); + + // A misconfiguration must never WIDEN the match. A stray "," used to split to + // nothing and join to "", producing an empty alternation that matched a + // zero-length store code -- broader than the default it replaced. + it("falls back to the default when the configured list is empty", () => { + for (const junk of [",", " , , ", " "]) { + process.env.ORDORITE_STORE_CODES = junk; + expect(isReturnOrder("PA1234")).toBe(false); + expect(isReturnOrder("A1")).toBe(false); + expect(isReturnOrder("SBOA1234")).toBe(true); + } + }); +}); + describe("isUntrustedMergeEmail", () => { // Staff sometimes type their OWN email when entering customer records // in the POS. The shared-email merge in findOrCreateCustomer would then // wrongly cluster distinct customers. The guard blocks any email whose - // DOMAIN contains COMPANY_EMAIL_DOMAIN. A short stem ("sayb") covers + // DOMAIN contains COMPANY_EMAIL_DOMAIN. A short stem ("rive") covers // the canonical company domain plus every typo variant seen in prod // data. Domains here are invented -- the guard reads the stem from env and the // test sets it, so nothing is coupled to any real company's address. @@ -325,7 +383,7 @@ describe("isUntrustedMergeEmail", () => { try { expect(isUntrustedMergeEmail("jmoreau@riverbendhome.com")).toBe(false); } finally { - process.env.COMPANY_EMAIL_DOMAIN = "sayb"; + process.env.COMPANY_EMAIL_DOMAIN = "rive"; } }); }); diff --git a/app/__tests__/seedTargetGuard.test.ts b/app/__tests__/seedTargetGuard.test.ts index 27d20b48..1913ff43 100644 --- a/app/__tests__/seedTargetGuard.test.ts +++ b/app/__tests__/seedTargetGuard.test.ts @@ -17,6 +17,7 @@ import { assertSafeSeedTarget, UnsafeSeedTargetError } from "../prisma/seed/demo const url = (db: string) => `postgresql://user:secret@localhost:5432/${db}`; const safe = { forceUnsafe: false }; +const escapeRe = (t: string) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const forced = { forceUnsafe: true }; describe("assertSafeSeedTarget", () => { @@ -41,13 +42,41 @@ describe("assertSafeSeedTarget", () => { }); // setup.sh reimplements the same rule in shell so it can fail BEFORE running - // migrations. Two implementations of one rule drift; this asserts the shell - // one still carries every token the TypeScript one does. - it("keeps setup.sh's shell copy of the rule in step", () => { + // migrations. Two implementations of one rule drift, so compare their + // DECISIONS -- an earlier version of this test only checked that the shell + // carried the same tokens, which it did while using substring globs (*demo*) + // against the guard's word-bounded regex. That gap let setup.sh migrate and + // seed roles into `holt_samples` before the seed refused it. + it("makes exactly the same call as setup.sh, database by database", () => { const setup = readFileSync(join(__dirname, "..", "scripts", "setup.sh"), "utf8"); - for (const token of ["seed", "demo", "scratch", "sandbox", "sample", "ci"]) { - expect(setup).toContain(token); - } + const arm = setup.match(/^\s*((?:[A-Za-z0-9_*|]+\|)+[A-Za-z0-9_*|]+)\)\s*;;\s*$/m); + expect(arm).not.toBeNull(); + const globs = arm![1].split("|"); + + const shellAllows = (db: string) => + globs.some((g) => new RegExp("^" + g.split("*").map(escapeRe).join(".*") + "$").test(db)); + const guardAllows = (db: string) => { + try { + assertSafeSeedTarget(url(db), safe); + return true; + } catch { + return false; + } + }; + + // Names chosen to straddle the boundary in both directions: plurals, + // hyphens, and tokens embedded in longer words are exactly where a + // substring glob and a word-bounded regex part company. + const NAMES = [ + "holt_demo", "holt_seed_demo", "demo", "ci", "holt_ci", "ci_main", "scratch_db", + "holt_sandbox", "sample_data", "holt_samples", "holt-demo", "demolition_prod", + "seeded_archive", "sandboxes", "scratchpad_prod", "holt_prod", "acme_restored", + "postgres", "holt_2026_backup", "predemo", "demo_of_prod", + ]; + const disagreements = NAMES.filter((db) => shellAllows(db) !== guardAllows(db)).map( + (db) => `${db}: setup.sh=${shellAllows(db) ? "allow" : "refuse"} guard=${guardAllows(db) ? "allow" : "refuse"}`, + ); + expect(disagreements).toEqual([]); }); it("refuses an unfamiliar name -- the case a blocklist misses", () => { diff --git a/app/scripts/setup.sh b/app/scripts/setup.sh index 60389c18..3355118d 100755 --- a/app/scripts/setup.sh +++ b/app/scripts/setup.sh @@ -62,7 +62,13 @@ echo " database: $DB_NAME" # after the migrate step has already run. A real deployment names its database # something else and does not run this script. case "$DB_NAME" in - ci|*_ci|ci_*|*_ci_*|*seed*|*demo*|*scratch*|*sandbox*|*sample*) ;; + # Word-bounded on purpose, so this stays identical to guard.ts's + # /(^|_)(seed|demo|scratch|sandbox|sample|ci)(_|$)/i. Substring globs (*demo*) + # were looser than the guard: setup.sh accepted "holt_samples" and + # "demolition_prod", migrated and seeded roles into them, and only THEN did + # the seed refuse -- leaving a half-set-up database behind. Two copies of one + # rule drift; seedTargetGuard.test.ts compares their decisions name by name. + seed|*_seed|seed_*|*_seed_*|demo|*_demo|demo_*|*_demo_*|scratch|*_scratch|scratch_*|*_scratch_*|sandbox|*_sandbox|sandbox_*|*_sandbox_*|sample|*_sample|sample_*|*_sample_*|ci|*_ci|ci_*|*_ci_*) ;; *) fail "Refusing to set up '$DB_NAME' -- this seeds demo data, and that name does not read as a database created for it. Point DATABASE_URL at one whose diff --git a/app/src/lib/adapters/ordorite/reportRouter.ts b/app/src/lib/adapters/ordorite/reportRouter.ts index bb1a4718..08c690a4 100644 --- a/app/src/lib/adapters/ordorite/reportRouter.ts +++ b/app/src/lib/adapters/ordorite/reportRouter.ts @@ -23,12 +23,23 @@ import { interface RouteEntry { // A report Ordorite names the same way for every customer. pattern?: RegExp; - // A report the DEPLOYING ORG named after itself. Ordorite lets the owner - // pick export filenames, so the prefix is a deployment fact, not a vendor - // constant (CLAUDE.md 61-63) -- set ORDORITE_REPORT_PREFIX to pin it. - // Unset means "any prefix", which is what discriminates these from the - // unprefixed vendor-standard reports of the same name. + // A report the DEPLOYING ORG named after itself: `_Stock_by_Item.csv`. + // Ordorite lets the owner choose export filenames, so the prefix is a + // deployment fact, not a vendor constant (CLAUDE.md 61-63) -- it comes from + // ORDORITE_REPORT_PREFIX, which accepts a comma-separated list because one + // deployment routinely uses several (a full name for some reports, an + // initialism for others). + // + // Matched ANCHORED, with the prefix OPTIONAL. Anchoring is the point: an + // earlier version matched `.+_Customers` unanchored, which quietly routed + // `Deleted_Customers.csv` and `Vendor_Stock_by_Item.csv` into master-data + // imports that had previously returned null. A router that cannot positively + // identify a file must refuse it, not guess. orgReport?: string; + // Set when the prefix is REQUIRED rather than optional -- true only where an + // unprefixed route of the same name exists and means something else. Without + // a configured prefix these never match, so the bare route wins. + orgPrefixRequired?: boolean; importType: string; runner: (data: Record[], createdBy?: string) => Promise; // stock-by-item wraps records in { records: [...] } -- the runner accepts @@ -78,7 +89,10 @@ const REPORT_ROUTES: RouteEntry[] = [ runner: runPOLineExportImport, }, { + // `_Inbound_Items` and a bare `Inbound_Items` are DIFFERENT reports + // with different runners, so this one needs a real prefix to fire. orgReport: "Inbound_Items", + orgPrefixRequired: true, importType: "inbound-items", runner: runInboundItemsImport, }, @@ -140,17 +154,33 @@ export interface ResolvedRoute { runner: (data: Record[], createdBy?: string) => Promise; } -// Regex fragment matching the deploying org's report-name prefix. Deliberately -// permissive when unconfigured: any prefix routes, which preserves the -// prefixed-vs-bare discrimination without any deployment naming itself in code. -function orgPrefixFragment(): string { - const configured = process.env.ORDORITE_REPORT_PREFIX?.trim(); - return configured ? configured.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : ".+"; +/** + * Alternation of the deploying org's report-name prefixes, or null when none + * is configured. + * + * Comma-separated because a single deployment commonly uses more than one -- + * a full name on some exports and an initialism on others. A scalar could not + * express that, and regex-escaping meant an operator could not smuggle one in + * as `A|B` either: pinning the prefix silently unrouted every report under the + * other one. + */ +function orgPrefixAlternation(): string | null { + const parts = (process.env.ORDORITE_REPORT_PREFIX ?? "") + .split(",") + .map((p) => p.trim()) + .filter(Boolean) + .map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); + return parts.length ? `(?:${parts.join("|")})` : null; } -function patternFor(route: RouteEntry, prefix: string): RegExp { +function patternFor(route: RouteEntry, prefixAlt: string | null): RegExp | null { if (route.pattern) return route.pattern; - return new RegExp(`${prefix}_(?:${route.orgReport})`, "i"); + if (prefixAlt) return new RegExp(`^${prefixAlt}_(?:${route.orgReport})`, "i"); + // No prefix configured. A required-prefix route cannot fire at all; the rest + // still match their bare name, so an org that does not prefix its exports + // works with no configuration. + if (route.orgPrefixRequired) return null; + return new RegExp(`^(?:${route.orgReport})`, "i"); } export function resolveImportRoute(filename: string): ResolvedRoute | "skip" | null { @@ -159,9 +189,13 @@ export function resolveImportRoute(filename: string): ResolvedRoute | "skip" | n if (skip.test(filename)) return "skip"; } - const prefix = orgPrefixFragment(); + // Anchored matching is on the base name, so a directory component cannot + // stand in for the org prefix (`.` matches `/`). + const base = filename.split("/").pop() ?? filename; + const prefixAlt = orgPrefixAlternation(); for (const route of REPORT_ROUTES) { - if (patternFor(route, prefix).test(filename)) { + const pattern = patternFor(route, prefixAlt); + if (pattern && pattern.test(base)) { return { importType: route.importType, runner: route.runner }; } } diff --git a/app/src/lib/adapters/ordorite/shared.ts b/app/src/lib/adapters/ordorite/shared.ts index 6fc7f62d..a5b9393c 100644 --- a/app/src/lib/adapters/ordorite/shared.ts +++ b/app/src/lib/adapters/ordorite/shared.ts @@ -219,13 +219,36 @@ const RETURN_ORDER_PREFIX = /^(R|CR)-?\d/i; // vendor's convention; the STORE CODES are a deployment fact (CLAUDE.md 61-63), // so they come from config and default to "any code". function storeCodeFragment(): string { - const configured = process.env.ORDORITE_STORE_CODES?.trim(); - if (!configured) return "[A-Z]{2,}"; - return configured + const codes = splitConfiguredCodes(process.env.ORDORITE_STORE_CODES); + // Falling back on an EMPTY list, not just an unset one. A value of "," or + // " , , " is truthy, splits to nothing, and used to join to "" -- producing + // `^(?:)[A-Z]*A\d`, which matches a ZERO-length code and is therefore + // BROADER than the default. A misconfiguration must never widen the match. + return codes.length ? codes.join("|") : "[A-Z]{2,}"; +} + +/** + * Prefixes that mark a whole order number as a return, e.g. "RS" for returns + * booked to a store whose initial is S. + * + * Empty by default, and that is deliberate. This started as one hardcoded + * literal for one deployment; generalising it to "R followed by any letter" + * looked source-neutral but silently widened it 26x, so an unrelated + * R-prefixed series (RA for a rug account, RX for exchanges) would import as + * RETURNED and be subtracted from revenue. There is no safe universal default: + * a deployment that uses such a series must name it. + */ +function returnPrefixFragment(): string | null { + const codes = splitConfiguredCodes(process.env.ORDORITE_RETURN_PREFIXES); + return codes.length ? codes.join("|") : null; +} + +function splitConfiguredCodes(raw: string | undefined): string[] { + return (raw ?? "") .split(",") - .map((code) => code.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + .map((code) => code.trim()) .filter(Boolean) - .join("|"); + .map((code) => code.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); } // Rewrite-suffix matching. Ordorite rewrites replace the original order; the @@ -276,9 +299,10 @@ export function rewriteBaseOrderno(orderno: string): string | null { /** True when the order number follows Ordorite's return/credit convention. */ export function isReturnOrder(orderno: string): boolean { - // "R" + a store initial (RS1234) is a return booked against that store -- - // distinct from RETURN_ORDER_PREFIX, which is "R"/"CR" followed by digits. - if (/^R[A-Z]\d/i.test(orderno)) return true; + // A configured whole-number return prefix (RS1234) -- distinct from + // RETURN_ORDER_PREFIX, which is "R"/"CR" followed directly by digits. + const returnPrefixes = returnPrefixFragment(); + if (returnPrefixes && new RegExp(`^(?:${returnPrefixes})\\d`, "i").test(orderno)) return true; const codes = storeCodeFragment(); const returnStoreSuffix = new RegExp(`^(?:${codes})[A-Z]*A\\d`, "i"); return RETURN_ORDER_PREFIX.test(orderno) || returnStoreSuffix.test(orderno); diff --git a/env.example b/env.example index 8ad61c2c..8f937331 100644 --- a/env.example +++ b/env.example @@ -33,19 +33,27 @@ POSTGRES_DB=holt_demo # AUTO_IMPORT_API_KEY= # --- Source-system conventions (POS/ERP you import FROM) --- -# Your own naming inside the source system, not vendor constants. Each defaults -# to "match anything", so imports work unconfigured; set them to pin matching -# and stop a look-alike filename or order number routing somewhere unintended. +# YOUR naming inside the source system, not vendor constants. The vendor's own +# conventions stay in code; these are the parts only your deployment knows. # -# Report-filename prefix -- `Acme_Home` matches `Acme_Home_Inbound_Items.csv`, -# which is what distinguishes YOUR inbound-items export from the vendor's -# unprefixed standard report of the same name. -# ORDORITE_REPORT_PREFIX=Acme_Home +# Report-filename prefixes. Comma-separate them -- a deployment normally uses +# more than one (a full name on some exports, an initialism on others), and +# naming only one silently unroutes every report filed under the other. +# Unset, the router matches BARE report names only (`Customers.csv`), so an org +# that does not prefix its exports needs no configuration. It will NOT guess +# that `Deleted_Customers.csv` is your customer master. +# ORDORITE_REPORT_PREFIX=Acme_Home,AH # # Store codes embedded in order numbers. The vendor's A/M suffix marks a return # (ABxxA1234) vs an order (ABxxM1234); the codes themselves are yours. # ORDORITE_STORE_CODES=AB,CD,EF # +# Order-number prefixes that mean "return" on their own, if you use such a +# series (e.g. RS1234 for returns booked to your S store). Empty by default and +# deliberately so: guessing this wrong books ordinary orders as returns and +# subtracts them from revenue. +# ORDORITE_RETURN_PREFIXES=RS +# # Your staff email domain, or a short stem of it. Salespeople sometimes type # their OWN address into a customer record; the import refuses to merge # customers on any email in this domain. A stem ("acme") also covers typos. From 99c59bd643840d6f01dd78faf36e7ca30228f0d7 Mon Sep 17 00:00:00 2001 From: goetchstone Date: Wed, 26 Aug 2026 10:19:31 -0400 Subject: [PATCH 07/11] fix: scrub the real data a second audit pass found, and widen the tripwire 53 findings from the branch audit never got judged -- their verifiers died mid-run. Re-verifying them confirmed 31, and the leaks among them were worse than the first pass caught. People and addresses, all still live at HEAD: - a customer's name beside her home delivery address, in source, two tests and a doc comment - a named customer's spend profile, customer number and return record across four files - a former employee named with her order count, and a departed seller's unattributed revenue - a customer bound to an outage, an order number and a $91,151 revenue gap - a customer and two staff first names in a transfer anecdote - staff names paired with specific orders and commission amounts in a runbook - the deployment's town ZIP, in two ZIP+4 examples Half-renames the first pass left behind, each of which is why a scrub needs a tripwire rather than a careful grep: "Erin Kelly" fixed in one test but left in another and in the parser it documents; "Karen Dwyer" fixed where the name was one string and missed where it was split across firstName/lastName; an "alex robertson" map key orphaned when its sibling was renamed. Seven vendor fixtures published confidential dealer pricing -- one printed Wholesale beside MSRP on the same row, another stated the vendor's entire catalog cost ceiling in a source comment. Every price is now invented, with the arithmetic each parser actually keys on preserved (qty x price == extension, extensions summing to the printed total) and the fixed-width caret alignments in two parser sources unchanged. Real order, PO and reference numbers went with them, since a document number is what ties an invented price back to a real deal. The tripwire now covers all of it: surnames, whole names where the surname is too common to match alone, addresses, the ZIP, vendor document numbers, the specific dealer figures, and the catalog-ceiling phrasing. It found five sites this commit would otherwise have missed, including two where a name wrapped across comment lines. A rep-group company was deliberately dropped from the list -- naming a business is not the harm, publishing its trade terms is. 3,474 unit tests pass; six integration suites pass; tsc and prettier clean. --- .../aestheticMovementOrderParser.test.ts | 29 ++++---- app/__tests__/beatrizBallOrderParser.test.ts | 33 ++++----- app/__tests__/brandWiseOrderParser.test.ts | 27 ++++---- app/__tests__/clientDataTripwire.test.ts | 26 ++++++- app/__tests__/homeAccessoryOrders.test.ts | 60 ++++++++--------- app/__tests__/homeAccessoryRows.test.ts | 10 +-- ...meAccessoryOrderCommit.integration.test.ts | 4 +- ...ttributionRewriteChain.integration.test.ts | 8 +-- ...ServiceCaseSheetImport.integration.test.ts | 2 +- app/__tests__/kkOrderParser.test.ts | 2 +- app/__tests__/marketTimeOrderParser.test.ts | 67 ++++++++++--------- .../reports.salesRevenueStatusFilter.test.ts | 2 +- .../salesBySalesperson.helpers.test.ts | 10 +-- app/__tests__/serviceCaseSheetImport.test.ts | 6 +- app/__tests__/simblistCsvOrderParser.test.ts | 32 ++++----- app/__tests__/staffAttribution.test.ts | 6 +- app/__tests__/superCatOrderParser.test.ts | 33 ++++----- app/__tests__/wendoverOrderParser.test.ts | 49 +++++++------- app/scripts/resolve-salespeople.impl.ts | 2 +- app/src/lib/adapters/ordorite/runners.ts | 4 +- app/src/lib/duplicateQuotes.ts | 2 +- app/src/lib/homeAccessoryOrders.ts | 10 +-- .../pricing/aestheticMovementOrderParser.ts | 3 +- app/src/lib/pricing/beatrizBallOrderParser.ts | 12 ++-- app/src/lib/pricing/brandWiseOrderParser.ts | 2 +- app/src/lib/pricing/marketTimeOrderParser.ts | 4 +- app/src/lib/pricing/simblistCsvOrderParser.ts | 6 +- app/src/lib/pricing/superCatOrderParser.ts | 11 +-- app/src/lib/pricing/wendoverOrderParser.ts | 23 ++++--- app/src/lib/salesBySalesperson.ts | 8 +-- app/src/lib/staffAttribution.ts | 6 +- docs/domains/import-pipeline.md | 2 +- docs/domains/mailchimp.md | 2 +- docs/domains/reporting.md | 10 +-- docs/domains/service-dispatch.md | 2 +- 35 files changed, 275 insertions(+), 240 deletions(-) diff --git a/app/__tests__/aestheticMovementOrderParser.test.ts b/app/__tests__/aestheticMovementOrderParser.test.ts index 06ffd97a..6de8dd3b 100644 --- a/app/__tests__/aestheticMovementOrderParser.test.ts +++ b/app/__tests__/aestheticMovementOrderParser.test.ts @@ -1,7 +1,8 @@ // /app/__tests__/aestheticMovementOrderParser.test.ts // -// The fixture is condensed from the real Aesthetic Movement order (Printworks, -// PON09056, 6 items, 66 units, $2,688.00). It keeps the two shapes a naive +// The fixture's LAYOUT is condensed from a real Aesthetic Movement order +// (Printworks). The PO number and every price are +// invented -- this repo is public and a vendor's dealer costs are confidential. It keeps the two shapes a naive // parser gets wrong: an item with an ETA/OOS status line between the name and // the UPC (which must not become the name or a price), and an out-of-stock item // that prints NO UPC (its barcode must come out blank, not steal the money). @@ -11,27 +12,27 @@ import { parseAestheticMovementOrderText } from "@/lib/pricing/aestheticMovement const FIXTURE = [ "Vendor: Printworks", "Date: June 12, 2026", - "PO: #PON09056", + "PO: #PON00003", "Earliest Ship Date October 01, 2026", "SKUItemQuantityPriceTotal", "PW00689", "Classic - Tic Tac Toe NEW", "7350108174152", - "12$33.00$396.00", + "12$25.00$300.00", "PW00682", "Classic - Backgammon NEW", "ETA EARLY JULY", "7350108174084", - "12$38.00$456.00", + "12$30.00$360.00", "PW00821", "Reverra - Mahjong", "OOS - ETA EARLY SEPTEMBER", - "6$126.00$756.00", + "6$90.00$540.00", "Number of Items: 3", "Total Quantity: 30", - "Subtotal:$1608.00", + "Subtotal:$1200.00", "Discount:$0.00", - "Order Total:$1608.00", + "Order Total:$1200.00", ].join("\n"); describe("parseAestheticMovementOrderText", () => { @@ -39,7 +40,7 @@ describe("parseAestheticMovementOrderText", () => { it("reads the vendor from the document and the PO number", () => { expect(order.vendorName).toBe("Printworks"); - expect(order.poNumber).toBe("PON09056"); + expect(order.poNumber).toBe("PON00003"); expect(order.shipDate).toBe("October 01, 2026"); }); @@ -49,8 +50,8 @@ describe("parseAestheticMovementOrderText", () => { name: "Classic - Tic Tac Toe NEW", upc: "7350108174152", qty: 12, - unitPrice: 33, - lineTotal: 396, + unitPrice: 25, + lineTotal: 300, }); }); @@ -63,14 +64,14 @@ describe("parseAestheticMovementOrderText", () => { it("exports a blank barcode for an out-of-stock item that prints no UPC", () => { const oos = order.items.find((i) => i.sku === "PW00821"); - expect(oos).toMatchObject({ name: "Reverra - Mahjong", upc: "", qty: 6, unitPrice: 126 }); - expect(oos?.lineTotal).toBe(756); + expect(oos).toMatchObject({ name: "Reverra - Mahjong", upc: "", qty: 6, unitPrice: 90 }); + expect(oos?.lineTotal).toBe(540); }); it("reconciles item count, units, and the order total with no warnings", () => { expect(order.items).toHaveLength(3); expect(order.items.reduce((s, i) => s + i.qty, 0)).toBe(30); - expect(order.items.reduce((s, i) => s + i.lineTotal, 0)).toBeCloseTo(1608, 2); + expect(order.items.reduce((s, i) => s + i.lineTotal, 0)).toBeCloseTo(1200, 2); expect(order.warnings).toEqual([]); }); diff --git a/app/__tests__/beatrizBallOrderParser.test.ts b/app/__tests__/beatrizBallOrderParser.test.ts index cd4cb98a..d5f43ad7 100644 --- a/app/__tests__/beatrizBallOrderParser.test.ts +++ b/app/__tests__/beatrizBallOrderParser.test.ts @@ -1,9 +1,10 @@ // /app/__tests__/beatrizBallOrderParser.test.ts // -// The fixture is condensed from the real Beatriz Ball Sales Orders (SO 0063477 -// net $226.00; SO 0063476 net $2,368.50). It keeps the shapes a naive parser +// The fixture's LAYOUT is condensed from two real Beatriz Ball Sales Orders; +// the order numbers, totals and every price are invented. This repo is public +// and a vendor's wholesale prices are confidential. It keeps the shapes a naive parser // gets wrong: the run-together item line whose item-code/amount boundary is -// ambiguous by shape ("3496"+"99.00" vs "34969"+"9.00"), a wrapped description, +// ambiguous by shape ("3496"+"72.00" vs "34967"+"2.00"), a wrapped description, // the repeated page header, and the free $0 placard line. import { parseBeatrizBallOrderText } from "@/lib/pricing/beatrizBallOrderParser"; @@ -12,25 +13,25 @@ const FIXTURE = [ "Sales Order", "DEPT AT 952426", "ATLANTA, GA 31192-2426", - "PO # PON09066", + "PO # PON00002", "Order Number:", "Order Date:", "Customer Number:", - "0063477", + "0090001", "6/10/2026", - "0008169", + "0090002", "Item Code", "WholesaleAmountMSRP", "Item Description", "Ordered", - "349699.0056.0024.754GLASS Vento Medium Vase (Clear)", + "349672.0045.0018.004GLASS Vento Medium Vase (Clear)", // wrapped description: ends mid-phrase, continues on the next line - "919282.0093.0041.002ENCANTO Claire Small Oval Bowl with Spoon (Bordeaux and ", + "919260.0075.0030.002ENCANTO Claire Small Oval Bowl with Spoon (Bordeaux and ", "White)", // free $0 line — must reconcile at zero and be kept "66440.000.000.001Beatriz Ball metal placard", // net = 99.00 + 82.00 + 0.00 for the three fixture lines - "Net Order:181.00", + "Net Order:132.00", "Freight:0.00", ].join("\n"); @@ -39,24 +40,24 @@ describe("parseBeatrizBallOrderText", () => { it("pins the vendor and reads the customer PO / order number / net total", () => { expect(order.vendorName).toBe("Beatriz Ball"); - expect(order.customerPo).toBe("PON09066"); - expect(order.orderNumber).toBe("0063477"); + expect(order.customerPo).toBe("PON00002"); + expect(order.orderNumber).toBe("0090001"); expect(order.orderDate).toBe("6/10/2026"); - expect(order.printedTotal).toBeCloseTo(181, 2); + expect(order.printedTotal).toBeCloseTo(132, 2); }); it("splits the item-code / amount boundary using wholesale x qty == amount", () => { // "349699.0056.0024.754..." -> code 3496, amount 99.00, msrp 56.00, // wholesale 24.75, qty 4 (NOT code 34969, amount 9.00). const vase = order.items.find((i) => i.itemCode === "3496"); - expect(vase).toMatchObject({ qty: 4, unitPrice: 24.75, lineTotal: 99, msrp: 56 }); + expect(vase).toMatchObject({ qty: 4, unitPrice: 18, lineTotal: 72, msrp: 45 }); expect(vase?.name).toBe("GLASS Vento Medium Vase (Clear)"); }); it("rejoins a description that wraps onto the next line", () => { const bowl = order.items.find((i) => i.itemCode === "9192"); expect(bowl?.name).toBe("ENCANTO Claire Small Oval Bowl with Spoon (Bordeaux and White)"); - expect(bowl).toMatchObject({ qty: 2, unitPrice: 41, lineTotal: 82, msrp: 93 }); + expect(bowl).toMatchObject({ qty: 2, unitPrice: 30, lineTotal: 60, msrp: 75 }); }); it("keeps a free $0 line and reconciles it at zero", () => { @@ -67,13 +68,13 @@ describe("parseBeatrizBallOrderText", () => { it("reconciles the line amounts against the net order with no warnings", () => { expect(order.items).toHaveLength(3); - expect(order.items.reduce((s, i) => s + i.lineTotal, 0)).toBeCloseTo(181, 2); + expect(order.items.reduce((s, i) => s + i.lineTotal, 0)).toBeCloseTo(132, 2); expect(order.warnings).toEqual([]); }); it("warns when the line amounts do not match the net order", () => { const bad = parseBeatrizBallOrderText( - ["349699.0056.0024.754GLASS Vento Medium Vase (Clear)", "Net Order:999.00"].join("\n"), + ["349672.0045.0018.004GLASS Vento Medium Vase (Clear)", "Net Order:999.00"].join("\n"), ); expect(bad.warnings.some((w) => w.includes("does not match the net order"))).toBe(true); }); diff --git a/app/__tests__/brandWiseOrderParser.test.ts b/app/__tests__/brandWiseOrderParser.test.ts index 72a787a4..35f25e76 100644 --- a/app/__tests__/brandWiseOrderParser.test.ts +++ b/app/__tests__/brandWiseOrderParser.test.ts @@ -1,7 +1,8 @@ // /app/__tests__/brandWiseOrderParser.test.ts // -// The fixture is condensed from the real Zodax BrandWise order (B31669979 / -// PON09029, 8 items, $3,322.00). It keeps the two shapes that a naive parser +// The fixture's LAYOUT is condensed from a real Zodax BrandWise order. The PO number and every price are +// invented -- this repo is public and a vendor's dealer costs are confidential. +// It keeps the two shapes that a naive parser // gets wrong: the money line with no "$", and the fully-concatenated single // line with an inch-mark digit right before the qty. @@ -9,24 +10,24 @@ import { parseBrandWiseOrderText } from "@/lib/pricing/brandWiseOrderParser"; const FIXTURE = [ "CUSTOMER OC", - "Sales Order No.B31669979", + "Sales Order No.B31600001", "Order TypeBrandWise Sales Orders", "F.O.B POINTSHIP VIABUYERSHIP DATECANCEL DATECUST P.O. NO.", - "Panorama City, CAFedEx GroundSarah Levatino8/24/20266/10/2027PON09029", + "Panorama City, CAFedEx GroundDana Whitfield8/24/20266/10/2027PON00005", "ORDER DATETERMSSALES PERSONSTORE #", "6/10/2026Net 30 DaysAPS00 Atlanta Showroom", "IMAGESKUDESCRIPTIONQTY ORDUNITS", // multi-line item, description wraps 'IN-8222The Cadier Wooden Wall Mirrors 23.75" x', '35.5"', - "4EA200.00800.00", + "4EA250.001000.00", "Available Qty:21", "Incoming Qty:0", // fully-concatenated single line, inch mark right before the qty - 'IN-8432Chevron Wood Box- 13"x 6.5"x 3.25"4EA57.00228.00', + 'IN-8432Chevron Wood Box- 13"x 6.5"x 3.25"4EA70.00280.00', "Available Qty:50", "TOTAL IN US$:", - "1,028.00", + "1,280.00", "Page: 2 of 2", ].join("\n"); @@ -34,26 +35,26 @@ describe("parseBrandWiseOrderText", () => { const order = parseBrandWiseOrderText(FIXTURE); it("reads the sales order number and the customer PO", () => { - expect(order.salesOrderNo).toBe("B31669979"); - expect(order.poNumber).toBe("PON09029"); + expect(order.salesOrderNo).toBe("B31600001"); + expect(order.poNumber).toBe("PON00005"); }); it("captures the total even when the label and value split across two lines", () => { - expect(order.printedTotal).toBe(1028); + expect(order.printedTotal).toBe(1280); }); it("parses a money line that has no dollar sign", () => { - // "4EA200.00800.00" -> qty 4, UOM EA, price 200.00, total 800.00. The split + // "4EA250.001000.00" -> qty 4, UOM EA, price 200.00, total 800.00. The split // is settled by qty x price == total, since there is no "$" to lean on. const mirror = order.items.find((i) => i.sku === "IN-8222"); - expect(mirror).toMatchObject({ qty: 4, uom: "EA", unitPrice: 200, lineTotal: 800 }); + expect(mirror).toMatchObject({ qty: 4, uom: "EA", unitPrice: 250, lineTotal: 1000 }); expect(mirror?.name).toBe('The Cadier Wooden Wall Mirrors 23.75" x 35.5"'); }); it("splits a fully-concatenated line despite an inch mark before the qty", () => { // "...3.25\"4EA57.00228.00" — the "4" is the qty, not part of "3.25". const box = order.items.find((i) => i.sku === "IN-8432"); - expect(box).toMatchObject({ qty: 4, unitPrice: 57, lineTotal: 228 }); + expect(box).toMatchObject({ qty: 4, unitPrice: 70, lineTotal: 280 }); expect(box?.name).toBe('Chevron Wood Box- 13"x 6.5"x 3.25"'); }); diff --git a/app/__tests__/clientDataTripwire.test.ts b/app/__tests__/clientDataTripwire.test.ts index 53f78738..c85f606a 100644 --- a/app/__tests__/clientDataTripwire.test.ts +++ b/app/__tests__/clientDataTripwire.test.ts @@ -39,7 +39,31 @@ const FORBIDDEN: { pattern: string; what: string }[] = [ { pattern: "sammyg40|joneil|gstone|wcope", what: "real staff email local-parts" }, { pattern: "greenstein|panagy|dransfield|matheny|tenerow", what: "real people's surnames" }, { pattern: "sorbo|nordquist|calkins|barnum|homan|favale|vantongeren", what: "real people's surnames" }, - { pattern: "860-470-3653|213-623-1345", what: "real phone numbers" }, + { pattern: "860-470-3653|213-623-1345|860-388-0891|860-388-3692", what: "real phone and fax numbers" }, + { pattern: "ciccone|dwyer|germano|filippone|levatino|sorbo|demik", what: "real people's surnames" }, + // Matched as whole names, because each of these surnames is far too common to + // use on its own -- a bare "kelly" or "warren" would fire on ordinary code and + // the entry would get deleted rather than fixed. + { + pattern: + "erin kelly|alex robertson|rebecca warren|mary goodwin|reginald adams|madison baker|susan roberts|jamie young|sarah levatino|amy sage|shannon martin|lisa ritz", + what: "real people's full names", + }, + { pattern: "rwarren@|sammyg40", what: "real staff email local-parts" }, + { pattern: "57 princeton lane|298 highland avenue|2 main street|8 monticello|east lyme", what: "real street addresses" }, + { pattern: "06475", what: "the pilot deployment's town ZIP" }, + // Vendor dealer/wholesale pricing and the document numbers that tie a price + // to a real order. Publishing another business's trade terms is the harm -- + // naming the vendor is not, which is why the brands themselves are absent here. + { + pattern: "PON090[0-9][0-9]|B31669979|153642-070126|1000292821|0063477|0063476|31680534|77234f1af6", + what: "real vendor order and document numbers", + }, + { + pattern: "10,976\\.49|32,108\\.67|1,057\\.62|1,188\\.57|22,373\\.00|2,688\\.00|3,322\\.00|2,196\\.00|722\\.74|2,368\\.50", + what: "real vendor order totals and dealer prices", + }, + { pattern: "costs top out at", what: "a disclosure of a vendor's catalog price ceiling" }, ]; /** diff --git a/app/__tests__/homeAccessoryOrders.test.ts b/app/__tests__/homeAccessoryOrders.test.ts index aebb2682..46a21a01 100644 --- a/app/__tests__/homeAccessoryOrders.test.ts +++ b/app/__tests__/homeAccessoryOrders.test.ts @@ -46,7 +46,7 @@ import type { BeatrizBallOrder } from "@/lib/pricing/beatrizBallOrderParser"; function bundle(overrides: Partial = {}): KKOrderBundle { return { vendorName: "", - customerPo: "PON09025", + customerPo: "PON00006", orderDate: "Jun 15, 2026", orders: [ { @@ -339,7 +339,7 @@ describe("normalizeKKBundle", () => { it("carries customerPo and orderDate through from the bundle", () => { const draft = normalizeKKBundle(bundle()); - expect(draft.customerPo).toBe("PON09025"); + expect(draft.customerPo).toBe("PON00006"); expect(draft.orderDate).toBe("Jun 15, 2026"); }); @@ -397,7 +397,7 @@ function wendoverItem(over: Partial = {}) { return { sku: "WLD3511", name: "Before the Rain Customized", - lineTotal: 1057.62, + lineTotal: 1200, unitPrice: 352.54, qty: 3, medium: "Canvas", @@ -413,9 +413,9 @@ function wendoverItem(over: Partial = {}) { function wendoverOrder(over: Partial = {}): WendoverOrder { return { vendorName: "Wendover Art Group", - orderNumber: "1000292821", + orderNumber: "1000000001", orderDate: "Jul 13, 2026, 12:26:21 PM", - printedSubtotal: 1057.62, + printedSubtotal: 1200, items: [wendoverItem()], warnings: [], ...over, @@ -462,8 +462,8 @@ describe("normalizeWendoverOrder", () => { it("references every row to the order number so one draft PO is created", () => { const draft = normalizeWendoverOrder(wendoverOrder()); - expect(draft.rows.every((r) => r.reference === "1000292821")).toBe(true); - expect(draft.orders).toEqual([{ orderNumber: "1000292821", requiredDate: "", itemCount: 1 }]); + expect(draft.rows.every((r) => r.reference === "1000000001")).toBe(true); + expect(draft.orders).toEqual([{ orderNumber: "1000000001", requiredDate: "", itemCount: 1 }]); }); it("prefers the registry's exact catalog vendor name", () => { @@ -496,7 +496,7 @@ describe("normalizeWendoverOrder", () => { function mtOrder(over: Partial = {}): MarketTimeOrder { return { vendorName: "Graf & Lantz Inc", - poNumber: "PON09057", + poNumber: "PON00004", orderDate: "06/11/2026", shipDate: "09/22/2026", printedSubtotal: 84, @@ -538,9 +538,9 @@ describe("normalizeMarketTimeOrder", () => { it("references every row to the PO number", () => { const draft = normalizeMarketTimeOrder(mtOrder()); - expect(draft.rows.every((r) => r.reference === "PON09057")).toBe(true); + expect(draft.rows.every((r) => r.reference === "PON00004")).toBe(true); expect(draft.orders).toEqual([ - { orderNumber: "PON09057", requiredDate: "09/22/2026", itemCount: 1 }, + { orderNumber: "PON00004", requiredDate: "09/22/2026", itemCount: 1 }, ]); }); @@ -575,8 +575,8 @@ describe("normalizeMarketTimeOrder", () => { describe("normalizeBrandWiseOrder", () => { function bwOrder(over: Partial = {}): BrandWiseOrder { return { - salesOrderNo: "B31669979", - poNumber: "PON09029", + salesOrderNo: "B31600001", + poNumber: "PON00005", orderDate: "6/10/2026", shipDate: "8/24/2026", printedTotal: 800, @@ -606,8 +606,8 @@ describe("normalizeBrandWiseOrder", () => { const format = HOME_ACCESSORY_FORMATS.find((f) => f.id === "brandwise-zodax"); const draft = normalizeBrandWiseOrder(bwOrder(), format); expect(draft.vendorName).toBe("Zodax"); - expect(draft.rows[0].reference).toBe("PON09029"); - expect(draft.orders[0]).toMatchObject({ orderNumber: "PON09029", itemCount: 1 }); + expect(draft.rows[0].reference).toBe("PON00005"); + expect(draft.orders[0]).toMatchObject({ orderNumber: "PON00005", itemCount: 1 }); }); it("carries the parser's warnings through", () => { @@ -620,7 +620,7 @@ describe("normalizeAestheticMovementOrder", () => { function amOrder(over: Partial = {}): AestheticMovementOrder { return { vendorName: "Printworks", - poNumber: "PON09056", + poNumber: "PON00003", shipDate: "October 01, 2026", printedTotal: 2688, printedItems: 2, @@ -664,8 +664,8 @@ describe("normalizeAestheticMovementOrder", () => { const format = HOME_ACCESSORY_FORMATS.find((f) => f.id === "aesthetic-movement"); const draft = normalizeAestheticMovementOrder(amOrder(), format); expect(draft.vendorName).toBe("Printworks"); - expect(draft.rows[0].reference).toBe("PON09056"); - expect(draft.orders[0]).toMatchObject({ orderNumber: "PON09056", itemCount: 2 }); + expect(draft.rows[0].reference).toBe("PON00003"); + expect(draft.orders[0]).toMatchObject({ orderNumber: "PON00003", itemCount: 2 }); }); it("carries the parser's warnings through", () => { @@ -677,8 +677,8 @@ describe("normalizeAestheticMovementOrder", () => { describe("normalizeSuperCatOrder", () => { function scOrder(over: Partial = {}): SuperCatOrder { return { - vendorName: "Jamie Young Company", - orderNumber: "153642-070126-175-1", + vendorName: "Dana Whitfield Company", + orderNumber: "990001-070126-175-1", customerPo: "", orderDate: "7/1/26", shipDate: "8/11/26", @@ -708,10 +708,10 @@ describe("normalizeSuperCatOrder", () => { it("reads the vendor from the document and references the order number", () => { const format = HOME_ACCESSORY_FORMATS.find((f) => f.id === "supercat"); const draft = normalizeSuperCatOrder(scOrder(), format); - expect(draft.vendorName).toBe("Jamie Young Company"); - expect(draft.rows[0].reference).toBe("153642-070126-175-1"); + expect(draft.vendorName).toBe("Dana Whitfield Company"); + expect(draft.rows[0].reference).toBe("990001-070126-175-1"); expect(draft.orders[0]).toMatchObject({ - orderNumber: "153642-070126-175-1", + orderNumber: "990001-070126-175-1", itemCount: 1, }); }); @@ -727,10 +727,10 @@ describe("normalizeSimblistOrder", () => { return { vendorName: "MAISON ZOE FORD", repGroup: "Simblist Group", - poNumber: "PON09047", + poNumber: "PON00001", orderDate: "2026-06-11", shipDate: "2026-09-01", - printedTotal: 722.74, + printedTotal: 615.6, items: [ { itemNumber: "ZFUSA03-C", @@ -764,8 +764,8 @@ describe("normalizeSimblistOrder", () => { const format = HOME_ACCESSORY_FORMATS.find((f) => f.id === "maison-zoe-ford"); const draft = normalizeSimblistOrder(smOrder(), format); expect(draft.vendorName).toBe("MAISON ZOE FORD"); - expect(draft.rows[0].reference).toBe("PON09047"); - expect(draft.orders[0]).toMatchObject({ orderNumber: "PON09047", itemCount: 1 }); + expect(draft.rows[0].reference).toBe("PON00001"); + expect(draft.orders[0]).toMatchObject({ orderNumber: "PON00001", itemCount: 1 }); }); it("carries the parser's discount warning through", () => { @@ -778,8 +778,8 @@ describe("normalizeBeatrizBallOrder", () => { function bbOrder(over: Partial = {}): BeatrizBallOrder { return { vendorName: "Beatriz Ball", - orderNumber: "0063477", - customerPo: "PON09066", + orderNumber: "0090001", + customerPo: "PON00002", orderDate: "6/10/2026", printedTotal: 226, items: [ @@ -824,8 +824,8 @@ describe("normalizeBeatrizBallOrder", () => { const format = HOME_ACCESSORY_FORMATS.find((f) => f.id === "beatriz-ball"); const draft = normalizeBeatrizBallOrder(bbOrder(), format); expect(draft.vendorName).toBe("Beatriz Ball"); - expect(draft.rows[0].reference).toBe("PON09066"); - expect(draft.orders[0]).toMatchObject({ orderNumber: "PON09066", itemCount: 2 }); + expect(draft.rows[0].reference).toBe("PON00002"); + expect(draft.orders[0]).toMatchObject({ orderNumber: "PON00002", itemCount: 2 }); }); it("carries the parser's warnings through", () => { diff --git a/app/__tests__/homeAccessoryRows.test.ts b/app/__tests__/homeAccessoryRows.test.ts index 7fbd0f6d..72f6b06c 100644 --- a/app/__tests__/homeAccessoryRows.test.ts +++ b/app/__tests__/homeAccessoryRows.test.ts @@ -42,7 +42,7 @@ function row(overrides: Partial = {}): HomeAccessoryExpo function draft(rows: HomeAccessoryExportRow[]): HomeAccessoryDraft { return { vendorName: "K & K Interiors", - customerPo: "PON09025", + customerPo: "PON00006", orderDate: "Jun 15, 2026", orders: [{ orderNumber: "0002592361", requiredDate: "9/1/26", itemCount: rows.length }], rows, @@ -232,9 +232,9 @@ describe("composeHomeAccessoryRows — PO numbers per order", () => { // The bug this guards: a single run-level PO number overriding every // row would silently merge a two-order bundle into ONE draft PO. const rows = composeHomeAccessoryRows( - input({ draft: twoOrderDraft(), poNumbers: { "0002592360": "PON09025" } }), + input({ draft: twoOrderDraft(), poNumbers: { "0002592360": "PON00006" } }), ); - expect(rows.map((r) => r.reference)).toEqual(["PON09025", "0002592361"]); + expect(rows.map((r) => r.reference)).toEqual(["PON00006", "0002592361"]); }); it("keeps two typed POs distinct, so two draft POs still get created", () => { @@ -274,7 +274,7 @@ describe("composeHomeAccessoryRows — PO numbers per order", () => { const rows = composeHomeAccessoryRows( input({ draft: d, - poNumbers: { "0002592360": "PON09025" }, + poNumbers: { "0002592360": "PON00006" }, splits: { 0: [ { suffix: "LG", cost: "22.79" }, @@ -285,7 +285,7 @@ describe("composeHomeAccessoryRows — PO numbers per order", () => { }), ); expect(rows).toHaveLength(3); - expect(rows.every((r) => r.reference === "PON09025")).toBe(true); + expect(rows.every((r) => r.reference === "PON00006")).toBe(true); }); }); diff --git a/app/__tests__/integration/homeAccessoryOrderCommit.integration.test.ts b/app/__tests__/integration/homeAccessoryOrderCommit.integration.test.ts index 4da6cc2f..fdaf963c 100644 --- a/app/__tests__/integration/homeAccessoryOrderCommit.integration.test.ts +++ b/app/__tests__/integration/homeAccessoryOrderCommit.integration.test.ts @@ -244,8 +244,8 @@ describe("Home Accessory Order Import commit — real DB", () => { sourceLabel: "Home Accessory Order Import — Wendover Art Group", }; const rows = [ - effectiveRow({ key: "0", reference: "1000292821", poExcluded: false }), - effectiveRow({ key: "1", partNumber: "WLD9999", reference: "1000292821", poExcluded: true }), + effectiveRow({ key: "0", reference: "1000000001", poExcluded: false }), + effectiveRow({ key: "1", partNumber: "WLD9999", reference: "1000000001", poExcluded: true }), ]; const result = await commitRows(rows, ctx); diff --git a/app/__tests__/integration/mailchimpAttributionRewriteChain.integration.test.ts b/app/__tests__/integration/mailchimpAttributionRewriteChain.integration.test.ts index cd0a0c25..8331fc23 100644 --- a/app/__tests__/integration/mailchimpAttributionRewriteChain.integration.test.ts +++ b/app/__tests__/integration/mailchimpAttributionRewriteChain.integration.test.ts @@ -4,7 +4,7 @@ // Impact report's attribution query against a real Postgres database. // This test exists because of a user-reported regression: // -// Barbara Germano (cust #6480) showed 2 orders for $88,624 attributed +// Rowan Fairbairn (cust #6480) showed 2 orders for $88,624 attributed // to a campaign when her actual NET spend was $61,922. The missing // $26,701-ish was the accounting return SR-013491 (status=RETURNED, // negative netPrice rows) that the report's WHERE clause silently @@ -15,7 +15,7 @@ // 1. Base order alone — sum equals the base netPrice (sanity check). // 2. Base + accounting return — sum equals ZERO (return nets the base). // 3. Full rewrite chain (base + return + rewrite) — sum equals JUST the -// rewrite, exactly mirroring the Barbara Germano case. +// rewrite, exactly mirroring the Rowan Fairbairn case. // // Test queries the same WHERE clause the API uses (status IN // SALES_REVENUE_STATUSES + lineItemStatus != CANCELLED), so a regression @@ -143,7 +143,7 @@ describe("Mailchimp attribution — rewrite chain net (real DB)", () => { expect(await sumNetPriceForCustomer(customer.id)).toBe(0); }); - it("nets to just the rewrite when full base + return + rewrite chain exists (Barbara Germano case)", async () => { + it("nets to just the rewrite when full base + return + rewrite chain exists (Rowan Fairbairn case)", async () => { // Reproduces the user-reported regression. The base order // ($44,312) + matching return (-$44,312) + rewrite ($44,312.01) // must net to $44,312.01 — exactly the rewrite. @@ -151,7 +151,7 @@ describe("Mailchimp attribution — rewrite chain net (real DB)", () => { // Previously (filter = ["ORDER", "FULFILLED"]) the query // returned base + rewrite = $88,624.01, double-counting. const customer = await prisma.customer.create({ - data: { firstName: "Barbara", lastName: "Germano" }, + data: { firstName: "Rowan", lastName: "Fairbairn" }, }); // Base await prisma.salesOrder.create({ diff --git a/app/__tests__/integration/runServiceCaseSheetImport.integration.test.ts b/app/__tests__/integration/runServiceCaseSheetImport.integration.test.ts index b8532506..e518e8dc 100644 --- a/app/__tests__/integration/runServiceCaseSheetImport.integration.test.ts +++ b/app/__tests__/integration/runServiceCaseSheetImport.integration.test.ts @@ -269,7 +269,7 @@ describe("runServiceCaseSheetImport — real-DB scenarios", () => { // QUARANTINED -- pre-existing bug: extractSalesOrderTokens does not match hyphenated // order numbers (SO-NNNNN); genericization miss. Tracked for a focused fix. it.skip("matches SalesOrder by orderno (including rewrite suffix)", async () => { - await seedCustomer({ firstName: "Karen", lastName: "Dwyer" }); + await seedCustomer({ firstName: "Karen", lastName: "Dunmore" }); // The orderno cell in the sheet often has multiple shapes mashed // together. Verify both straight + " - A" forms resolve. diff --git a/app/__tests__/kkOrderParser.test.ts b/app/__tests__/kkOrderParser.test.ts index fd994c4d..58dfa70d 100644 --- a/app/__tests__/kkOrderParser.test.ts +++ b/app/__tests__/kkOrderParser.test.ts @@ -2,7 +2,7 @@ // // Pins the K & K Interiors "Order Detail" bundle parser against synthetic // fixtures built from the verified OEORD_BUNDLE layout (real extraction: -// 2 orders / 28 items / customerPo PON09025 / zero warnings, printed totals +// 2 orders / 28 items / customerPo PON00006 / zero warnings, printed totals // reconciling to the penny). Fixtures below are minimal reconstructions of // that layout, not the real PDF text. diff --git a/app/__tests__/marketTimeOrderParser.test.ts b/app/__tests__/marketTimeOrderParser.test.ts index 5aa4ae1a..1667a877 100644 --- a/app/__tests__/marketTimeOrderParser.test.ts +++ b/app/__tests__/marketTimeOrderParser.test.ts @@ -1,65 +1,66 @@ // /app/__tests__/marketTimeOrderParser.test.ts // -// The fixture is the real Harper Group / MarketTime PO for Graf & Lantz -// (PON09057, 06/11/2026, 11 SKUs / 73 units / $2,196.00), condensed. +// The fixture's LAYOUT is a real Harper Group / MarketTime PO for Graf & Lantz, +// condensed. The PO number and every price are +// invented -- this repo is public and a vendor's dealer costs are confidential. import { parseMarketTimeOrderText, splitUpcPriceTotal } from "@/lib/pricing/marketTimeOrderParser"; const FIXTURE = [ - "Purchase Order by - ID# 31680534MarketTime", + "Purchase Order by - ID# 31600001MarketTime", " Season/Program:", " 06/11/2026Order Date:", // value BEFORE the label " Ship Date:09/22/2026", // value AFTER the label "Special Instructions: This is just a quote please hold", - "PON09057", + "PON00004", "PO #", "Graf & Lantz Inc", "QtyImageItem #NameUPCPriceUQUOMTotal", "1GL60BIN50FTLG", "Merino Wool Large Bin - Feather ", "(Avail:08/01/26)", - "84002724476284.00$84.00", + "84002724476255.00$55.00", "6GL70TECH10GN16IN", 'Merino Wool 16" Laptop Computer ', "Sleeve - Granite V (Avail:07/10/26)", - "84002724051149.00$294.00", - "PO # PON09057 (cont'd)Cust #MFR: Graf & Lantz IncCustomer: Riverbend Home", + "84002724051135.00$210.00", + "PO # PON00004 (cont'd)Cust #MFR: Graf & Lantz IncCustomer: Riverbend Home", " Page of 22", "10GL10WINO10-12AUTU", "Wine-O's Merino Wool Round Wine ", "Markers - Autumn (Avail:06/08/26)", - "84002720301112.00$120.00", + "84002720301115.00$150.00", "3 Skus | 17 Units", - "$498.00", + "$415.00", "$0.00", - "$498.00", + "$415.00", "Sub Total:", ].join("\n"); describe("splitUpcPriceTotal — the concatenation trap", () => { it("uses the arithmetic to settle where the UPC ends", () => { - // "84002724476284.00$84.00" has NO separator. A greedy digit match reads a - // 13-digit UPC and leaves "4.00" as the price — a silent 20x cost error + // "84002724476255.00$55.00" has NO separator. A greedy digit match reads a + // 13-digit UPC and leaves "4.00" as the price — a silent 11x cost error // that still parses cleanly. qty x price == total is the only thing that // can tell the readings apart. - expect(splitUpcPriceTotal("84002724476284.00$84.00", 1)).toEqual({ + expect(splitUpcPriceTotal("84002724476255.00$55.00", 1)).toEqual({ upc: "840027244762", - unitPrice: 84, - lineTotal: 84, + unitPrice: 55, + lineTotal: 55, }); }); it("refuses rather than guessing when no reading reconciles", () => { // Same line, wrong quantity: neither the 12- nor the 13-digit reading // satisfies the arithmetic, so it reports instead of picking one. - expect(splitUpcPriceTotal("84002724476284.00$84.00", 5)).toBeNull(); + expect(splitUpcPriceTotal("84002724476255.00$55.00", 5)).toBeNull(); }); it("splits a multi-unit line correctly", () => { - expect(splitUpcPriceTotal("84002724051149.00$294.00", 6)).toEqual({ + expect(splitUpcPriceTotal("84002724051135.00$210.00", 6)).toEqual({ upc: "840027240511", - unitPrice: 49, - lineTotal: 294, + unitPrice: 35, + lineTotal: 210, }); }); @@ -83,7 +84,7 @@ describe("parseMarketTimeOrderText", () => { // Order Date prints "06/11/2026Order Date:" while Ship Date prints // "Ship Date:09/22/2026" — right- vs left-aligned cells. Handling only one // direction leaves the other silently blank. - expect(order.poNumber).toBe("PON09057"); + expect(order.poNumber).toBe("PON00004"); expect(order.orderDate).toBe("06/11/2026"); expect(order.shipDate).toBe("09/22/2026"); }); @@ -104,8 +105,8 @@ describe("parseMarketTimeOrderText", () => { // every cost by the quantity. const sleeve = order.items.find((i) => i.itemNumber === "GL70TECH10GN16IN"); expect(sleeve?.qty).toBe(6); - expect(sleeve?.unitPrice).toBe(49); - expect(sleeve?.lineTotal).toBe(294); + expect(sleeve?.unitPrice).toBe(35); + expect(sleeve?.lineTotal).toBe(210); }); it("splits the concatenated qty and item number", () => { @@ -138,14 +139,14 @@ describe("parseMarketTimeOrderText", () => { expect(order.warnings).toEqual([]); expect(order.printedSkus).toBe(3); expect(order.printedUnits).toBe(17); - expect(order.printedSubtotal).toBe(498); - expect(order.items.reduce((s, i) => s + i.lineTotal, 0)).toBeCloseTo(498, 2); + expect(order.printedSubtotal).toBe(415); + expect(order.items.reduce((s, i) => s + i.lineTotal, 0)).toBeCloseTo(415, 2); expect(order.items.reduce((s, i) => s + i.qty, 0)).toBe(17); }); it("warns when the counts do not match what the document printed", () => { const short = parseMarketTimeOrderText( - ["1GL60BIN50FTLG", "A bin", "84002724476284.00$84.00", "9 Skus | 99 Units"].join("\n"), + ["1GL60BIN50FTLG", "A bin", "84002724476255.00$55.00", "9 Skus | 99 Units"].join("\n"), ); expect(short.warnings.some((w) => w.includes("9 SKUs"))).toBe(true); expect(short.warnings.some((w) => w.includes("99 units"))).toBe(true); @@ -160,7 +161,7 @@ describe("parseMarketTimeOrderText", () => { it("says nothing about holds on an ordinary order", () => { const plain = parseMarketTimeOrderText( - ["PON09999", "1GL60BIN50FTLG", "A bin", "84002724476284.00$84.00"].join("\n"), + ["PON09999", "1GL60BIN50FTLG", "A bin", "84002724476255.00$55.00"].join("\n"), ); expect(plain.holdNote).toBe(""); expect(plain.warnings).toEqual([]); @@ -171,8 +172,8 @@ describe("parseMarketTimeOrderText — the UQ/UOM + numeric-item variants", () = // Other MarketTime vendors print UQ/UOM columns in the money line and, for // book vendors (Simon & Schuster via Anne McGilvray), use the ISBN as the // item number — sometimes with the whole block concatenated onto one line. - // Verified against the real orders (Graphique SO9939511, Anne McGilvray - // PON09059) before these fixtures were condensed from them. + // Verified against the real orders (Graphique SO9900001, a second rep group + // PON00007) before these fixtures were condensed from them. it("reads a money line with UQ + UOM between the price and the total", () => { // "...7.50" + "1EACH" + "$90.00" — the Graf & Lantz dialect had neither. @@ -250,10 +251,10 @@ describe("parseMarketTimeOrderText — the UQ/UOM + numeric-item variants", () = it("still parses Graf & Lantz's simpler dialect (no UQ/UOM, letter items)", () => { // Regression guard: the extension must not disturb the original format. const order = parseMarketTimeOrderText( - ["PON09057", "1GL60BIN50FTLG", "Merino Wool Large Bin", "84002724476284.00$84.00"].join("\n"), + ["PON00004", "1GL60BIN50FTLG", "Merino Wool Large Bin", "84002724476255.00$55.00"].join("\n"), ); expect(order.items).toHaveLength(1); - expect(order.items[0]).toMatchObject({ itemNumber: "GL60BIN50FTLG", qty: 1, unitPrice: 84 }); + expect(order.items[0]).toMatchObject({ itemNumber: "GL60BIN50FTLG", qty: 1, unitPrice: 55 }); }); it("falls back to the MarketTime order id when a document carries no buyer PON", () => { @@ -276,12 +277,12 @@ describe("parseMarketTimeOrderText — the UQ/UOM + numeric-item variants", () = const order = parseMarketTimeOrderText( [ "Purchase Order by - ID# 32008813MarketTime", - "PON09057", + "PON00004", "1GL60BIN50FTLG", "Merino Wool Large Bin", - "84002724476284.00$84.00", + "84002724476255.00$55.00", ].join("\n"), ); - expect(order.poNumber).toBe("PON09057"); + expect(order.poNumber).toBe("PON00004"); }); }); diff --git a/app/__tests__/reports.salesRevenueStatusFilter.test.ts b/app/__tests__/reports.salesRevenueStatusFilter.test.ts index 4bf590db..80ef2230 100644 --- a/app/__tests__/reports.salesRevenueStatusFilter.test.ts +++ b/app/__tests__/reports.salesRevenueStatusFilter.test.ts @@ -8,7 +8,7 @@ // (RETURNED orders must be INCLUDED in customer-revenue sums so the // negative netPrice rows net out rewrite chains and refunds). // -// User-reported origin (Barbara Germano, 2026-05-13): the Mailchimp +// User-reported origin (Rowan Fairbairn, 2026-05-13): the Mailchimp // Campaign Impact report attributed $88,624 to her engagement when // her actual net spend was $61,922. The missing $26K was the // accounting return SR-013491 that the WHERE clause silently diff --git a/app/__tests__/salesBySalesperson.helpers.test.ts b/app/__tests__/salesBySalesperson.helpers.test.ts index 8a1d6a53..ab6dca28 100644 --- a/app/__tests__/salesBySalesperson.helpers.test.ts +++ b/app/__tests__/salesBySalesperson.helpers.test.ts @@ -12,8 +12,8 @@ // against the 5 canonical the POS names (no partNo match, no // contains-substring) — see post-failure log 2026-05-01 // - false-positive guard: lines with productName containing -// "delivery" / "freight" as substrings (Susan Roberts SO-38708, -// "Delivery to 8 Monticello Dr East Lyme") are NOT excluded +// "delivery" / "freight" as substrings (Cheryl Holloway SO-38708, +// "Delivery to 12 Larkfield Ln Wexbridge") are NOT excluded // - applySalesPersonFilter matches by both id and name (the FK-NULL // fix from PR #162) @@ -76,7 +76,7 @@ describe("buildLineItemWhere", () => { it("explicitly OR-includes NULL productName so the three-valued-logic NULL trap can't drop real product lines", () => { // Tripwire test: this is the exact bug shape that produced the - // 2026-05-05 Julia Filippone SO-1660 outage. If a future refactor + // 2026-05-05 Marta Vandeleur SO-1660 outage. If a future refactor // collapses back to `where.NOT = { OR: [equals 'A', equals 'B'] }`, // this assertion fails first. const where = buildLineItemWhere([]); @@ -121,8 +121,8 @@ describe("buildLineItemWhere", () => { // `contains: "freight"` which matched any productName with that // substring. That excluded real products like CASP-91510.29 // ("Cards Special Delivery Baby Shower"), AL-EARLYBIRD ("Early - // Bird Delivery Request Quick Ship"), and Susan Roberts' - // 100216574 ("Delivery to 8 Monticello Dr East Lyme") — 40 lines + // Bird Delivery Request Quick Ship"), and Cheryl Holloway' + // 100216574 ("Delivery to 12 Larkfield Ln Wexbridge") — 40 lines // / $36K of real April sales mis-excluded. Switch to `equals`. const where = buildLineItemWhere([]); const serialized = JSON.stringify(where); diff --git a/app/__tests__/serviceCaseSheetImport.test.ts b/app/__tests__/serviceCaseSheetImport.test.ts index 17c51b9a..198394ed 100644 --- a/app/__tests__/serviceCaseSheetImport.test.ts +++ b/app/__tests__/serviceCaseSheetImport.test.ts @@ -24,12 +24,12 @@ describe("parsePersonXml", () => { it("extracts displayName + userId for every ", () => { const xml = ` - + `; const map = parsePersonXml(xml); expect(map.size).toBe(2); expect(map.get("aaaaaaa1-bbbb-cccc-dddd-eeeeeeeeeeee")?.displayName).toBe("Rebecca Wexford"); - expect(map.get("fffffff2-aaaa-bbbb-cccc-dddddddddddd")?.userId).toBe("rwarren@example.com"); + expect(map.get("fffffff2-aaaa-bbbb-cccc-dddddddddddd")?.userId).toBe("rwexford@example.com"); }); it("tolerates attribute orderings in either direction", () => { @@ -180,7 +180,7 @@ describe("resolveAuthor", () => { const staffByEmail = new Map([["alex@example.com", 100]]); const staffByName = new Map([ ["rebecca wexford", 101], - ["alex robertson", 100], + ["alex rowntree", 100], ]); it("matches by email when userId is set", () => { diff --git a/app/__tests__/simblistCsvOrderParser.test.ts b/app/__tests__/simblistCsvOrderParser.test.ts index c32b1658..ca707d4f 100644 --- a/app/__tests__/simblistCsvOrderParser.test.ts +++ b/app/__tests__/simblistCsvOrderParser.test.ts @@ -1,6 +1,8 @@ // /app/__tests__/simblistCsvOrderParser.test.ts // -// The fixture is the real Simblist Group / Maison Zoe Ford export (PON09047, 5 +// The fixture is a real Simblist Group / Maison Zoe Ford export (PO number and every +// price invented -- this repo is public and a vendor's dealer costs are +// confidential). It keeps the 5 // items). It keeps the two-table shape (order-header pair + item table) and the // order-level discount (line totals summing above the order total) that the // parser must surface rather than silently apply. @@ -9,13 +11,13 @@ import { parseSimblistCsvText } from "@/lib/pricing/simblistCsvOrderParser"; const FIXTURE = [ "RepGroup,Manufacturer,PO #,Order Date,Request Date,Ship Date,Cancel Date,Order Total,Customer Name", - "Simblist Group,MAISON ZOE FORD,PON09047,2026-06-11,2026-09-01,2026-09-01,,722.74,RIVERBEND HOME", + "Simblist Group,MAISON ZOE FORD,PON00001,2026-06-11,2026-09-01,2026-09-01,,615.60,RIVERBEND HOME", "Sequence #,Item Number,Name,Description,Quantity,Unit Price,Unit Qty,Item Discount,UPC,Unit of measure,Size,Color,Style,Notes,Retailer Item Number,List Price,Item Status,Extended Price,Total Price", - '3,ZFUSA03-C,Big Time Brownie Mix - case pack of 6,,2,53.94,,0.0,10628678860152,,,,,"Only available to ship on September 1, 2026",,17.99,,,$107.88', - "6,ZFUSA07-C,Speedy Cinnamon Roll Mix - case pack of 6,,2,41.94,,0.0,10628678860176,,,,,,,13.99,,,$83.88", - "1,ZFUSA13-C,Extraordinary Brownie Hot Chocolate,,6,71.92,,0.0,10628678860213,,,,,,,17.99,,,$431.52", - "2,ZFUSA20-C,Quick Focaccia Style Flatbread Mix,,2,35.94,,0.0,10628678860299,,,,,,,11.99,,,$71.88", - "5,ZFUSA21-C,Outrageous Ginger Cookie Mix - case pack of 6,,2,53.94,,0.0,10628678860336,,,,,,,17.99,,,$107.88", + '3,ZFUSA03-C,Big Time Brownie Mix - case pack of 6,,2,48.00,,0.0,10628678860152,,,,,"Only available to ship on September 1, 2026",,15.00,,,$96.00', + "6,ZFUSA07-C,Speedy Cinnamon Roll Mix - case pack of 6,,2,36.00,,0.0,10628678860176,,,,,,,12.00,,,$72.00", + "1,ZFUSA13-C,Extraordinary Brownie Hot Chocolate,,6,60.00,,0.0,10628678860213,,,,,,,15.00,,,$360.00", + "2,ZFUSA20-C,Quick Focaccia Style Flatbread Mix,,2,30.00,,0.0,10628678860299,,,,,,,10.00,,,$60.00", + "5,ZFUSA21-C,Outrageous Ginger Cookie Mix - case pack of 6,,2,48.00,,0.0,10628678860336,,,,,,,15.00,,,$96.00", ].join("\n"); describe("parseSimblistCsvText", () => { @@ -24,9 +26,9 @@ describe("parseSimblistCsvText", () => { it("reads the manufacturer, rep group, and PO from the order-header row", () => { expect(order.vendorName).toBe("MAISON ZOE FORD"); expect(order.repGroup).toBe("Simblist Group"); - expect(order.poNumber).toBe("PON09047"); + expect(order.poNumber).toBe("PON00001"); expect(order.shipDate).toBe("2026-09-01"); - expect(order.printedTotal).toBeCloseTo(722.74, 2); + expect(order.printedTotal).toBeCloseTo(615.6, 2); }); it("reads columns by name and confirms qty x Unit Price == Total Price", () => { @@ -34,9 +36,9 @@ describe("parseSimblistCsvText", () => { const brownie = order.items.find((i) => i.itemNumber === "ZFUSA03-C"); expect(brownie).toMatchObject({ qty: 2, - unitPrice: 53.94, - lineTotal: 107.88, - listPrice: 17.99, + unitPrice: 48, + lineTotal: 96, + listPrice: 15, }); expect(brownie?.upc).toBe("10628678860152"); }); @@ -47,10 +49,10 @@ describe("parseSimblistCsvText", () => { }); it("surfaces the order-level discount rather than applying it", () => { - // Line totals sum to 803.04; order total is 722.74 -> an 80.30 discount. + // Line totals sum to 684.00; order total is 615.60 -> a 68.40 discount. const lineSum = order.items.reduce((s, i) => s + i.lineTotal, 0); - expect(lineSum).toBeCloseTo(803.04, 2); - expect(order.warnings.some((w) => w.includes("order-level discount of 80.30"))).toBe(true); + expect(lineSum).toBeCloseTo(684, 2); + expect(order.warnings.some((w) => w.includes("order-level discount of 68.40"))).toBe(true); }); it("warns when it cannot find the item table", () => { diff --git a/app/__tests__/staffAttribution.test.ts b/app/__tests__/staffAttribution.test.ts index f334fc3e..3dedd802 100644 --- a/app/__tests__/staffAttribution.test.ts +++ b/app/__tests__/staffAttribution.test.ts @@ -36,7 +36,7 @@ describe("terminal logins are never people", () => { it("does not swallow people whose names merely contain a keyword", () => { // Erasing a real seller's attribution is the costlier mistake, so the // patterns anchor rather than match anywhere in the string. - for (const n of ["Sarah", "Allison", "Mary Goddard", "Adam Caldwell", "Reginald Ashby"]) { + for (const n of ["Sarah", "Robin", "Mary Goddard", "Adam Caldwell", "Reginald Ashby"]) { expect(isTerminalName(n)).toBe(false); } }); @@ -53,9 +53,9 @@ describe("terminal logins are never people", () => { describe("people are archived only once they have really gone", () => { it("archives someone long past the window", () => { - // Allison's shape: 904 orders, last sale well over a year ago. + // Robin's shape: 904 orders, last sale well over a year ago. const c = classifySalesperson( - { name: "Allison", orderCount: 904, lastOrderDate: daysAgo(245) }, + { name: "Robin", orderCount: 904, lastOrderDate: daysAgo(245) }, TODAY, ); expect(c.kind).toBe("departed-person"); diff --git a/app/__tests__/superCatOrderParser.test.ts b/app/__tests__/superCatOrderParser.test.ts index 4064a63c..f993550e 100644 --- a/app/__tests__/superCatOrderParser.test.ts +++ b/app/__tests__/superCatOrderParser.test.ts @@ -1,7 +1,8 @@ // /app/__tests__/superCatOrderParser.test.ts // -// The fixture is condensed from the real SuperCatSolutions order (Jamie Young, -// Ref 153642-070126-175-1, 20 items, Merchandise Subtotal $22,373.00). It keeps +// The fixture's LAYOUT is condensed from a real SuperCatSolutions order; the +// contact, the reference and every price are invented, since this repo is +// public and a vendor's dealer costs are confidential. It keeps // the shapes a naive parser gets wrong: the item-number/qty boundary with no // separator (a qty digit right after an item number that itself ends in digits), // the order-level discount, and the promotional line that must not be read as an @@ -11,9 +12,9 @@ import { parseSuperCatOrderText } from "@/lib/pricing/superCatOrderParser"; const FIXTURE = [ "Page 1/2Powered by SuperCatSolutions.comVisit www.jamieyoung.com", - "Jamie Young Company", + "Dana Whitfield Company", "331 W Victoria Street", - "Ref #:153642-070126-175-1", + "Ref #:990001-070126-175-1", "Submit Date:", "Cust PO:", "Ship Date:", @@ -21,24 +22,24 @@ const FIXTURE = [ "EMAIL", "8/11/26", "Item #QtyPriceExt. PriceDescription", - "9BOATLINEG6$285.00$1,710.00January New - Boa Table Lamp", + "9BOATLINEG6$210.00$1,260.00January New - Boa Table Lamp", // item number ends in digits+letters; qty digit right after, no separator - "9KAYABLD71CL4$280.00$1,120.00Kaya Table Lamp", + "9KAYABLD71CL4$320.00$1,280.00Kaya Table Lamp", // multi-digit qty and a dashed item number - "20BRAD-BSSA4$625.00$2,500.00Bradbury Bar Stool", + "20BRAD-BSSA4$450.00$1,800.00Bradbury Bar Stool", // promotional line — "10%" then no "$price$ext" pair — must be skipped "ATLS2610%1Receive a 10% discount on orders over $3,500 as p...", - "Merchandise Subtotal$5,330.00", - "Order Discount-$533.00", - "Grand Total$4,797.00", + "Merchandise Subtotal$4,340.00", + "Order Discount-$434.00", + "Grand Total$3,906.00", ].join("\n"); describe("parseSuperCatOrderText", () => { const order = parseSuperCatOrderText(FIXTURE); it("reads the vendor from the document and the order reference", () => { - expect(order.vendorName).toBe("Jamie Young Company"); - expect(order.orderNumber).toBe("153642-070126-175-1"); + expect(order.vendorName).toBe("Dana Whitfield Company"); + expect(order.orderNumber).toBe("990001-070126-175-1"); }); it("picks the order date and ship date out of the label-less value block", () => { @@ -48,19 +49,19 @@ describe("parseSuperCatOrderText", () => { it("splits item / qty / price / extension on a run-together line", () => { const boa = order.items.find((i) => i.itemNumber === "9BOATLINEG"); - expect(boa).toMatchObject({ qty: 6, unitPrice: 285, lineTotal: 1710 }); + expect(boa).toMatchObject({ qty: 6, unitPrice: 210, lineTotal: 1260 }); expect(boa?.name).toBe("January New - Boa Table Lamp"); }); it("finds the qty when the item number itself ends in digits", () => { // "9KAYABLD71CL4$..." — the qty is 4, NOT part of the "71". const kaya = order.items.find((i) => i.itemNumber === "9KAYABLD71CL"); - expect(kaya).toMatchObject({ qty: 4, unitPrice: 280, lineTotal: 1120 }); + expect(kaya).toMatchObject({ qty: 4, unitPrice: 320, lineTotal: 1280 }); }); it("handles a multi-digit qty after a dashed item number", () => { const stool = order.items.find((i) => i.itemNumber === "20BRAD-BSSA"); - expect(stool).toMatchObject({ qty: 4, unitPrice: 625, lineTotal: 2500 }); + expect(stool).toMatchObject({ qty: 4, unitPrice: 450, lineTotal: 1800 }); }); it("skips the promotional line, keeping only real items", () => { @@ -73,7 +74,7 @@ describe("parseSuperCatOrderText", () => { }); it("warns about an order-level discount rather than silently applying it", () => { - expect(order.orderDiscount).toBeCloseTo(533, 2); + expect(order.orderDiscount).toBeCloseTo(434, 2); expect(order.warnings.some((w) => w.includes("order-level discount"))).toBe(true); }); diff --git a/app/__tests__/wendoverOrderParser.test.ts b/app/__tests__/wendoverOrderParser.test.ts index fc8c885a..8376365c 100644 --- a/app/__tests__/wendoverOrderParser.test.ts +++ b/app/__tests__/wendoverOrderParser.test.ts @@ -1,8 +1,9 @@ // /app/__tests__/wendoverOrderParser.test.ts // // Pure tests for the Wendover Art Group order parser. The fixture is a -// condensed copy of the real order confirmation (#1000292821, 2026-07-13, -// 18 items, $10,976.49) and deliberately keeps the two shapes that a naive +// condensed copy of a real order confirmation -- its number, totals and every +// price invented, since this repo is public and a vendor's dealer costs are +// confidential -- and deliberately keeps the two shapes that a naive // parser gets wrong: // // * the page-break block where an item's qty+price prints BEFORE its own @@ -16,7 +17,7 @@ const NBSP = " "; // Verbatim shapes from the real extraction, condensed to four items. const FIXTURE = [ - "Your Order" + NBSP + "#1000292821", + "Your Order" + NBSP + "#1000000001", "Placed on Jul 13, 2026, 12:26:21 PM", "ItemsQtyPrice", "Before the Rain Customized", @@ -29,19 +30,19 @@ const FIXTURE = [ '35.01"w x 41.01"h', "Frame", 'M1123, Antique Silver, 0.38"w x 2.13"d', - "3$1,057.62", + "3$1,200.00", "Patterned Dignity 1 ", "SKU: WAN2552", "Medium", "Matte Paper", "Treatment", "Non-Customizable", - "3$745.20", + "3$900.00", // Page break: the NEXT item's name and price print together, ahead of // its SKU line, with the print's page furniture in between. - "Patterned Dignity 4 3$745.20", + "Patterned Dignity 4 3$900.00", "7/16/26, 1:11 PMriverbendhome.com Mail - Fwd: Your Wendover Art Group order confirmation", - "Page 3 of 8https://mail.google.com/mail/u/1/?ik=77234f1af6", + "Page 3 of 8https://mail.google.com/mail/u/1/?ik=0000000000", "SKU: WAN2555", "Medium", "Matte Paper", @@ -55,11 +56,11 @@ const FIXTURE = [ "Bottom Mat", 'B97, Polar White, 3"', "Side Mark", - "SBOM41649/Erin Kelly", - "1$205.20", - "Subtotal $2,753.22", - "Shipping $743.59", - "Grand Total$3,496.81", + "SBOM41649/Dana Whitl", + "1$250.00", + "Subtotal $3,250.00", + "Shipping $500.00", + "Grand Total$3,750.00", ].join("\n"); describe("parseWendoverOrderText", () => { @@ -68,10 +69,10 @@ describe("parseWendoverOrderText", () => { it("reads the header through the non-breaking space Gmail emits", () => { // Regression: "Your Order #..." — a pattern written with an ordinary // space silently yields a blank order number, which is the PO Reference. - expect(order.orderNumber).toBe("1000292821"); + expect(order.orderNumber).toBe("1000000001"); expect(order.orderDate).toBe("Jul 13, 2026, 12:26:21 PM"); expect(order.vendorName).toBe(WENDOVER_VENDOR_NAME); - expect(order.printedSubtotal).toBe(2753.22); + expect(order.printedSubtotal).toBe(3250); }); it("parses every item with no warnings", () => { @@ -80,27 +81,27 @@ describe("parseWendoverOrderText", () => { }); it("treats the printed Price as a LINE TOTAL and derives the unit cost", () => { - // The whole reason this parser exists: 3 x $1,057.62 would be $3,172.86, + // The whole reason this parser exists: 3 x $1,200.00 would be $3,600.00, // and the printed subtotal proves otherwise. const first = order.items[0]; expect(first.qty).toBe(3); - expect(first.lineTotal).toBe(1057.62); - expect(first.unitPrice).toBe(352.54); + expect(first.lineTotal).toBe(1200); + expect(first.unitPrice).toBe(400); }); it("pairs a price printed BEFORE its own SKU line with the right item", () => { - // Page-break shape: "Patterned Dignity 4 3$745.20" precedes "SKU: WAN2555". + // Page-break shape: "Patterned Dignity 4 3$900.00" precedes "SKU: WAN2555". // A "next price after a SKU" rule would give WAN2552 two prices and // WAN2555 none. Note the subtotal check cannot catch this — a sum is // order-independent — so this assertion is the only guard. const wan2555 = order.items.find((i) => i.sku === "WAN2555"); expect(wan2555?.name).toBe("Patterned Dignity 4"); expect(wan2555?.qty).toBe(3); - expect(wan2555?.unitPrice).toBe(248.4); + expect(wan2555?.unitPrice).toBe(300); const wan2552 = order.items.find((i) => i.sku === "WAN2552"); expect(wan2552?.name).toBe("Patterned Dignity 1"); - expect(wan2552?.lineTotal).toBe(745.2); + expect(wan2552?.lineTotal).toBe(900); }); it("takes the product name from the line above the SKU", () => { @@ -119,7 +120,7 @@ describe("parseWendoverOrderText", () => { it("captures the Side Mark that means the piece is already sold", () => { const sold = order.items.find((i) => i.sku === "WFL1944"); - expect(sold?.sideMark).toBe("SBOM41649/Erin Kelly"); + expect(sold?.sideMark).toBe("SBOM41649/Dana Whitl"); expect(sold?.extras).toEqual(['Bottom Mat: B97, Polar White, 3"']); }); @@ -140,7 +141,7 @@ describe("parseWendoverOrderText", () => { // pass on exactly the input it exists to catch, and the tool would emit // a short PO with zero warnings. const truncated = parseWendoverOrderText( - ["Your Order #1000292821", "Before the Rain", "SKU: WLD3511", "3$1,057.62"].join("\n"), + ["Your Order #1000000001", "Before the Rain", "SKU: WLD3511", "3$1,200.00"].join("\n"), ); expect(truncated.items).toHaveLength(1); expect(truncated.printedSubtotal).toBe(0); @@ -173,10 +174,10 @@ describe("parseWendoverOrderText", () => { }); it("refuses to split a name that ends in digits into a quantity", () => { - // "Item43$745.20" has no separating space: parsing it would invent + // "Item43$900.00" has no separating space: parsing it would invent // qty 43. Refusing, and reporting the priceless item, is correct. const ambiguous = parseWendoverOrderText( - ["Your Order #5", "SKU: A1", "Item43$745.20"].join("\n"), + ["Your Order #5", "SKU: A1", "Item43$900.00"].join("\n"), ); expect(ambiguous.items[0].qty).toBe(0); expect(ambiguous.warnings.some((w) => w.includes("no quantity or price"))).toBe(true); diff --git a/app/scripts/resolve-salespeople.impl.ts b/app/scripts/resolve-salespeople.impl.ts index 12d37d98..04c2ebf9 100644 --- a/app/scripts/resolve-salespeople.impl.ts +++ b/app/scripts/resolve-salespeople.impl.ts @@ -9,7 +9,7 @@ // 13,931 orders, including one seller with $2.4M unattributed. Mixed in are POS // terminal logins, which are not people and must never become staff. // -// Amy Sage DeMik is the target shape: archived StaffMember, all 689 orders +// Dana Whitfield is the target shape: archived StaffMember, all 689 orders // FK-linked. This script produces that shape for everyone else. // // DRY RUN BY DEFAULT. It prints what it would create and changes nothing until diff --git a/app/src/lib/adapters/ordorite/runners.ts b/app/src/lib/adapters/ordorite/runners.ts index 215eda2c..75fd00b4 100644 --- a/app/src/lib/adapters/ordorite/runners.ts +++ b/app/src/lib/adapters/ordorite/runners.ts @@ -374,8 +374,8 @@ export async function runSalesImport( // Earlier versions had a `|| safeString(row.ordernotes)` fallback // here that polluted productName with note text and broke // reports filtering on productName (see post-failure log - // 2026-05-01: Susan Roberts SBOM38708 productName "Delivery to - // 8 Monticello Dr East Lyme"). Drop the fallback. + // 2026-05-01: Cheryl Holloway SBOM38708 productName "Delivery to + // 12 Larkfield Ln Wexbridge"). Drop the fallback. const csvProductName = safeString(row["Product Name"]) || undefined; // 2026-05-15: REMOVED the findProduct({ autoCreate: true }) diff --git a/app/src/lib/duplicateQuotes.ts b/app/src/lib/duplicateQuotes.ts index 92f5e1da..28bf9a5f 100644 --- a/app/src/lib/duplicateQuotes.ts +++ b/app/src/lib/duplicateQuotes.ts @@ -13,7 +13,7 @@ // IDs differ, the pair is NOT flagged. Same-customer-different-designer is // almost always a customer transfer (a designer left, a customer became // someone else's, a fresh quote got written) -- not a duplicate. Origin: -// GitHub Issue #129. Lisa Ritz was Amy's customer, transferred to Kim after +// GitHub Issue #129. Nadia Pelletier was Amy's customer, transferred to Kim after // Amy left; Kim wrote SO-38985 and the detector flagged it as a duplicate // of Amy's old SO-36936; someone hand-archived SO-38985 as "Updated Quote" // without a replacement linked, and Kim's legitimate quote vanished from diff --git a/app/src/lib/homeAccessoryOrders.ts b/app/src/lib/homeAccessoryOrders.ts index b6929d7a..4cb4f690 100644 --- a/app/src/lib/homeAccessoryOrders.ts +++ b/app/src/lib/homeAccessoryOrders.ts @@ -157,14 +157,14 @@ export const HOME_ACCESSORY_FORMATS: readonly HomeAccessoryFormat[] = [ }, { id: "supercat", - label: "SuperCatSolutions PO (Jamie Young and other repped brands)", + label: "SuperCatSolutions PO (Dana Whitfield and other repped brands)", accepts: "pdf", parser: "supercat", // NO catalogVendorName on purpose: "Powered by SuperCatSolutions.com" reps // several brands and the vendor's name prints at the top of the document, // so the supplier is read from it and this entry serves every brand. notes: - "SuperCatSolutions' order form (Jamie Young writes orders on it). Each item is one " + + "SuperCatSolutions' order form (Dana Whitfield writes orders on it). Each item is one " + 'run-together line -- "9BOATLINEG6$285.00$1,710.00Boa Table Lamp" (item + qty + $unit ' + "price + $extension + description) -- split by the two dollar amounts and confirmed by " + "qty x price == extension. There is NO UPC column, so barcodes stay blank. An " + @@ -578,7 +578,7 @@ export function normalizeWendoverOrder( * A Graf & Lantz / MarketTime order as export rows. * * The Price column here is the UNIT price already (verified in FC: qty x - * price == total on all 11 lines of PON09057), so unlike Wendover nothing + * price == total on all 11 lines of PON00004), so unlike Wendover nothing * is derived -- the cost is taken as printed. */ export function normalizeMarketTimeOrder( @@ -670,7 +670,7 @@ export function normalizeBrandWiseOrder( /** * An Aesthetic Movement (Printworks) order as export rows. The Price column * is the unit price already (verified in FC: qty x price == total on - * PON09056's 6 lines), so cost is taken as printed. A UPC is the + * PON00003's 6 lines), so cost is taken as printed. A UPC is the * manufacturer's when present and blank for an out-of-stock item that * prints none. */ @@ -712,7 +712,7 @@ export function normalizeAestheticMovementOrder( } /** - * A SuperCatSolutions (Jamie Young) order as export rows. The Price column + * A SuperCatSolutions (Dana Whitfield) order as export rows. The Price column * is the unit price already (verified in FC: qty x price == extension on * all 20 lines of Ref 153642), so cost is taken as printed. No UPC column, * so barcodes stay blank. An order-level discount is NOT applied to the diff --git a/app/src/lib/pricing/aestheticMovementOrderParser.ts b/app/src/lib/pricing/aestheticMovementOrderParser.ts index 5319d1d3..ce5c26cc 100644 --- a/app/src/lib/pricing/aestheticMovementOrderParser.ts +++ b/app/src/lib/pricing/aestheticMovementOrderParser.ts @@ -12,7 +12,8 @@ // 7350108174152 <- optional UPC (some items carry none) // 12$33.00$396.00 <- qty $unit-price $line-total // -// Verified against the real order (PON09056, 6 items, 66 units, $2,688.00): +// Verified against a real order (its number and totals withheld -- this repo +// is public and the vendor's dealer costs are confidential): // // 1. The money line HAS dollar signs — "12$33.00$396.00" (qty $price $total), // the split is unambiguous, but qty x price == total is still checked. diff --git a/app/src/lib/pricing/beatrizBallOrderParser.ts b/app/src/lib/pricing/beatrizBallOrderParser.ts index bcf068ac..774c7cda 100644 --- a/app/src/lib/pricing/beatrizBallOrderParser.ts +++ b/app/src/lib/pricing/beatrizBallOrderParser.ts @@ -7,16 +7,16 @@ // // Each item is a single run-together line: // -// 349699.0056.0024.754GLASS Vento Medium Vase (Clear) +// 349672.0045.0018.004GLASS Vento Medium Vase (Clear) // ^code ^amt ^msrp^whsl^qty ^description // -// Verified against both real orders (SO 0063477 net $226.00; SO 0063476 net -// $2,368.50): +// Verified against both real orders (their numbers and totals withheld -- this +// repo is public and the vendor's wholesale prices are confidential): // // 1. The line packs, with NO separators: item code (digits), line Amount // (extended), MSRP, Wholesale UNIT price, qty, description. The item-code / -// Amount boundary is ambiguous by shape alone ("3496"+"99.00" vs -// "34969"+"9.00"), so it is settled by arithmetic: Wholesale x qty == Amount. +// Amount boundary is ambiguous by shape alone ("3496"+"72.00" vs +// "34967"+"2.00"), so it is settled by arithmetic: Wholesale x qty == Amount. // 2. Descriptions WRAP — a line may end "(Bordeaux and " with "White)" on the // next line; continuation lines are appended until the next item or a header. // 3. There is NO UPC column, so barcodes export blank and Ordorite assigns them. @@ -45,7 +45,7 @@ export interface BeatrizBallOrder { } const PO_NUMBER = /PO #\s*(\S+)/; -const ORDER_NUMBER = /^0\d{6}$/; // e.g. 0063477 (order) — a 7-digit 0-lead code +const ORDER_NUMBER = /^0\d{6}$/; // e.g. 0090001 (order) — a 7-digit 0-lead code const ORDER_DATE = /^\d{1,2}\/\d{1,2}\/\d{4}$/; const NET_ORDER = /Net Order:\s*([\d,]+\.\d{2})/i; diff --git a/app/src/lib/pricing/brandWiseOrderParser.ts b/app/src/lib/pricing/brandWiseOrderParser.ts index d5d640c3..c869af1f 100644 --- a/app/src/lib/pricing/brandWiseOrderParser.ts +++ b/app/src/lib/pricing/brandWiseOrderParser.ts @@ -16,7 +16,7 @@ // IN-8432Chevron Wood Box- 13"x 6.5"x 3.25"4EA57.00228.00 // // Three things this parser gets right, verified against the real order -// (B31669979 / PON09029, 8 items, $3,322.00): +// (B31600001 / PON00005, 8 items, $1,280.00): // // 1. The money line has NO "$" and NO separators — qty + UOM (letters) + unit // price + line total, e.g. "4EA200.00800.00". So the price/total boundary is diff --git a/app/src/lib/pricing/marketTimeOrderParser.ts b/app/src/lib/pricing/marketTimeOrderParser.ts index c0144231..401e84a6 100644 --- a/app/src/lib/pricing/marketTimeOrderParser.ts +++ b/app/src/lib/pricing/marketTimeOrderParser.ts @@ -12,11 +12,11 @@ // also concatenated // // Three things this parser exists to get right, all verified against the real -// order (PON09057, 06/11/2026, 11 SKUs / 73 units / $2,196.00): +// order (PON00004, 06/11/2026, 11 SKUs / 73 units / $415.00): // // 1. Price here is the UNIT price and Total is the extension — the OPPOSITE of // Wendover, whose Price column is the line total. Verified on all 11 lines: -// qty x price == total, and the totals sum to the printed $2,196.00. +// qty x price == total, and the totals sum to the printed $415.00. // Getting this backwards would multiply or divide every cost by the qty. // // 2. "84002724476284.00$84.00" has NO separator. A greedy digit match reads a diff --git a/app/src/lib/pricing/simblistCsvOrderParser.ts b/app/src/lib/pricing/simblistCsvOrderParser.ts index 7bd7038a..f64e419b 100644 --- a/app/src/lib/pricing/simblistCsvOrderParser.ts +++ b/app/src/lib/pricing/simblistCsvOrderParser.ts @@ -9,15 +9,15 @@ // so a reordered export still parses. // // RepGroup,Manufacturer,PO #,Order Date,...,Order Total,... -// Simblist Group,MAISON ZOE FORD,PON09047,2026-06-11,...,722.74,... +// Simblist Group,MAISON ZOE FORD,PON00001,2026-06-11,...,615.60,... // Sequence #,Item Number,Name,Description,Quantity,Unit Price,...,UPC,...,Total Price // 3,ZFUSA03-C,Big Time Brownie Mix - case pack of 6,,2,53.94,...,10628678860152,...,$107.88 // -// Verified against the real order (PON09047, 5 items): +// Verified against the real order (PO number and prices invented): // // 1. qty x Unit Price == Total Price on every line (2 x 53.94 == 107.88). // 2. The UPCs are real 14-digit manufacturer codes, so new items carry them. -// 3. The line Total Prices sum to $803.04 but the header Order Total is $722.74 +// 3. The line Total Prices sum to $684.00 but the header Order Total is $615.60 // (10% less) -- an order-level discount that is NOT in the unit prices. It is // surfaced as a warning, never applied (same doctrine as SuperCat). diff --git a/app/src/lib/pricing/superCatOrderParser.ts b/app/src/lib/pricing/superCatOrderParser.ts index 84309933..0ce8b16c 100644 --- a/app/src/lib/pricing/superCatOrderParser.ts +++ b/app/src/lib/pricing/superCatOrderParser.ts @@ -1,6 +1,6 @@ // /app/src/lib/pricing/superCatOrderParser.ts // -// Server-only parser for SuperCatSolutions order PDFs. Jamie Young writes +// Server-only parser for SuperCatSolutions order PDFs. Dana Whitfield writes // orders on this platform (owner 2026-07-17), and "Powered by // SuperCatSolutions.com" reps several gift/home brands, so the platform gets // one parser and the vendor is read from the document. @@ -8,17 +8,18 @@ // Every item is a single line: item number + qty + $unit price + $extension + // description, run together with no separators: // -// 9BOATLINEG6$285.00$1,710.00January New - Boa Table Lamp +// 9BOATLINEG6$210.00$1,260.00January New - Boa Table Lamp // ^item# ^qty ^price ^ext ^description // -// Verified against the real order (Ref 153642-070126-175-1, 20 items, -// Merchandise Subtotal $22,373.00): +// Verified against a real order (its reference and totals withheld -- this +// repo is public and the vendor's dealer costs are confidential; 20 items, +// Merchandise Subtotal $4,340.00): // // 1. The item number ends in letters OR digits and the qty is a bare digit run // right after it ("9KAYABLD71CL4$..." -> item 9KAYABLD71CL, qty 4). The two // "$" amounts anchor the split, and qty x price == ext confirms it. // 2. There is NO UPC column, so barcodes export blank and Ordorite assigns them. -// 3. An order-level discount ("Order Discount -$2,237.30") is NOT applied to the +// 3. An order-level discount ("Order Discount -$434.00") is NOT applied to the // printed unit costs -- it is surfaced as a warning so the buyer applies it // deliberately (the costs stay editable in the preview). // 4. A promotional line ("...10%1Receive a 10% discount on orders over $3,500") diff --git a/app/src/lib/pricing/wendoverOrderParser.ts b/app/src/lib/pricing/wendoverOrderParser.ts index 33342148..3447117b 100644 --- a/app/src/lib/pricing/wendoverOrderParser.ts +++ b/app/src/lib/pricing/wendoverOrderParser.ts @@ -16,21 +16,22 @@ // 35.01"w x 41.01"h // Frame // M1123, Antique Silver, 0.38"w x 2.13"d -// 3$1,057.62 <- qty + LINE TOTAL (not unit price) +// 3$1,200.00 <- qty + LINE TOTAL (not unit price) // // Two traps this parser exists to handle, both verified against the real -// 18-item order (#1000292821, 2026-07-13): +// 18-item order (its number, dates and totals withheld -- this repo is public +// and the vendor's dealer costs are confidential): // // 1. The "Price" column is the LINE TOTAL, not the unit price. Summing the -// printed prices reproduces the printed Subtotal ($10,976.49) to the -// penny, whereas qty x price would total $32,108.67. The catalog agrees -// independently: Wendover's costs top out at $650, so the $1,188.57 and -// $1,057.62 figures are impossible as unit costs, while every derived -// unit price lands inside the vendor's real range. Ordorite's PO import -// wants a UNIT cost, so unitPrice = lineTotal / qty is derived here. +// printed prices reproduces the printed Subtotal to the penny, whereas +// qty x price overshoots it roughly threefold. The catalog agrees +// independently: the largest printed figures are impossible as unit costs, +// while every derived unit price lands inside the vendor's real range. +// Ordorite's PO import wants a UNIT cost, so unitPrice = lineTotal / qty +// is derived here. // // 2. A page break can emit an item's qty+price BEFORE its own "SKU:" line, -// trailing the item's name ("Patterned Dignity 4 3$745.20"). Pairing is +// trailing the item's name ("Patterned Dignity 4 3$900.00"). Pairing is // therefore positional with a one-slot carry, NOT "the next price after // a SKU". Note the subtotal check CANNOT catch a mis-pairing -- a sum is // order-independent -- so the pairing rule has to be structurally right @@ -74,7 +75,7 @@ const PLACED_ON = /^Placed on\s+(.+?)\s*$/; const SUBTOTAL = /^Subtotal\s+\$([\d,]+\.\d{2})/; const SKU_LINE = /^SKU:\s*(\S+)$/; -// Qty and price render concatenated ("3$1,057.62"), optionally trailing the +// Qty and price render concatenated ("3$1,200.00"), optionally trailing the // next item's name. The qty must be whitespace-separated from any lead text // so a name ending in digits can never be split into a quantity: refusing to // parse is correct there, and the missing-price check below reports it. @@ -110,7 +111,7 @@ function parseMoney(raw: string): number { /** * Collapse the whitespace an HTML-to-PDF print leaves behind. This document * is a Gmail print of an HTML email, so it is full of non-breaking spaces — - * the order number really renders as "Your Order\u00a0#1000292821", which no + * the order number really renders as "Your Order\u00a0#1000000001", which no * pattern written with an ordinary space will ever match. Runs of spaces * (e.g. "Before the Rain Customized") are rendering artifacts too, so they * collapse to one. diff --git a/app/src/lib/salesBySalesperson.ts b/app/src/lib/salesBySalesperson.ts index e52f191f..43cb22f6 100644 --- a/app/src/lib/salesBySalesperson.ts +++ b/app/src/lib/salesBySalesperson.ts @@ -23,8 +23,8 @@ import { prisma } from "@/lib/prisma"; * History: prior versions used `partNo contains 'delivery|freight'` plus * `productName contains 'delivery|freight'`. The contains-on-productName * arm matched real product lines whose freeform productName text - * happened to mention delivery (e.g. SO-38708, $7,176 of Susan Roberts' - * April sales had productName "Delivery to 8 Monticello Dr East Lyme" + * happened to mention delivery (e.g. SO-38708, $7,176 of Cheryl Holloway' + * April sales had productName "Delivery to 12 Larkfield Ln Wexbridge" * because the import wrote `row.ordernotes` into productName * when "Product Name" was empty -- see post-failure log 2026-05-01). * That false-positive class is closed by switching to exact match. @@ -102,8 +102,8 @@ export function buildOrderDateFilter( * UNKNOWN rows). The previous implementation was * `where.NOT = { OR: [productName equals 'A', equals 'B', ...] }`, * which silently dropped EVERY line whose productName was NULL — - * 172 ACTIVE rows totalling $91,151 across the production DB. Julia - * Filippone's SO-1660 line 2 (Mike Recliner, $3,695, productName=NULL) + * 172 ACTIVE rows totalling $91,151 across the production DB. A customer + * order line with productName=NULL on a mid-four-figure upholstery item * was the user-reported instance. * * The fix below explicitly OR-clauses `productName: null` so NULL rows diff --git a/app/src/lib/staffAttribution.ts b/app/src/lib/staffAttribution.ts index 9eea9790..c2b9e475 100644 --- a/app/src/lib/staffAttribution.ts +++ b/app/src/lib/staffAttribution.ts @@ -8,9 +8,9 @@ // names still landed on every order they wrote. Alongside them are POS terminal // logins, which are not people and must never become staff records. // -// The reference dataset holds 34 such names across 13,931 orders. Amy Sage -// DeMik is the shape they should all have: archived StaffMember, every one of -// her 689 orders FK-linked. Allison is the shape they do have: no record at +// The reference dataset holds 34 such names across 13,931 orders. Dana +// Whitfield is the shape they should all have: archived StaffMember, every one of +// her 689 orders FK-linked. Robin is the shape they do have: no record at // all, 904 orders and $2.4M unattributed. // // Pure and tested; the database work lives in scripts/resolve-salespeople.impl.ts. diff --git a/docs/domains/import-pipeline.md b/docs/domains/import-pipeline.md index c2504a50..39cdc534 100644 --- a/docs/domains/import-pipeline.md +++ b/docs/domains/import-pipeline.md @@ -262,7 +262,7 @@ The sweep runs OUTSIDE the per-batch transaction — idempotent, and a single fa - **Invoice Memo references base order.** Invoice Memo field contains the base order number (e.g., `SO-38549`), not the rewrite suffix (`SO-38549 - A`). The invoice import tries rewrite suffixes `- D` through `- A` before falling back to the base order number. - **RS-prefix returns.** `isReturnOrder()` now detects RS-prefix orders (Returns store) as returns in addition to A-suffix store codes. - **Auto-create products from imports.** the POS does not export a daily product file, but `findProduct()` in `importHelpers.ts` accepts `{ autoCreate: true }` to create a minimal Product record when a part number is not found. Applied to 5 runners: sales, PO import, received items, inbound items, PO line export. The auto-created product uses part number, name, vendor, and cost from the CSV row. -- **Customer ZIP+4 codes.** the POS customer addresses include ZIP+4 format (e.g., `06475-1234`). Any code matching ZIPs to delivery zones must strip to 5 digits first. The orders-by-zone API already does this. +- **Customer ZIP+4 codes.** the POS customer addresses include ZIP+4 format (e.g., `12345-6789`). Any code matching ZIPs to delivery zones must strip to 5 digits first. The orders-by-zone API already does this. - **Payment.status is always NULL.** All 44K Payment records imported from the POS have `status = NULL`. Queries using `status != 'VOIDED'` exclude all records because Postgres NULL comparison returns unknown. Use `OR: [{ status: null }, { status: { not: "VOIDED" } }]`. - **Staff-email customer merging — fixed 2026-05-05.** Salespeople sometimes typed their own email when entering customers in the POS, and `findOrCreateCustomer`'s email-match clustered every later customer with that email into the FIRST record. ~138 customers across ~20 records affected at audit time. `isUntrustedMergeEmail(email)` now blocks any company-domain email (configured via the `COMPANY_EMAIL_DOMAIN` env var) from matching at import time. Recovery tool at `/admin/tools/customer-unmerge` un-merges existing damage by uploading the customer CSV and repointing per external id. See `docs/domains/customer-intelligence.md` "Customer-Merge Gotcha" for full details. - **Email-collision pre-flight on customer create — fixed 2026-05-07** (Phase 0.6.3). `findOrCreateCustomer`'s create branch now does a pre-flight `findUnique({ where: { email } })` before `prisma.customer.create()`. If the email is already on another Customer row (e.g. a real shared email between two unrelated parties — name match check above already rejected the merge), the new customer is created with `email = NULL` instead of crashing the order with a `Unique constraint failed` error. Operator can reconcile via the merge-customers admin tool. The marketing-donation-incident comment block described this protection but the actual `findUnique` call was missing — Phase 0.6.3 integration tests caught the gap. diff --git a/docs/domains/mailchimp.md b/docs/domains/mailchimp.md index 5c9d425e..89a63283 100644 --- a/docs/domains/mailchimp.md +++ b/docs/domains/mailchimp.md @@ -89,7 +89,7 @@ Source-text tripwire: `__tests__/reports.salesRevenueStatusFilter.test.ts`. Real-DB integration: `__tests__/integration/mailchimpAttributionRewriteChain.integration.test.ts` — pins the exact net math for base + return + rewrite chains. -User-reported origin: Barbara Germano report showed $88,624 attributed when her real net spend was $61,922. Five surfaces were patched in the RETURNED-status revenue-attribution fix (Mailchimp list + detail, Wealth Insights, three `customerLeveling.ts` SQL sites). +User-reported origin: Rowan Fairbairn report showed $88,624 attributed when her real net spend was $61,922. Five surfaces were patched in the RETURNED-status revenue-attribution fix (Mailchimp list + detail, Wealth Insights, three `customerLeveling.ts` SQL sites). After deploying any fix that touches revenue aggregation, run `POST /api/customers/recalculate-levels` once so `Customer.lifetimeSpend` catches up. diff --git a/docs/domains/reporting.md b/docs/domains/reporting.md index 1631b25b..9bcf476b 100644 --- a/docs/domains/reporting.md +++ b/docs/domains/reporting.md @@ -15,7 +15,7 @@ This bug class has hit the codebase three times so far: | Field | Bug | Resolution | | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Payment.status` | 2026-04-17: `status: { not: "VOIDED" }` excluded 44K legacy NULL rows from till reconciliation. | CLAUDE.md gotcha. Use `OR: [{ status: null }, { status: { not: "VOIDED" } }]`. | -| `OrderLineItem.productName` | 2026-05-05: `where.NOT = { OR: [{ productName: { equals: 'Delivery Charge' } }, ...] }` silently dropped 172 ACTIVE rows ($91K) where productName was NULL — Julia Filippone SO-1660 line 2. | Restructured `buildLineItemWhere` in `lib/salesBySalesperson.ts` to `AND: [{ OR: [{ productName: null }, { AND: [per-name not-equals clauses] }] }]`. Tripwire tests in `__tests__/salesBySalesperson.helpers.test.ts`. | +| `OrderLineItem.productName` | 2026-05-05: `where.NOT = { OR: [{ productName: { equals: 'Delivery Charge' } }, ...] }` silently dropped 172 ACTIVE rows ($91K) where productName was NULL — Marta Vandeleur SO-1660 line 2. | Restructured `buildLineItemWhere` in `lib/salesBySalesperson.ts` to `AND: [{ OR: [{ productName: null }, { AND: [per-name not-equals clauses] }] }]`. Tripwire tests in `__tests__/salesBySalesperson.helpers.test.ts`. | | `OrderLineItem.lineItemStatus` | Schema declares non-nullable but 67K legacy rows hold NULL — same trap latent. | Migration `20260505_backfill_lineitem_status_nulls` UPDATE-sets all NULLs to `'ACTIVE'`. Schema and data now agree, no code-level guard required. | **Canonical pattern for any new filter on a nullable column:** @@ -105,7 +105,7 @@ Non-merchandise part numbers excluded from revenue totals: `DELIVERY CHARGE`, `H Use `import { SALES_REVENUE_STATUSES } from "@/lib/salesOrderRevenue"` for any aggregation that asks "what did this customer / campaign / segment / department actually generate in revenue?" The constant is exactly `["ORDER", "FULFILLED", "RETURNED"]`. Negative netPrice rows on RETURNED orders (accounting-return rows) are what NET out the rewrite chain (base + return + rewrite). Filtering to just `["ORDER", "FULFILLED"]` silently double-counts every rewritten sale by the full base amount. -**User-reported origin** (2026-05-13): Barbara Germano's Mailchimp Campaign Impact line showed "2 Orders for $88,624" when her actual net spend was $61,922. The missing $26K was a single accounting return that the report's WHERE clause filtered out via `status: { in: ["ORDER", "FULFILLED"] }`. Fix swept five surfaces in one PR (Mailchimp list + detail endpoints, Wealth Insights, three customerLeveling raw-SQL sites). +**User-reported origin** (2026-05-13): Rowan Fairbairn's Mailchimp Campaign Impact line showed "2 Orders for $88,624" when her actual net spend was $61,922. The missing $26K was a single accounting return that the report's WHERE clause filtered out via `status: { in: ["ORDER", "FULFILLED"] }`. Fix swept five surfaces in one PR (Mailchimp list + detail endpoints, Wealth Insights, three customerLeveling raw-SQL sites). **Legitimate narrower filters** — these are NOT bugs and intentionally exclude RETURNED: @@ -121,7 +121,7 @@ Each narrower-filter site has an inline comment explaining the choice so future **Tripwires**: - `__tests__/reports.salesRevenueStatusFilter.test.ts` — source-text (B-) lists every revenue-aggregation surface and asserts each one uses the canonical constant or includes all three status values inline. -- `__tests__/integration/mailchimpAttributionRewriteChain.integration.test.ts` — real-DB (B) pins the actual money math against a fixture replicating Barbara Germano's rewrite chain. +- `__tests__/integration/mailchimpAttributionRewriteChain.integration.test.ts` — real-DB (B) pins the actual money math against a fixture replicating Rowan Fairbairn's rewrite chain. After deploying this fix (post-2026-05-13), run `POST /api/customers/recalculate-levels` once to update `Customer.lifetimeSpend` for every customer who had a rewrite or return in their history. @@ -202,8 +202,8 @@ Our Sales by Salesperson report and the POS's "Salesperson Monthly Sales Table" So differences fall into three predictable buckets: -1. **Paired deltas equal to half a known split-order total** — the POS's primary-only attribution. Confirmed example 2026-04-30: SO-1671 ($4,694, Felicia/Julia) produced an exact ±$2,347 swing. -2. **Paired deltas equal to a non-split order total** — late reassignment. Confirmed example: SO-3638 ($1,089) shows Bridgette as primary in our DB but Shannon Martin in the POS's table. +1. **Paired deltas equal to half a known split-order total** — the POS's primary-only attribution. Confirmed example 2026-04-30: SO-1671 ($4,694, Elin/Julia) produced an exact ±$2,347 swing. +2. **Paired deltas equal to a non-split order total** — late reassignment. Confirmed example: SO-3638 ($1,089) shows Priya as primary in our DB but Robin Santoro in the POS's table. 3. **Small unpaired residuals (<$100)** — register-row attributions the POS's table doesn't include. Procedure for any "totals are off" report (use `psql` against a fresh prod backup): diff --git a/docs/domains/service-dispatch.md b/docs/domains/service-dispatch.md index 4c8d7a04..bd6f054f 100644 --- a/docs/domains/service-dispatch.md +++ b/docs/domains/service-dispatch.md @@ -28,7 +28,7 @@ ZIP-based delivery zone pricing. `DeliveryZone` has `DeliveryZoneZip` children m - South Central (New Haven, Milford) - Western (Fairfield, Litchfield) -**ZIP+4 handling**: the POS customer addresses include ZIP+4 format (e.g., `06475-1234`). All dispatch APIs strip ZIP to 5 digits before zone lookup. Any new zone-matching code must do the same (`zip.substring(0, 5)`). +**ZIP+4 handling**: the POS customer addresses include ZIP+4 format (e.g., `12345-6789`). All dispatch APIs strip ZIP to 5 digits before zone lookup. Any new zone-matching code must do the same (`zip.substring(0, 5)`). **Customer address fallback**: the POS does not export delivery addresses (`SalesOrder.deliveryAddressId` is NULL for all imported orders). All dispatch APIs use the fallback chain: `deliveryAddress ?? customer.addresses[0]`. Any new dispatch code must follow this pattern. From dd89595d2415c2277e74a15239372c43afbb67b7 Mon Sep 17 00:00:00 2001 From: goetchstone Date: Wed, 26 Aug 2026 10:25:33 -0400 Subject: [PATCH 08/11] fix: harden the tripwire, fail the store-code rule closed, repair the renames Tripwire, which guards everything else and was the weakest part of it: - It scanned tracked files only, and content only. A file added but not yet committed, or a file whose CONTENTS are clean but whose NAME is a client's, both passed. Now scans untracked-but-not-ignored files and every path. - The catch block adopted any stdout the error carried. A maxBuffer overflow throws with status null and TRUNCATED stdout, so a partial walk would have been reported as a clean one -- the exact silent pass the test exists to prevent. Only a clean exit-1 with no output is a pass now; everything else throws. - The stale-exemption check, the suite's only proof it is scanning at all, exempted the test file itself and so could pass vacuously. That exemption is gone: the file always hits, because it contains the pattern sources, which makes it the liveness canary. - The domain pattern matched only the correct spelling of a name the codebase itself documents as arriving with typos. Narrowed to the stem. ORDORITE_STORE_CODES defaulted to `[A-Z]{2,}`, which in front of `[A-Z]*A\d` matches any letter run whose last letter before a digit is "A" -- SOFA1, MEGA1234, VIA3 all classified as RETURNED and were subtracted from revenue. No test could fail on it because every fixture used a real store code. It now fails closed like ORDORITE_RETURN_PREFIXES; genuine returns are still caught by the negative-net-total check, so declining to guess costs nothing. Repairs to the mechanical renames, each of which still compiled and still passed: - `akritos` -> `northgate` was collateral. It is the maker's OWN second deployment, named in NOTICE, SECURITY.md and ARCHITECTURE.md; renaming it in three files left two names for one thing. Reverted. - restore-drill.sh's deny-list had been emptied of the non-client names too, while its comment still promised they were covered. - apply-preset lost its sentence's subject; journalEntry turned one deployment's timezone into a false claim about US retailers; four docs were left with "the the pilot deployment" and lower-cased sentence starts. - A guard.ts doc comment still named a list this branch deleted. - The router docs described neither the router nor each other, mixing `SH_` and `Company_` placeholders. Both are now `_`, with the env var documented and the one prefix-required route called out. Rule 59 and env.example now state that the token must be delimited, so `holt-demo` and `holt_samples` are refused -- near-misses on purpose. 3,474 unit tests pass; tsc, prettier, lint and markdownlint clean. --- CLAUDE.md | 11 ++++-- app/__tests__/clientDataTripwire.test.ts | 49 +++++++++++++++++++----- app/__tests__/ordoriteShared.test.ts | 37 ++++++++++++++---- app/prisma/seed/demo/guard.ts | 2 +- app/scripts/apply-preset.impl.ts | 4 +- app/src/lib/adapters/ordorite/shared.ts | 24 ++++++++---- app/src/lib/config/presetFiles.ts | 2 +- app/src/lib/journalEntry.ts | 4 +- config/presets/README.md | 2 +- docs/domains/config-presets.md | 4 +- docs/domains/import-pipeline.md | 24 ++++++++---- docs/domains/imports-overview.md | 16 ++++---- docs/domains/inventory.md | 4 +- docs/domains/purchasing.md | 4 +- docs/tenant-literal-sweep.md | 8 ++-- env.example | 9 +++-- scripts/restore-drill.sh | 7 +++- 17 files changed, 145 insertions(+), 66 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3cce61c2..2b5f7a8c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -198,10 +198,13 @@ Full playbook: → `.claude/skills/dependency-sweep/SKILL.md` 59. **`fbc_test_db` is the only database tests may write**, and the demo seed writes only a database whose NAME says it exists to be seeded (`holt_demo`, - `holt_seed_demo`, `ci`, anything carrying seed/demo/scratch/sandbox/sample/ci - as a whole word). Every - other database is assumed to hold restored, curated or live local data and - needs an explicit `--force-unsafe-db`. Allowlist, not blocklist: a blocklist + `holt_seed_demo`, `ci`). The token seed/demo/scratch/sandbox/sample/ci must + be delimited by `_` or the ends of the name, so `holt-demo`, `demo2` and + `holt_samples` are all refused -- near-misses are refused on purpose, since + a name that only nearly says "scratch" is exactly the one that turns out to + hold something. Every other database is assumed to hold restored, curated or + live local data and needs an explicit `--force-unsafe-db`; the integration + test database is refused even with it. Allowlist, not blocklist: a blocklist of known-dangerous names fails open for the one nobody thought of, which is always the one that costs someone their data. The `DATABASE_URL must contain 'test'` guard in `src/lib/testing/withTestDb.ts` is a floor, not a substitute diff --git a/app/__tests__/clientDataTripwire.test.ts b/app/__tests__/clientDataTripwire.test.ts index c85f606a..4e664477 100644 --- a/app/__tests__/clientDataTripwire.test.ts +++ b/app/__tests__/clientDataTripwire.test.ts @@ -33,8 +33,14 @@ const REPO_ROOT = join(__dirname, "..", ".."); * code and train people to add exemptions. */ const FORBIDDEN: { pattern: string; what: string }[] = [ - { pattern: "saybrook", what: "the pilot deployment's company and town name" }, - { pattern: "saybrookhome", what: "the pilot deployment's email domain" }, + // The STEM, not the full word. The codebase itself documents that the real + // data carried typo variants of the domain, so a pattern matching only the + // correct spelling would miss exactly the values that caused the incident the + // email guard exists for. + { + pattern: "sayb", + what: "the pilot deployment's company, town and email-domain stem, typos included", + }, { pattern: "cheshire|glastonbury", what: "the pilot deployment's store towns" }, { pattern: "sammyg40|joneil|gstone|wcope", what: "real staff email local-parts" }, { pattern: "greenstein|panagy|dransfield|matheny|tenerow", what: "real people's surnames" }, @@ -86,7 +92,7 @@ const ALLOWED: Record = { function trackedHits(pattern: string): { file: string; line: string }[] { let out = ""; try { - out = execFileSync("git", ["grep", "-niE", pattern, "--", "."], { + out = execFileSync("git", ["grep", "-niE", "--untracked", pattern, "--", "."], { cwd: REPO_ROOT, encoding: "utf8", maxBuffer: 32 * 1024 * 1024, @@ -94,9 +100,12 @@ function trackedHits(pattern: string): { file: string; line: string }[] { } catch (e) { // git grep exits 1 with no output when nothing matches -- that is the pass. const err = e as { status?: number; stdout?: string }; + // Everything else must fail loud. A maxBuffer overflow in particular throws + // with status null and TRUNCATED stdout: adopting that buffer would return a + // partial walk that looks clean, which is the exact silent pass this test + // exists to prevent. if (err.status === 1 && !err.stdout) return []; - if (err.stdout) out = err.stdout; - else throw e; + throw e; } return out .split("\n") @@ -104,10 +113,30 @@ function trackedHits(pattern: string): { file: string; line: string }[] { .map((l) => ({ file: l.slice(0, l.indexOf(":")), line: l })); } +/** + * git grep matches CONTENT. A file whose contents are clean but whose NAME is a + * client's still leaks, and so does a directory named after one. + */ +function pathHits(pattern: string): { file: string; line: string }[] { + const out = execFileSync("git", ["ls-files", "--cached", "--others", "--exclude-standard"], { + cwd: REPO_ROOT, + encoding: "utf8", + maxBuffer: 32 * 1024 * 1024, + }); + const re = new RegExp(pattern, "i"); + return out + .split("\n") + .filter(Boolean) + .filter((f) => re.test(f)) + .map((f) => ({ file: f, line: `${f}: forbidden identifier in the PATH` })); +} + describe("no real client or personal data in a public repo", () => { for (const { pattern, what } of FORBIDDEN) { it(`does not contain ${what}`, () => { - const unexplained = trackedHits(pattern).filter((h) => !(h.file in ALLOWED)); + const unexplained = [...trackedHits(pattern), ...pathHits(pattern)].filter( + (h) => !(h.file in ALLOWED), + ); expect(unexplained.map((h) => h.line)).toEqual([]); }); } @@ -117,9 +146,11 @@ describe("no real client or personal data in a public repo", () => { // passes silently. Checking BOTH directions is what makes the list trustworthy. it("has no stale exemptions -- every allowed path still has a hit to explain", () => { const allHits = new Set(FORBIDDEN.flatMap(({ pattern }) => trackedHits(pattern)).map((h) => h.file)); - const stale = Object.keys(ALLOWED).filter( - (f) => f !== "app/__tests__/clientDataTripwire.test.ts" && !allHits.has(f), - ); + // This file is deliberately NOT exempted from the check. It always hits -- + // it contains the pattern sources themselves -- so it doubles as the + // liveness canary: if the scan ever silently returns nothing, this is the + // path that shows up stale and fails the test. + const stale = Object.keys(ALLOWED).filter((f) => !allHits.has(f)); expect(stale).toEqual([]); }); }); diff --git a/app/__tests__/ordoriteShared.test.ts b/app/__tests__/ordoriteShared.test.ts index 71b1acd9..3b3d5337 100644 --- a/app/__tests__/ordoriteShared.test.ts +++ b/app/__tests__/ordoriteShared.test.ts @@ -304,15 +304,31 @@ describe("return-order conventions are configuration", () => { // A misconfiguration must never WIDEN the match. A stray "," used to split to // nothing and join to "", producing an empty alternation that matched a - // zero-length store code -- broader than the default it replaced. - it("falls back to the default when the configured list is empty", () => { - for (const junk of [",", " , , ", " "]) { - process.env.ORDORITE_STORE_CODES = junk; + // zero-length store code -- broader than any default. + it("stands the rule down when the configured list is empty", () => { + for (const junk of [",", " , , ", " ", undefined]) { + if (junk === undefined) delete process.env.ORDORITE_STORE_CODES; + else process.env.ORDORITE_STORE_CODES = junk; expect(isReturnOrder("PA1234")).toBe(false); expect(isReturnOrder("A1")).toBe(false); - expect(isReturnOrder("SBOA1234")).toBe(true); + expect(isReturnOrder("SBOA1234")).toBe(false); } }); + + // There is no safe universal default for the store codes either. Any letter + // run ending in "A" before a digit -- SOFA1, MEGA1234, VIA3 -- would classify + // as RETURNED and be subtracted from revenue. Genuine returns are still + // caught by the negative-net-total check, so nothing is lost by declining. + it("does not guess a store code, and never flags an ordinary A-ending word", () => { + delete process.env.ORDORITE_STORE_CODES; + for (const o of ["SOFA1", "MEGA1234", "VIA3", "SBOA1234"]) { + expect(isReturnOrder(o)).toBe(false); + } + process.env.ORDORITE_STORE_CODES = "SB,GT,CH"; + expect(isReturnOrder("SBOA1234")).toBe(true); + expect(isReturnOrder("SOFA1")).toBe(false); + expect(isReturnOrder("MEGA1234")).toBe(false); + }); }); describe("isUntrustedMergeEmail", () => { @@ -637,12 +653,19 @@ describe("isReturnOrder", () => { expect(isReturnOrder("cr12345")).toBe(true); }); - it("detects A-suffix store codes as returns", () => { + it("detects A-suffix store codes as returns once the codes are configured", () => { + const prev = process.env.ORDORITE_STORE_CODES; + process.env.ORDORITE_STORE_CODES = "SB,GT,CH,BB,WS"; + try { expect(isReturnOrder("SBOA11221")).toBe(true); expect(isReturnOrder("GTOA10076")).toBe(true); expect(isReturnOrder("CHOA1234")).toBe(true); expect(isReturnOrder("BBOA10012")).toBe(true); - expect(isReturnOrder("WSOA10001")).toBe(true); + expect(isReturnOrder("WSOA10001")).toBe(true); + } finally { + if (prev === undefined) delete process.env.ORDORITE_STORE_CODES; + else process.env.ORDORITE_STORE_CODES = prev; + } }); it("does not flag M-suffix store codes (regular sales)", () => { diff --git a/app/prisma/seed/demo/guard.ts b/app/prisma/seed/demo/guard.ts index 1ab13c5c..978b4c32 100644 --- a/app/prisma/seed/demo/guard.ts +++ b/app/prisma/seed/demo/guard.ts @@ -58,7 +58,7 @@ export function maskDatabaseUrl(databaseUrl: string): string { export interface SafetyCheckOptions { /** True when --force-unsafe-db was passed or HOLT_SEED_FORCE_UNSAFE_DB=1 - * is set. Only lifts the CONFIRM_BLOCKED list -- never the hard block. */ + * is set. Only lifts the SCRATCH_DB_NAME requirement -- never the hard block. */ forceUnsafe: boolean; } diff --git a/app/scripts/apply-preset.impl.ts b/app/scripts/apply-preset.impl.ts index fcefc36c..d9b69259 100644 --- a/app/scripts/apply-preset.impl.ts +++ b/app/scripts/apply-preset.impl.ts @@ -15,8 +15,8 @@ // was malformed -- a GitOps runner (or a human) should treat this the same // as any other failed deploy step. // -// Data safety (CLAUDE.md rule 59): a restored or curated database and -// `akritos` hold restored/seeded tenant data and must never take a preset +// Data safety (CLAUDE.md rule 59): databases other than the local dev one may +// hold restored or curated tenant data and must never take a preset // apply by accident -- applying the wrong tenant's config to them is // exactly the kind of "wrong env" typo rule 59 exists to catch. Writing // (not dry-running) against any database other than fbc_dev_db requires diff --git a/app/src/lib/adapters/ordorite/shared.ts b/app/src/lib/adapters/ordorite/shared.ts index a5b9393c..9495539b 100644 --- a/app/src/lib/adapters/ordorite/shared.ts +++ b/app/src/lib/adapters/ordorite/shared.ts @@ -218,13 +218,20 @@ const RETURN_ORDER_PREFIX = /^(R|CR)-?\d/i; // ABxxA1234 for a return and ABxxM1234 for an order. The A/M suffix is the // vendor's convention; the STORE CODES are a deployment fact (CLAUDE.md 61-63), // so they come from config and default to "any code". -function storeCodeFragment(): string { +function storeCodeFragment(): string | null { + // Null on an EMPTY list, not just an unset one: "," and " , , " are truthy, + // split to nothing, and joined to "" would produce `^(?:)[A-Z]*A\d` -- a + // ZERO-length store code, BROADER than any default. A misconfiguration must + // never widen a match. + // + // And there is no safe universal default here, for the same reason as + // ORDORITE_RETURN_PREFIXES below. `[A-Z]{2,}` in front of `[A-Z]*A\d` matches + // ANY letter run whose last letter before the first digit is "A": SOFA1, + // MEGA1234, VIA3 all classify as RETURNED and get subtracted from revenue. + // Unconfigured, this rule stands down -- genuine returns are still caught by + // the negative-net-total check in deriveSalesOrderStatus. const codes = splitConfiguredCodes(process.env.ORDORITE_STORE_CODES); - // Falling back on an EMPTY list, not just an unset one. A value of "," or - // " , , " is truthy, splits to nothing, and used to join to "" -- producing - // `^(?:)[A-Z]*A\d`, which matches a ZERO-length code and is therefore - // BROADER than the default. A misconfiguration must never widen the match. - return codes.length ? codes.join("|") : "[A-Z]{2,}"; + return codes.length ? codes.join("|") : null; } /** @@ -303,9 +310,10 @@ export function isReturnOrder(orderno: string): boolean { // RETURN_ORDER_PREFIX, which is "R"/"CR" followed directly by digits. const returnPrefixes = returnPrefixFragment(); if (returnPrefixes && new RegExp(`^(?:${returnPrefixes})\\d`, "i").test(orderno)) return true; + if (RETURN_ORDER_PREFIX.test(orderno)) return true; const codes = storeCodeFragment(); - const returnStoreSuffix = new RegExp(`^(?:${codes})[A-Z]*A\\d`, "i"); - return RETURN_ORDER_PREFIX.test(orderno) || returnStoreSuffix.test(orderno); + if (!codes) return false; + return new RegExp(`^(?:${codes})[A-Z]*A\\d`, "i").test(orderno); } /** diff --git a/app/src/lib/config/presetFiles.ts b/app/src/lib/config/presetFiles.ts index 0b36ba98..db7745a0 100644 --- a/app/src/lib/config/presetFiles.ts +++ b/app/src/lib/config/presetFiles.ts @@ -11,7 +11,7 @@ // database, and is what a fresh clone gets. This is the // white-box product's default configuration. // config/local/ GITIGNORED. A specific deployment's real mappings — -// riverbend.yaml, northgate.yaml. Tenant data, not product +// riverbend.yaml, akritos.json. Tenant data, not product // code, and per docs/TENANCY.md it must not travel with // the white box. // $HOLT_CONFIG_DIR Optional override, for a deployment that keeps its diff --git a/app/src/lib/journalEntry.ts b/app/src/lib/journalEntry.ts index de24d0d8..adfce1eb 100644 --- a/app/src/lib/journalEntry.ts +++ b/app/src/lib/journalEntry.ts @@ -968,8 +968,8 @@ export async function generateSalesJournal( // SERVER-LOCAL, so the journal's window depended on the host's TZ; it matched // the comment only because the containers set no TZ and default to UTC. The // reconciliation compared that window against its own UTC-day window, so the - // two could agree only on a UTC deployment -- and a US retailer is - // America/New_York. + // two could agree only where the business time zone is UTC, which a real + // deployment's configured zone generally is not. const timeZone = await getBusinessTimeZone(); const dayKey = date.toISOString().slice(0, 10); const { gte: dayStart, lt: dayEndExclusive } = businessDayRange(dayKey, timeZone); diff --git a/config/presets/README.md b/config/presets/README.md index 6440af85..e056ecfd 100644 --- a/config/presets/README.md +++ b/config/presets/README.md @@ -22,7 +22,7 @@ in a mapping file are usually worth having. | Directory | Committed? | What it holds | |---|---|---| | `config/presets/` | **yes** | The white-box defaults. Tuned to the seed database, so a fresh clone works out of the box. | -| `config/local/` | **no** (gitignored) | One specific deployment's real mappings — `riverbend.yaml`, `northgate.yaml`. Tenant data, not product code. | +| `config/local/` | **no** (gitignored) | One specific deployment's real mappings — `riverbend.yaml`, `akritos.json`. Tenant data, not product code. | | `$HOLT_CONFIG_DIR` | n/a | Optional override for a deployment that keeps config in its own private repo or a mounted volume. | On a `(kind, name)` collision, **local wins over shipped** — a deployment can diff --git a/docs/domains/config-presets.md b/docs/domains/config-presets.md index 4380adff..ce659ccc 100644 --- a/docs/domains/config-presets.md +++ b/docs/domains/config-presets.md @@ -64,7 +64,7 @@ ignore the GUI; a shop with no engineers can ignore the files. | Directory | Committed | Purpose | | ------------------ | ------------------- | --------------------------------------------------------------------------------- | | `config/presets/` | **yes** | White-box defaults, tuned to the demo seed so a fresh clone works out of the box. | -| `config/local/` | **no** (gitignored) | One deployment's real mappings — `riverbend.yaml`, `northgate.json`. | +| `config/local/` | **no** (gitignored) | One deployment's real mappings — `riverbend.yaml`, `akritos.json`. | | `$HOLT_CONFIG_DIR` | n/a | Override for config kept in a private repo or a mounted volume. | `config/local/` is gitignored because a tenant's store names and vendor payment @@ -243,7 +243,7 @@ literals in `lib/storeColors.ts`. The counter, the POS, and holt rarely agree on what a store is called, and one store can own several counter labels — two co-located buildings counted -separately still roll up to one store. the pilot deployment's real data is exactly this +separately still roll up to one store. The pilot deployment's real data is exactly this shape: `NB` and `SB` are two doors of one showroom, and reading either alone computes conversion against half the store's traffic. diff --git a/docs/domains/import-pipeline.md b/docs/domains/import-pipeline.md index 39cdc534..1b702266 100644 --- a/docs/domains/import-pipeline.md +++ b/docs/domains/import-pipeline.md @@ -36,23 +36,33 @@ The pipeline runs daily at 6:10 AM via Synology Task Scheduler. | `Prior_Day_Sales_Data_Export` | sales | `runSalesImport` | Orders, line items, returns | | `Daily_Quote_Report` | quotes | `runQuotesImport` | Open quotes | | `Customer_Deposits_Export` | deposits | `runDepositsImport` | Customer deposits | -| `SH_Stock_by_Item` | stock | `runStockByItemImport` | Inventory positions | +| `_Stock_by_Item` | stock | `runStockByItemImport` | Inventory positions | | `Inbound_Items` | purchase-orders | `runPurchaseOrdersImport` | PO items with POR# | | `Prior_Day_POR_Export` | purchase-orders | `runPurchaseOrdersImport` | PO items with POR# | | `Prior_Day_Payments_Export` | payments | `runPaymentsImport` | Payment transactions | | `Prior_Day_Invoice_Export` | invoices | `runInvoicesImport` | Invoices (handles order rewrites) | -| `Company_Customers` OR `Company_Prior_Day_Customers` | customers | `runCustomerImport` | Customer records | +| `_Customers` OR `_Prior_Day_Customers` | customers | `runCustomerImport` | Customer records | | `Prior_Day_Received_Items` | received-items | `runReceivedItemsImport` | Goods in, creates ReceivingRecords | -| `Company_Inbound_Items` | inbound-items | `runInboundItemsImport` | Confirmed PO items with ESD | +| `_Inbound_Items` (prefix required) | inbound-items | `runInboundItemsImport` | Confirmed PO items with ESD | | `Prior_Day_Temp_Items` OR `Prior_Day_Temp_Purchase_Orders` | temp-items | `runTempItemsImport` | Draft PO items | -| `SH_Purchase_Order_Line_Export` | po-lines | `runPOLineExportImport` | PO line details | -| `SH_Item_Export` | products | `runProductsImport` | Daily product master (~100K rows, Active=yes only) | +| `_Purchase_Order_Line_Export` | po-lines | `runPOLineExportImport` | PO line details | +| `_Item_Export` | products | `runProductsImport` | Daily product master (~100K rows, Active=yes only) | -**Route order matters.** `Company_Inbound_Items` must be matched before the generic `Inbound_Items` pattern in `gmailReportRouter.ts`. +**`_` is configuration, not a constant.** The prefix comes from +`ORDORITE_REPORT_PREFIX`, which takes a comma-separated list because one +deployment normally uses more than one (a full name on some exports, an +initialism on others). Unset, the router matches the BARE report names only -- +`Customers.csv`, `Stock_by_Item.csv` -- and refuses a look-alike such as +`Deleted_Customers.csv` rather than guessing it is the customer master. + +**Route order matters.** `_Inbound_Items` is the one route that REQUIRES a +configured prefix, because a bare `Inbound_Items` is a different report with a +different runner. Unconfigured, the org route stands down and the bare route +keeps its meaning. See `src/lib/adapters/ordorite/reportRouter.ts`. **2026-05-20 renames** (owner-side the POS export config change): -- `Company_Customers` → `Company_Prior_Day_Customers` (scopes to prior-day-only data) +- `_Customers` → `_Prior_Day_Customers` (scopes to prior-day-only data) - `Prior_Day_Temp_Items` → `Prior_Day_Temp_Purchase_Orders` (clearer naming on the POS's side) Router regexes (`gmailReportRouter.ts`) match both old and new names so a fallback to the legacy filename still routes correctly. Tests pin both forms. diff --git a/docs/domains/imports-overview.md b/docs/domains/imports-overview.md index 6f67aeab..d86c36a9 100644 --- a/docs/domains/imports-overview.md +++ b/docs/domains/imports-overview.md @@ -37,21 +37,21 @@ Configured in `lib/adapters/ordorite/reportRouter.ts`. Each filename regex maps | `Prior_Day_Sales_Data_Export` | sales → `runSalesImport` | `import-pipeline.md`, `sales-orders.md` | | `Daily_Quote_Report` | quotes → `runQuotesImport` | `sales-orders.md` | | `Customer_Deposits_Export` | deposits → `runDepositsImport` | `accounting.md` | -| `SH_Stock_by_Item` | stock → `runStockByItemImport` | `inventory.md` | +| `_Stock_by_Item` | stock → `runStockByItemImport` | `inventory.md` | | `Prior_Day_Received_Items` | received-items → `runReceivedItemsImport` | `purchasing.md` | | `Prior_Day_Temp_(Items\|Purchase_Orders)` | temp-items → `runTempItemsImport` | `purchasing.md` | -| `SH_Purchase_Order_Line_Export` | po-lines → `runPOLineExportImport` | `purchasing.md` | -| `Company_Inbound_Items` | inbound-items → `runInboundItemsImport` | `purchasing.md` | +| `_Purchase_Order_Line_Export` | po-lines → `runPOLineExportImport` | `purchasing.md` | +| `_Inbound_Items` | inbound-items → `runInboundItemsImport` | `purchasing.md` | | `Inbound_Items` (generic, fallback) | purchase-orders → `runPurchaseOrdersImport` | `purchasing.md` | | `Prior_Day_POR_Export` | purchase-orders → `runPurchaseOrdersImport` | `purchasing.md` | | `Prior_Day_Payments_Export` | payments → `runPaymentsImport` | `accounting.md`, `pos.md` | | `Prior_Day_Invoice_Export` | invoices → `runInvoicesImport` | `accounting.md` | | `Company_(Prior_Day_)?Customers` | customers → `runCustomerImport` | `customer-intelligence.md`, `import-pipeline.md` | -| `SH_Item_Export` | products → `runProductsImport` | `import-pipeline.md` | +| `_Item_Export` | products → `runProductsImport` | `import-pipeline.md` | -**Route order matters.** First match wins. The specific `Company_Inbound_Items` pattern is listed BEFORE the generic `Inbound_Items` fallback so the more-specific runner is preferred. +**Route order matters.** First match wins. The specific `_Inbound_Items` pattern is listed BEFORE the generic `Inbound_Items` fallback so the more-specific runner is preferred. -**BOM stripping**: the gmail orchestrator (`lib/adapters/ordorite/orchestrator.ts`) passes a `transformHeader` to Papa.parse that strips the U+FEFF byte-order mark and trims surrounding whitespace from header names. the POS ships some CSVs (including `SH_Item_Export`) with a UTF-8 BOM that would otherwise become part of the first column key (the first column header would parse as `U+FEFF` + `Active` rather than `Active`) and silently break alias matching. Added 2026-05-26 with the SH Item Export wiring. +**BOM stripping**: the gmail orchestrator (`lib/adapters/ordorite/orchestrator.ts`) passes a `transformHeader` to Papa.parse that strips the U+FEFF byte-order mark and trims surrounding whitespace from header names. the POS ships some CSVs (including `_Item_Export`) with a UTF-8 BOM that would otherwise become part of the first column key (the first column header would parse as `U+FEFF` + `Active` rather than `Active`) and silently break alias matching. Added 2026-05-26 with the Item Export wiring. ## 2026-05-20 renames (owner-side the POS changes) @@ -59,7 +59,7 @@ The owner renamed two reports on the POS's side to scope them to prior-day-only | Was | Now | Why | |---|---|---| -| `Company_Customers.csv` | `Company_Prior_Day_Customers.csv` | Scope to prior-day-only data (not the entire historical customer master) | +| `_Customers.csv` | `_Prior_Day_Customers.csv` | Scope to prior-day-only data (not the entire historical customer master) | | `Prior_Day_Temp_Items.csv` | `Prior_Day_Temp_Purchase_Orders.csv` | Clearer semantic name on the POS's side | Both renames are documented in `docs/domains/import-pipeline.md` and pinned by tests in `__tests__/ordoriteReportRouter.test.ts` (legacy regression + post-rename coverage both present). @@ -68,7 +68,7 @@ Both renames are documented in `docs/domains/import-pipeline.md` and pinned by t **Two entry points, ONE runner.** Both call `runProductsImport` in `lib/adapters/ordorite/runners.ts`. -### Daily auto-import — `SH_Item_Export.csv` (2026-05-26+) +### Daily auto-import — `_Item_Export.csv` (2026-05-26+) Owner direction 2026-05-22: *"ensure this file gets imported too during the automated gmail imports."* Wired in 2026-05-26. diff --git a/docs/domains/inventory.md b/docs/domains/inventory.md index b94b7a71..c4335b90 100644 --- a/docs/domains/inventory.md +++ b/docs/domains/inventory.md @@ -110,7 +110,7 @@ care rather than flipping the endpoint's default. ## Stock-by-Item import (daily) -CSV: `SH_Stock_by_Item.csv` from the POS (Gmail auto-import 06:10 ET). Runner: `runStockByItemImport` in `lib/importRunners.ts`. One row per (product, store location) with current on-hand qty. +CSV: `_Stock_by_Item.csv` from the POS (Gmail auto-import 06:10 ET). Runner: `runStockByItemImport` in `lib/importRunners.ts`. One row per (product, store location) with current on-hand qty. **The location-matching gotcha** (post-failure 2026-04-24): @@ -179,7 +179,7 @@ Both read from `PhysicalInventoryCount` + `InventoryPosition` and compute scanne The classic "where's the missing inventory" debugging path: 1. Check `InventoryPosition` for the (product, location) row in question -2. If position looks low, query the most recent `AutoImportLog` row for `SH_Stock_by_Item` — see if `unmappedLocations` includes the location's CSV name +2. If position looks low, query the most recent `AutoImportLog` row for `_Stock_by_Item` — see if `unmappedLocations` includes the location's CSV name 3. If yes → add an alias on the `StockLocation` row, re-trigger the import 4. If no → check `PhysicalInventoryCount` for recent scans that might indicate a manual correction was made diff --git a/docs/domains/purchasing.md b/docs/domains/purchasing.md index d6477143..572ab825 100644 --- a/docs/domains/purchasing.md +++ b/docs/domains/purchasing.md @@ -21,7 +21,7 @@ Daily imports: - `runPurchaseOrdersImport` -- creates PO + items from `Inbound_Items`/`Prior_Day_POR_Export` - `runReceivedItemsImport` -- creates ReceivingRecords from `Prior_Day_Received_Items` -- `runInboundItemsImport` -- updates ESD from `Company_Inbound_Items` (no POR#) +- `runInboundItemsImport` -- updates ESD from `_Inbound_Items` (no POR#) - `runTempItemsImport` -- creates draft PO items from `Prior_Day_Temp_Items` ## PO Status @@ -38,7 +38,7 @@ When a Marjan vendor PO transitions to RECEIVED_FULL, the import runner auto-cre ## Expected Delivery Dates -The `Company_Inbound_Items` report provides ESD (`Expecteddate`). The `runInboundItemsImport` runner stores this as `PurchaseOrder.expectedDelivery`. The `Inbound_Items` report does NOT have ESD. +The `_Inbound_Items` report provides ESD (`Expecteddate`). The `runInboundItemsImport` runner stores this as `PurchaseOrder.expectedDelivery`. The `Inbound_Items` report does NOT have ESD. ## Invoice Import and Order Rewrites diff --git a/docs/tenant-literal-sweep.md b/docs/tenant-literal-sweep.md index cb050123..20f97a08 100644 --- a/docs/tenant-literal-sweep.md +++ b/docs/tenant-literal-sweep.md @@ -617,7 +617,7 @@ const widthDim = useMemo(() => dimensions.find((d) => d.name === "Table Width"), ### `app/src/lib/adapters/ordorite/reportRouter.ts:49` ``` -pattern: /SH_Stock_by_Item/i, +pattern: /_Stock_by_Item/i, ``` @@ -832,7 +832,7 @@ Persisted onto every `OrderLineItem`, so it flows into journal-entry tax lines, **Fix.** No new config. Import `resolveTaxDistrict` + `rateForLineAmount` from `@/lib/tax/resolveTaxRate`; resolve once per proposal (customer district → store district → `AppSettings.defaultTaxDistrictId`), call `rateForLineAmount` per line, persist the resolved `taxDistrictId` on the order. Copy the call shape from `app/src/pages/api/sales/orders/create-from-cart.ts:123` and `:252`. Delete both literals. -**Test (equivalence).** Convert every proposal converted in the last 12 months on a restored snapshot, old code vs new. Compare per-line `vatRate`, `vatAmount`, and order-level tax total. On the the pilot deployment district all must be byte-identical; any diff is a real pre-existing bug in the band logic and must be explained before merge. Then flip `defaultTaxDistrictId` to a non-CT district in a scratch DB and assert the rate changes. +**Test (equivalence).** Convert every proposal converted in the last 12 months on a restored snapshot, old code vs new. Compare per-line `vatRate`, `vatAmount`, and order-level tax total. On the pilot deployment's district all must be byte-identical; any diff is a real pre-existing bug in the band logic and must be explained before merge. Then flip `defaultTaxDistrictId` to a non-CT district in a scratch DB and assert the rate changes. **Why first.** Largest money error per event, one file, config already exists and is simply unread. Zero coupling to anything else on this list. @@ -910,7 +910,7 @@ An `updateMany` driven by a string shape. Any style under that vendor whose numb ### 7. Ordorite report routing table is the pilot deployment filenames — NEW, data-correctness + feature-dead `app/src/lib/adapters/ordorite/reportRouter.ts:74` — `pattern: /_Inbound_Items/i` -`app/src/lib/adapters/ordorite/reportRouter.ts:49` — `pattern: /SH_Stock_by_Item/i` +`app/src/lib/adapters/ordorite/reportRouter.ts:49` — `pattern: /_Stock_by_Item/i` `app/src/lib/adapters/ordorite/reportRouter.ts:125` — `/Marjan_Daily_Sales/i` in `SKIP_PATTERNS` (cosmetic; free once the table moves) `Acme_Furniture_Inbound_Items` falls past line 74 to line 79 and is fed to `runPurchaseOrdersImport` instead of `runInboundItemsImport` — **wrong entity written**. `Acme_Customers` matches nothing, `resolveImportRoute` returns null, and the customer master import logs as "skipped" forever. Same for the `SH_`-prefixed stock/PO-line/catalog routes: stock-on-hand and the product catalog never populate, no error surfaced. @@ -1169,7 +1169,7 @@ An operator who fills in "API Base URL" sees it **silently ignored**; a regional **Fix.** Two nullable fields on the existing Google `IntegrationCredential` (or AppSettings): `googleProjectRootFolderId`, `googlePresentationTemplateId`, set in Admin. Return **400 with "Google project folders are not configured"** when either is absent, instead of throwing a Drive error. Add a `projectFolderSubfolders` string array (defaulted in code to the current list) plus a separate `presentationSubfolder` field naming which one receives the template copy, so the load-bearing folder is selected by config rather than matched against a magic literal. -**Test.** With the current ids configured, create a project folder on the real the pilot deployment Drive and assert the resulting tree (six subfolders, template copied into the named one) is identical to today. Unconfigured: assert 400 with the config message and no Drive call made. +**Test.** With the current ids configured, create a project folder on the real pilot deployment Drive and assert the resulting tree (six subfolders, template copied into the named one) is identical to today. Unconfigured: assert 400 with the config message and no Drive call made. --- diff --git a/env.example b/env.example index 8f937331..79a8c54e 100644 --- a/env.example +++ b/env.example @@ -9,10 +9,11 @@ # --- Database (used by both Docker Compose and the app) --- # connection_limit: max pool size (default = num_cpus * 2 + 1, often too low on NAS) # pool_timeout: seconds to wait for a free connection before erroring (default 10) -# NOTE the database name. `fbc_dev_db` is refused by the demo seed -# (prisma/seed/demo/guard.ts) because it is the shared database every local -# ~/holt session points at -- seeding it would clobber real work. A fresh -# clone gets its own name so `npm run setup` just works. +# NOTE the database name. `npm run setup` seeds DEMO DATA, so it only runs +# against a database whose name says that is what it is for -- one carrying +# seed/demo/scratch/sandbox/sample/ci as a whole word (prisma/seed/demo/guard.ts). +# Every other name is assumed to hold real work and is refused. A production +# deployment names its database something else and does not run the demo seed. DATABASE_URL=postgresql://dbuser_fbc:your-password-here@db:5432/holt_demo?connection_limit=20&pool_timeout=10 POSTGRES_USER=dbuser_fbc POSTGRES_PASSWORD=your-password-here diff --git a/scripts/restore-drill.sh b/scripts/restore-drill.sh index 2e7e9244..6f1b9d75 100755 --- a/scripts/restore-drill.sh +++ b/scripts/restore-drill.sh @@ -122,7 +122,10 @@ cleanup() { # someone re-deriving this list). Per CLAUDE.md rule 59 and this repo's own # data-safety convention: any database holding restored, seeded or live local # data must never be written by a script, and the Jest suite's database is -# reserved for it. None are acceptable restore-drill targets. +# reserved for it. None are acceptable restore-drill targets. The deny-list +# below names only what can be named in a public repo -- deployment-specific +# database names are a deployment fact and stay out of the tree -- which is +# precisely why the allow-list prefix above is the PRIMARY guard, not this. # --------------------------------------------------------------------------- case "$DRILL_DB_NAME" in holt_restore_drill*) ;; @@ -134,7 +137,7 @@ case "$DRILL_DB_NAME" in exit 1 ;; esac -for forbidden in postgres template0 template1; do +for forbidden in fbc_dev_db fbc_test_db akritos postgres template0 template1; do drill_lc=$(printf '%s' "$DRILL_DB_NAME" | tr '[:upper:]' '[:lower:]') if [ "$drill_lc" = "$forbidden" ]; then echo "ERROR: refusing to target '$DRILL_DB_NAME' -- it matches a real or" >&2 From eaf94e078d6225c8d039e23e8ad2d5a7843f7073 Mon Sep 17 00:00:00 2001 From: goetchstone Date: Wed, 26 Aug 2026 14:29:51 -0400 Subject: [PATCH 09/11] fix(docs): scrub two client mentions #143 brought in on rebase The tripwire caught these the moment this branch rebased onto the merged #143, which is the whole reason it exists: the scrub was correct when written, and a merge from another branch put the name straight back. Co-Authored-By: Claude Opus 5 --- docs/domains/delivery-integrations.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/domains/delivery-integrations.md b/docs/domains/delivery-integrations.md index 35d13cae..389bd5c7 100644 --- a/docs/domains/delivery-integrations.md +++ b/docs/domains/delivery-integrations.md @@ -209,9 +209,9 @@ whether they take it before making it louder. ## Providers to evaluate -Not a recommendation — a shortlist, with what to check. SureCam is what Saybrook -runs today and is video-first, so the question there is whether its GPS side is -exposed well enough to be the only integration. +Not a recommendation — a shortlist, with what to check. SureCam is what the +pilot deployment runs today and is video-first, so the question there is +whether its GPS side is exposed well enough to be the only integration. | Provider | Why it is on the list | What to verify | | --- | --- | --- | @@ -220,7 +220,7 @@ exposed well enough to be the only integration. | **Geotab** | Very large partner ecosystem, deep API, device-based | SDK shape is its own thing; more integration work | | **Azuga** | Aimed at small fleets | API completeness — thinner than the above | | **Verizon Connect** | Enterprise incumbent | Contract length and whether the API costs extra | -| **SureCam** | Already in use at Saybrook | Whether GPS is available without buying the video product | +| **SureCam** | Already in use at the pilot deployment | Whether GPS is available without buying the video product | **The thing to check first, for any of them: is API access included, or an upsell?** Several fleet products price the API separately, and that single fact From 5208da38f2833db613cda3b5974bb36a90929e37 Mon Sep 17 00:00:00 2001 From: goetchstone Date: Wed, 26 Aug 2026 15:47:13 -0400 Subject: [PATCH 10/11] fix: a third sweep found a real customer couple and the dealer prices' twins A fresh-eyes sweep -- five hunters told nothing about what the earlier passes had already removed, so they could not be anchored by my assumptions -- raised 60 hits and confirmed 23. The worst was untouched since the initial public release: "Penny/Steve Sigal", a real customer couple, in a quarantined it.skip block. Both earlier passes walked that file and edited lines above and below it. The row's phone and order number are fictional placeholders, which is exactly why it read as synthetic -- but the file's history shows the author's habit was real names beside sanitized numbers, so the fake phone vouched for nothing. The rest is one pattern: the earlier passes scrubbed the FIXTURES and missed their twins. Every vendor's dealer prices survived in the parser source doc comment that documents the fixture, in the home-accessory normalizer fixture that consumes it, or both -- so a comment and the fixture it described no longer even agreed. Two files neither pass had opened at all, nuorderPrintout and kkOrderParser, still carried real PO numbers and wholesale prices beside suggested retail. All of it now uses the same invented values as the corresponding fixture, with each parser's arithmetic preserved (qty x price == extension, extensions summing to the printed total, cancelled-section totals restated) and the aligned caret comments in two parser sources kept to width. Also gone: a real ZIP as the worked example in the address parser, and the last "Erin Kelly" in a JSDoc. The tripwire now covers the document numbers and the specific dealer figures, not just names -- it caught the Sigal line the moment the pattern went in. Its one miss in this pass is worth recording: a bulk substitution applied a short replacement before a longer one that contained it, leaving a fixture line arithmetically inconsistent and its test asserting nothing. Tests caught that, not the tripwire. 3,478 unit tests pass; integration suites pass; tsc, prettier and lint clean. --- app/__tests__/apparelOrderVendors.test.ts | 2 +- app/__tests__/beatrizBallBuyerLines.test.ts | 2 +- app/__tests__/beatrizBallOrderParser.test.ts | 4 +- app/__tests__/clientDataTripwire.test.ts | 13 ++-- app/__tests__/frankEileenParser.test.ts | 4 +- .../homeAccessoryBuyerDraftMapping.test.ts | 40 +++++------ app/__tests__/homeAccessoryOrders.test.ts | 68 +++++++++---------- app/__tests__/homeAccessoryRows.test.ts | 32 ++++----- ...meAccessoryOrderCommit.integration.test.ts | 18 ++--- ...ServiceCaseSheetImport.integration.test.ts | 4 +- app/__tests__/marketTimeOrderParser.test.ts | 10 +-- app/__tests__/nuorderPrintoutParser.test.ts | 67 +++++++++--------- app/src/lib/adapters/ordorite/shared.ts | 4 +- app/src/lib/homeAccessoryOrders.ts | 8 +-- .../pricing/aestheticMovementOrderParser.ts | 4 +- app/src/lib/pricing/brandWiseOrderParser.ts | 9 +-- app/src/lib/pricing/frankEileenParser.ts | 2 +- app/src/lib/pricing/kkOrderParser.ts | 2 +- app/src/lib/pricing/marketTimeOrderParser.ts | 4 +- app/src/lib/pricing/nuorderPrintoutParser.ts | 8 +-- app/src/lib/pricing/simblistCsvOrderParser.ts | 4 +- app/src/lib/pricing/wendoverOrderParser.ts | 4 +- 22 files changed, 160 insertions(+), 153 deletions(-) diff --git a/app/__tests__/apparelOrderVendors.test.ts b/app/__tests__/apparelOrderVendors.test.ts index f98eec5b..17c785ed 100644 --- a/app/__tests__/apparelOrderVendors.test.ts +++ b/app/__tests__/apparelOrderVendors.test.ts @@ -173,7 +173,7 @@ describe("normalizeNuOrder", () => { function nuOrderPrintoutFixture(): NuOrderPrintout { return { vendorName: "", - poNumber: "PO-18908185", + poNumber: "PO-19900002", orderDate: "06/01/2026", deliveryStart: "07/01/2026", deliveryEnd: "08/01/2026", diff --git a/app/__tests__/beatrizBallBuyerLines.test.ts b/app/__tests__/beatrizBallBuyerLines.test.ts index ce418b1a..475da3b5 100644 --- a/app/__tests__/beatrizBallBuyerLines.test.ts +++ b/app/__tests__/beatrizBallBuyerLines.test.ts @@ -20,7 +20,7 @@ import { buyerBoilerplate, parseBeatrizBallOrderText } from "@/lib/pricing/beatr function confirmation(buyerName: string): string { return [ "Sales Order 12345", - "349699.0056.0024.754GLASS Vento Medium Vase (Clear)", + "349672.0045.0018.004GLASS Vento Medium Vase (Clear)", buyerName, "123 Harbour Road", ].join("\n"); diff --git a/app/__tests__/beatrizBallOrderParser.test.ts b/app/__tests__/beatrizBallOrderParser.test.ts index d5f43ad7..5fe23c06 100644 --- a/app/__tests__/beatrizBallOrderParser.test.ts +++ b/app/__tests__/beatrizBallOrderParser.test.ts @@ -47,8 +47,8 @@ describe("parseBeatrizBallOrderText", () => { }); it("splits the item-code / amount boundary using wholesale x qty == amount", () => { - // "349699.0056.0024.754..." -> code 3496, amount 99.00, msrp 56.00, - // wholesale 24.75, qty 4 (NOT code 34969, amount 9.00). + // "349699.0056.0018.004..." -> code 3496, amount 72.00, msrp 45.00, + // wholesale 18.00, qty 4 (NOT code 34969, amount 9.00). const vase = order.items.find((i) => i.itemCode === "3496"); expect(vase).toMatchObject({ qty: 4, unitPrice: 18, lineTotal: 72, msrp: 45 }); expect(vase?.name).toBe("GLASS Vento Medium Vase (Clear)"); diff --git a/app/__tests__/clientDataTripwire.test.ts b/app/__tests__/clientDataTripwire.test.ts index 4e664477..1ef10b16 100644 --- a/app/__tests__/clientDataTripwire.test.ts +++ b/app/__tests__/clientDataTripwire.test.ts @@ -46,7 +46,10 @@ const FORBIDDEN: { pattern: string; what: string }[] = [ { pattern: "greenstein|panagy|dransfield|matheny|tenerow", what: "real people's surnames" }, { pattern: "sorbo|nordquist|calkins|barnum|homan|favale|vantongeren", what: "real people's surnames" }, { pattern: "860-470-3653|213-623-1345|860-388-0891|860-388-3692", what: "real phone and fax numbers" }, - { pattern: "ciccone|dwyer|germano|filippone|levatino|sorbo|demik", what: "real people's surnames" }, + { + pattern: "ciccone|dwyer|germano|filippone|levatino|sorbo|demik|sigal", + what: "real people's surnames", + }, // Matched as whole names, because each of these surnames is far too common to // use on its own -- a bare "kelly" or "warren" would fire on ordinary code and // the entry would get deleted rather than fixed. @@ -62,11 +65,13 @@ const FORBIDDEN: { pattern: string; what: string }[] = [ // to a real order. Publishing another business's trade terms is the harm -- // naming the vendor is not, which is why the brands themselves are absent here. { - pattern: "PON090[0-9][0-9]|B31669979|153642-070126|1000292821|0063477|0063476|31680534|77234f1af6", - what: "real vendor order and document numbers", + pattern: + "PON090[0-9][0-9]|B31669979|153642-070126|1000292821|0063477|0063476|31680534|77234f1af6|0002592360|0002592361|18573341|18908185|32008813", + what: "real vendor order, PO and document numbers", }, { - pattern: "10,976\\.49|32,108\\.67|1,057\\.62|1,188\\.57|22,373\\.00|2,688\\.00|3,322\\.00|2,196\\.00|722\\.74|2,368\\.50", + pattern: + "10,976\\.49|32,108\\.67|1,057\\.62|1,188\\.57|22,373\\.00|2,688\\.00|3,322\\.00|2,196\\.00|722\\.74|2,368\\.50|9,?298\\.9|2,?484\\.65|352\\.54|53\\.94|107\\.88|24\\.75|39\\.99|1,?710\\.00|\\b285\\.00|\\b294\\.00|\\b396\\.00|\\b228\\.00|\\b688\\.00", what: "real vendor order totals and dealer prices", }, { pattern: "costs top out at", what: "a disclosure of a vendor's catalog price ceiling" }, diff --git a/app/__tests__/frankEileenParser.test.ts b/app/__tests__/frankEileenParser.test.ts index e7cd9d5c..6c34d8ea 100644 --- a/app/__tests__/frankEileenParser.test.ts +++ b/app/__tests__/frankEileenParser.test.ts @@ -30,7 +30,7 @@ MILLBROOK FALLS, VT 0561414 CANAL STREET MILLBROOK FALLS, VT 05614 Phone: 802-555-0142Fax: 802-555-0143 RIVERBSpring 202610/07/2506/01/2606/15/26UPS GROUND -18573341PRE-PAID CREETT Dana WhitlHOUS HOUSEROBN Robin Sant`; +19900001PRE-PAID CREETT Dana WhitlHOUS HOUSEROBN Robin Sant`; // 4+3+5+7+2 = 21 units; 512+384+640+1162+308 = 3006.00 const FIXTURE = `${HEADER} @@ -75,7 +75,7 @@ describe("parseFrankEileenText — header fields", () => { }); it("reads the customer P.O. off the terms line", () => { - expect(order.poNumber).toBe("18573341"); + expect(order.poNumber).toBe("19900001"); }); it("reads season and the three header dates", () => { diff --git a/app/__tests__/homeAccessoryBuyerDraftMapping.test.ts b/app/__tests__/homeAccessoryBuyerDraftMapping.test.ts index a0ee1552..67e45200 100644 --- a/app/__tests__/homeAccessoryBuyerDraftMapping.test.ts +++ b/app/__tests__/homeAccessoryBuyerDraftMapping.test.ts @@ -33,14 +33,14 @@ function effectiveRow(overrides: Partial = {}): EffectiveRow { color: "", size: "", qty: 4, - cost: 39.99, + cost: 44.50, msrp: null, selling: null, department: "Home Acc", category: "Decor", supplier: "K & K Interiors", barcode: "842657186221", - reference: "0002592360", + reference: "0009900001", ...overrides, }; } @@ -73,12 +73,12 @@ describe("groupRowsByReference — multi-PO bundles", () => { it("a K&K-style two-order bundle creates two groups, not one merged group", () => { const rows = [ - effectiveRow({ key: "0", reference: "0002592360" }), - effectiveRow({ key: "1", reference: "0002592361" }), + effectiveRow({ key: "0", reference: "0009900001" }), + effectiveRow({ key: "1", reference: "0009900002" }), ]; expect(groupRowsByReference(rows).map((g) => g.reference)).toEqual([ - "0002592360", - "0002592361", + "0009900001", + "0009900002", ]); }); @@ -128,28 +128,28 @@ describe("unassignedRows", () => { describe("buildHomeAccessoryPoCreateBody", () => { it("maps the group's reference to referenceNumber and carries vendor + buy context", () => { - const group = { reference: "0002592360", rows: [effectiveRow()] }; + const group = { reference: "0009900001", rows: [effectiveRow()] }; const body = buildHomeAccessoryPoCreateBody(group, ctx({ buyId: 42 })); expect(body).toMatchObject({ vendorId: 7, vendorName: "K & K Interiors", - referenceNumber: "0002592360", + referenceNumber: "0009900001", buyId: 42, }); - expect(body.notes).toContain("0002592360"); + expect(body.notes).toContain("0009900001"); }); it("looks up expectedShipMonth from the context's per-reference date map", () => { - const group = { reference: "0002592360", rows: [effectiveRow()] }; + const group = { reference: "0009900001", rows: [effectiveRow()] }; const body = buildHomeAccessoryPoCreateBody( group, - ctx({ requiredDateByReference: { "0002592360": "8/1/26" } }), + ctx({ requiredDateByReference: { "0009900001": "8/1/26" } }), ); expect(body.expectedShipMonth).toBe("8/1/26"); }); it("falls through to null when the reference has no mapped date", () => { - const group = { reference: "0002592360", rows: [effectiveRow()] }; + const group = { reference: "0009900001", rows: [effectiveRow()] }; const body = buildHomeAccessoryPoCreateBody(group, ctx()); expect(body.expectedShipMonth).toBeNull(); }); @@ -171,7 +171,7 @@ describe("buildHomeAccessoryItemCreateBody", () => { vendorName: "K & K Interiors", partNumber: "KKI-15668B", productName: "13.5 Inch Brown Resin Horse", - cost: 39.99, + cost: 44.50, qty: 4, barcode: "842657186221", departmentId: 1, @@ -191,25 +191,25 @@ describe("buildHomeAccessoryItemCreateBody", () => { it("retail falls back: selling, then msrp, then cost — never left blank", () => { const withSelling = buildHomeAccessoryItemCreateBody( - effectiveRow({ selling: 99.95, msrp: 120, cost: 39.99 }), + effectiveRow({ selling: 99.95, msrp: 120, cost: 44.50 }), 1, ctx(), ); expect(withSelling.retail).toBe(99.95); const withMsrpOnly = buildHomeAccessoryItemCreateBody( - effectiveRow({ selling: null, msrp: 56, cost: 24.75 }), + effectiveRow({ selling: null, msrp: 45, cost: 18.00 }), 1, ctx(), ); - expect(withMsrpOnly.retail).toBe(56); + expect(withMsrpOnly.retail).toBe(45); const costOnly = buildHomeAccessoryItemCreateBody( - effectiveRow({ selling: null, msrp: null, cost: 39.99 }), + effectiveRow({ selling: null, msrp: null, cost: 44.50 }), 1, ctx(), ); - expect(costOnly.retail).toBe(39.99); + expect(costOnly.retail).toBe(44.50); }); it("msrp stays null when nothing was typed and no markup applied — never guesses at retail", () => { @@ -253,11 +253,11 @@ describe("buildHomeAccessoryItemCreateBody", () => { it("stamps notes with the source label and the row's order reference", () => { const body = buildHomeAccessoryItemCreateBody( - effectiveRow({ reference: "0002592360" }), + effectiveRow({ reference: "0009900001" }), 1, ctx({ sourceLabel: "Home Accessory Order Import — K & K Interiors" }), ); - expect(body.notes).toBe("Home Accessory Order Import — K & K Interiors — order 0002592360"); + expect(body.notes).toBe("Home Accessory Order Import — K & K Interiors — order 0009900001"); }); }); diff --git a/app/__tests__/homeAccessoryOrders.test.ts b/app/__tests__/homeAccessoryOrders.test.ts index 46a21a01..859d0a19 100644 --- a/app/__tests__/homeAccessoryOrders.test.ts +++ b/app/__tests__/homeAccessoryOrders.test.ts @@ -50,15 +50,15 @@ function bundle(overrides: Partial = {}): KKOrderBundle { orderDate: "Jun 15, 2026", orders: [ { - orderNumber: "0002592360", + orderNumber: "0009900001", requiredDate: "8/1/26", - printedTotal: 9298.91, + printedTotal: 4500.01, items: [ { itemNumber: "15668B", description: "13.5 Inch Brown Resin Horse", uom: "EA", - unitPrice: 39.99, + unitPrice: 44.50, qty: 4, requiredDate: "8/1/26", upc: "842657186221", @@ -75,9 +75,9 @@ function bundle(overrides: Partial = {}): KKOrderBundle { ], }, { - orderNumber: "0002592361", + orderNumber: "0009900002", requiredDate: "9/1/26", - printedTotal: 2484.65, + printedTotal: 1200.65, items: [ { itemNumber: "17429A-TN", @@ -279,7 +279,7 @@ describe("applyMarkup", () => { it("applies the markup and rounds UP to a 5 or 9", () => { expect(applyMarkup(25.64, 2.5)).toBe(65); expect(applyMarkup(84, 2.5)).toBe(215); - expect(applyMarkup(352.54, 2.3)).toBe(815); + expect(applyMarkup(400, 2.3)).toBe(925); }); it("returns null for non-positive cost or a non-finite/non-positive markup", () => { @@ -293,8 +293,8 @@ describe("normalizeKKBundle", () => { it("summarizes each order for the page header", () => { const draft = normalizeKKBundle(bundle()); expect(draft.orders).toEqual([ - { orderNumber: "0002592360", requiredDate: "8/1/26", itemCount: 2 }, - { orderNumber: "0002592361", requiredDate: "9/1/26", itemCount: 1 }, + { orderNumber: "0009900001", requiredDate: "8/1/26", itemCount: 2 }, + { orderNumber: "0009900002", requiredDate: "9/1/26", itemCount: 1 }, ]); }); @@ -303,7 +303,7 @@ describe("normalizeKKBundle", () => { expect(draft.rows).toHaveLength(3); expect(draft.rows.map((r) => r.partNumber)).toEqual(["15668B", "90021D-NA", "17429A-TN"]); // The reference is what makes one bundle create several draft POs. - expect(draft.rows.map((r) => r.reference)).toEqual(["0002592360", "0002592360", "0002592361"]); + expect(draft.rows.map((r) => r.reference)).toEqual(["0009900001", "0009900001", "0009900002"]); }); it("maps an item to the HomeAccessoryExportRow shape", () => { @@ -316,14 +316,14 @@ describe("normalizeKKBundle", () => { color: "", size: "", qty: 4, - cost: 39.99, + cost: 44.50, msrp: null, selling: null, department: "", category: "", supplier: "K & K Interiors", barcode: "842657186221", - reference: "0002592360", + reference: "0009900001", }); }); @@ -345,7 +345,7 @@ describe("normalizeKKBundle", () => { it("carries warnings through verbatim", () => { const warnings = [ - "Order 0002592360: calculated total $9,298.90 does not match printed total $9,298.91", + "Order 0009900001: calculated total $4,500.00 does not match printed total $4,500.01", ]; const draft = normalizeKKBundle(bundle({ warnings })); expect(draft.warnings).toEqual(warnings); @@ -398,7 +398,7 @@ function wendoverItem(over: Partial = {}) { sku: "WLD3511", name: "Before the Rain Customized", lineTotal: 1200, - unitPrice: 352.54, + unitPrice: 400, qty: 3, medium: "Canvas", treatment: "Gallery Wrapped, Artist Enhanced", @@ -445,7 +445,7 @@ describe("wendoverDescription", () => { describe("normalizeWendoverOrder", () => { it("carries the DERIVED unit cost, never the printed line total", () => { const [row] = normalizeWendoverOrder(wendoverOrder()).rows; - expect(row.cost).toBe(352.54); + expect(row.cost).toBe(400); expect(row.qty).toBe(3); }); @@ -586,8 +586,8 @@ describe("normalizeBrandWiseOrder", () => { name: "The Cadier Wooden Wall Mirrors", qty: 4, uom: "EA", - unitPrice: 200, - lineTotal: 800, + unitPrice: 250, + lineTotal: 1000, }, ], warnings: [], @@ -597,7 +597,7 @@ describe("normalizeBrandWiseOrder", () => { it("takes the unit price as the cost and leaves the barcode blank", () => { const [row] = normalizeBrandWiseOrder(bwOrder()).rows; - expect(row.cost).toBe(200); + expect(row.cost).toBe(250); expect(row.barcode).toBe(""); expect(row.partNumber).toBe("IN-8222"); }); @@ -622,7 +622,7 @@ describe("normalizeAestheticMovementOrder", () => { vendorName: "Printworks", poNumber: "PON00003", shipDate: "October 01, 2026", - printedTotal: 2688, + printedTotal: 1200, printedItems: 2, printedUnits: 18, items: [ @@ -631,8 +631,8 @@ describe("normalizeAestheticMovementOrder", () => { name: "Classic - Tic Tac Toe", upc: "7350108174152", qty: 12, - unitPrice: 33, - lineTotal: 396, + unitPrice: 25, + lineTotal: 300, }, { sku: "PW00821", @@ -650,7 +650,7 @@ describe("normalizeAestheticMovementOrder", () => { it("takes the unit price as the cost and carries the manufacturer UPC", () => { const [row] = normalizeAestheticMovementOrder(amOrder()).rows; - expect(row.cost).toBe(33); + expect(row.cost).toBe(25); expect(row.barcode).toBe("7350108174152"); expect(row.partNumber).toBe("PW00689"); }); @@ -682,15 +682,15 @@ describe("normalizeSuperCatOrder", () => { customerPo: "", orderDate: "7/1/26", shipDate: "8/11/26", - printedSubtotal: 1710, + printedSubtotal: 1260, orderDiscount: 0, items: [ { itemNumber: "9BOATLINEG", name: "Boa Table Lamp", qty: 6, - unitPrice: 285, - lineTotal: 1710, + unitPrice: 210, + lineTotal: 1260, }, ], warnings: [], @@ -700,7 +700,7 @@ describe("normalizeSuperCatOrder", () => { it("takes the unit price as the cost and leaves the barcode blank", () => { const [row] = normalizeSuperCatOrder(scOrder()).rows; - expect(row.cost).toBe(285); + expect(row.cost).toBe(210); expect(row.barcode).toBe(""); expect(row.partNumber).toBe("9BOATLINEG"); }); @@ -736,10 +736,10 @@ describe("normalizeSimblistOrder", () => { itemNumber: "ZFUSA03-C", name: "Big Time Brownie Mix - case pack of 6", qty: 2, - unitPrice: 53.94, - lineTotal: 107.88, + unitPrice: 48, + lineTotal: 96, upc: "10628678860152", - listPrice: 17.99, + listPrice: 15, notes: "Only available to ship on September 1, 2026", }, ], @@ -750,7 +750,7 @@ describe("normalizeSimblistOrder", () => { it("takes the unit price as cost and carries the manufacturer UPC", () => { const [row] = normalizeSimblistOrder(smOrder()).rows; - expect(row.cost).toBe(53.94); + expect(row.cost).toBe(48); expect(row.barcode).toBe("10628678860152"); expect(row.partNumber).toBe("ZFUSA03-C"); }); @@ -787,9 +787,9 @@ describe("normalizeBeatrizBallOrder", () => { itemCode: "3496", name: "GLASS Vento Medium Vase (Clear)", qty: 4, - unitPrice: 24.75, + unitPrice: 18, lineTotal: 99, - msrp: 56, + msrp: 45, }, { itemCode: "6644", @@ -807,9 +807,9 @@ describe("normalizeBeatrizBallOrder", () => { it("takes the wholesale unit price as cost and prefills retail from MSRP", () => { const [row] = normalizeBeatrizBallOrder(bbOrder()).rows; - expect(row.cost).toBe(24.75); - expect(row.msrp).toBe(56); - expect(row.selling).toBe(56); + expect(row.cost).toBe(18); + expect(row.msrp).toBe(45); + expect(row.selling).toBe(45); expect(row.barcode).toBe(""); expect(row.partNumber).toBe("3496"); }); diff --git a/app/__tests__/homeAccessoryRows.test.ts b/app/__tests__/homeAccessoryRows.test.ts index 72f6b06c..f167cecf 100644 --- a/app/__tests__/homeAccessoryRows.test.ts +++ b/app/__tests__/homeAccessoryRows.test.ts @@ -34,7 +34,7 @@ function row(overrides: Partial = {}): HomeAccessoryExpo category: "", supplier: "K & K Interiors", barcode: "840220407476", - reference: "0002592361", + reference: "0009900002", ...overrides, }; } @@ -44,7 +44,7 @@ function draft(rows: HomeAccessoryExportRow[]): HomeAccessoryDraft { vendorName: "K & K Interiors", customerPo: "PON00006", orderDate: "Jun 15, 2026", - orders: [{ orderNumber: "0002592361", requiredDate: "9/1/26", itemCount: rows.length }], + orders: [{ orderNumber: "0009900002", requiredDate: "9/1/26", itemCount: rows.length }], rows, warnings: [], }; @@ -212,36 +212,36 @@ describe("composeHomeAccessoryRows — value precedence (no catalog match layer describe("composeHomeAccessoryRows — PO numbers per order", () => { const twoOrderDraft = () => { - const a = row({ partNumber: "AAA", reference: "0002592360" }); - const b = row({ partNumber: "BBB", reference: "0002592361" }); + const a = row({ partNumber: "AAA", reference: "0009900001" }); + const b = row({ partNumber: "BBB", reference: "0009900002" }); return { ...draft([a, b]), orders: [ - { orderNumber: "0002592360", requiredDate: "8/1/26", itemCount: 1 }, - { orderNumber: "0002592361", requiredDate: "9/1/26", itemCount: 1 }, + { orderNumber: "0009900001", requiredDate: "8/1/26", itemCount: 1 }, + { orderNumber: "0009900002", requiredDate: "9/1/26", itemCount: 1 }, ], }; }; it("leaves each order on its own vendor order number when nothing is typed", () => { const rows = composeHomeAccessoryRows(input({ draft: twoOrderDraft() })); - expect(rows.map((r) => r.reference)).toEqual(["0002592360", "0002592361"]); + expect(rows.map((r) => r.reference)).toEqual(["0009900001", "0009900002"]); }); it("applies a typed PO to ONLY that order, leaving the other alone", () => { // The bug this guards: a single run-level PO number overriding every // row would silently merge a two-order bundle into ONE draft PO. const rows = composeHomeAccessoryRows( - input({ draft: twoOrderDraft(), poNumbers: { "0002592360": "PON00006" } }), + input({ draft: twoOrderDraft(), poNumbers: { "0009900001": "PON00006" } }), ); - expect(rows.map((r) => r.reference)).toEqual(["PON00006", "0002592361"]); + expect(rows.map((r) => r.reference)).toEqual(["PON00006", "0009900002"]); }); it("keeps two typed POs distinct, so two draft POs still get created", () => { const rows = composeHomeAccessoryRows( input({ draft: twoOrderDraft(), - poNumbers: { "0002592360": "PO-A", "0002592361": "PO-B" }, + poNumbers: { "0009900001": "PO-A", "0009900002": "PO-B" }, }), ); expect(rows.map((r) => r.reference)).toEqual(["PO-A", "PO-B"]); @@ -250,31 +250,31 @@ describe("composeHomeAccessoryRows — PO numbers per order", () => { it("treats a blank or whitespace entry as 'use the vendor's number'", () => { const rows = composeHomeAccessoryRows( - input({ draft: twoOrderDraft(), poNumbers: { "0002592360": " " } }), + input({ draft: twoOrderDraft(), poNumbers: { "0009900001": " " } }), ); - expect(rows[0].reference).toBe("0002592360"); + expect(rows[0].reference).toBe("0009900001"); }); it("lets one typed PO deliberately cover both orders when that is the intent", () => { const rows = composeHomeAccessoryRows( input({ draft: twoOrderDraft(), - poNumbers: { "0002592360": "PON1", "0002592361": "PON1" }, + poNumbers: { "0009900001": "PON1", "0009900002": "PON1" }, }), ); expect(new Set(rows.map((r) => r.reference)).size).toBe(1); }); it("carries the order's PO onto every piece of a split set", () => { - const setRow = row({ partNumber: "17695A", reference: "0002592360" }); + const setRow = row({ partNumber: "17695A", reference: "0009900001" }); const d = { ...draft([setRow]), - orders: [{ orderNumber: "0002592360", requiredDate: "", itemCount: 1 }], + orders: [{ orderNumber: "0009900001", requiredDate: "", itemCount: 1 }], }; const rows = composeHomeAccessoryRows( input({ draft: d, - poNumbers: { "0002592360": "PON00006" }, + poNumbers: { "0009900001": "PON00006" }, splits: { 0: [ { suffix: "LG", cost: "22.79" }, diff --git a/app/__tests__/integration/homeAccessoryOrderCommit.integration.test.ts b/app/__tests__/integration/homeAccessoryOrderCommit.integration.test.ts index fdaf963c..23a7dc2a 100644 --- a/app/__tests__/integration/homeAccessoryOrderCommit.integration.test.ts +++ b/app/__tests__/integration/homeAccessoryOrderCommit.integration.test.ts @@ -39,14 +39,14 @@ function effectiveRow(overrides: Partial = {}): EffectiveRow { color: "", size: "", qty: 4, - cost: 39.99, + cost: 44.50, msrp: null, selling: null, department: "", category: "", supplier: "K & K Interiors", barcode: "842657186221", - reference: "0002592360", + reference: "0009900001", ...overrides, }; } @@ -118,7 +118,7 @@ describe("Home Accessory Order Import commit — real DB", () => { where: { id: result.createdPos[0].id }, }); expect(po.vendorId).toBe(vendor.id); - expect(po.referenceNumber).toBe("0002592360"); + expect(po.referenceNumber).toBe("0009900001"); expect(po.status).toBe("DRAFT"); const items = await prisma.buyerDraftItem.findMany({ where: { draftPoId: po.id } }); @@ -133,10 +133,10 @@ describe("Home Accessory Order Import commit — real DB", () => { departmentId: dept.id, categoryId: cat.id, }); - expect(items[0].cost.toNumber()).toBe(39.99); + expect(items[0].cost.toNumber()).toBe(44.50); // No selling/msrp typed -> retail falls back to cost (never left blank // on the required, non-nullable column). - expect(items[0].retail.toNumber()).toBe(39.99); + expect(items[0].retail.toNumber()).toBe(44.50); expect(items[0].msrp).toBeNull(); }); @@ -150,8 +150,8 @@ describe("Home Accessory Order Import commit — real DB", () => { sourceLabel: "Home Accessory Order Import — K & K Interiors", }; const rows = [ - effectiveRow({ key: "0", partNumber: "KKI-AAA", reference: "0002592360" }), - effectiveRow({ key: "1", partNumber: "KKI-BBB", reference: "0002592361" }), + effectiveRow({ key: "0", partNumber: "KKI-AAA", reference: "0009900001" }), + effectiveRow({ key: "1", partNumber: "KKI-BBB", reference: "0009900002" }), ]; const result = await commitRows(rows, ctx); @@ -162,7 +162,7 @@ describe("Home Accessory Order Import commit — real DB", () => { const pos = await prisma.buyerDraftPurchaseOrder.findMany({ orderBy: { referenceNumber: "asc" }, }); - expect(pos.map((p) => p.referenceNumber)).toEqual(["0002592360", "0002592361"]); + expect(pos.map((p) => p.referenceNumber)).toEqual(["0009900001", "0009900002"]); // Each item lands on ITS OWN order's PO, not both on one. for (const po of pos) { @@ -289,7 +289,7 @@ describe("Home Accessory Order Import commit — real DB", () => { vendorName: vendor.name, stockLocationId: null, buyId: null, - requiredDateByReference: { "0002592360": "8/1/26" }, + requiredDateByReference: { "0009900001": "8/1/26" }, sourceLabel: "Home Accessory Order Import — K & K Interiors", }; diff --git a/app/__tests__/integration/runServiceCaseSheetImport.integration.test.ts b/app/__tests__/integration/runServiceCaseSheetImport.integration.test.ts index e518e8dc..34998ca5 100644 --- a/app/__tests__/integration/runServiceCaseSheetImport.integration.test.ts +++ b/app/__tests__/integration/runServiceCaseSheetImport.integration.test.ts @@ -468,7 +468,7 @@ describe("runServiceCaseSheetImport — real-DB scenarios", () => { // name-based matcher won't resolve. const targetCustomer = await seedCustomer({ firstName: "Penny", - lastName: "Sigal", + lastName: "Sarlow", phone: "203-555-0101", }); await prisma.salesOrder.create({ @@ -484,7 +484,7 @@ describe("runServiceCaseSheetImport — real-DB scenarios", () => { { Timestamp: new Date("2025-10-03T00:00:00Z"), // Slash-couple name format — won't match Customer.lastName lookup - Name: "Penny/Steve Sigal", + Name: "Penny/Steve Sarlow", // Phone differs from the seeded Penny → can't match by phone either "Phone #": "860-999-9999", "Order #": "SO-77777", diff --git a/app/__tests__/marketTimeOrderParser.test.ts b/app/__tests__/marketTimeOrderParser.test.ts index 1667a877..83cbae64 100644 --- a/app/__tests__/marketTimeOrderParser.test.ts +++ b/app/__tests__/marketTimeOrderParser.test.ts @@ -258,25 +258,25 @@ describe("parseMarketTimeOrderText — the UQ/UOM + numeric-item variants", () = }); it("falls back to the MarketTime order id when a document carries no buyer PON", () => { - // ACC Art Books prints "ID# 32008813" instead of a PON — the reference must + // ACC Art Books prints "ID# 32000002" instead of a PON — the reference must // not come out blank. const order = parseMarketTimeOrderText( [ - "Purchase Order by - ID# 32008813MarketTime", + "Purchase Order by - ID# 32000002MarketTime", "You will receive an invoice from ACC Art Books", "9782875501417", "SENSE OF STYLE", - "978287550141786.00$688.00", + "978287550141760.00$480.00", ].join("\n"), ); - expect(order.poNumber).toBe("32008813"); + expect(order.poNumber).toBe("32000002"); expect(order.vendorName).toBe("ACC Art Books"); }); it("prefers a real PON over the order-id fallback", () => { const order = parseMarketTimeOrderText( [ - "Purchase Order by - ID# 32008813MarketTime", + "Purchase Order by - ID# 32000002MarketTime", "PON00004", "1GL60BIN50FTLG", "Merino Wool Large Bin", diff --git a/app/__tests__/nuorderPrintoutParser.test.ts b/app/__tests__/nuorderPrintoutParser.test.ts index 4c106bdf..9c1193b3 100644 --- a/app/__tests__/nuorderPrintoutParser.test.ts +++ b/app/__tests__/nuorderPrintoutParser.test.ts @@ -2,8 +2,9 @@ // // Pins the pure core of the NuOrder "order printout" parser // (parseNuOrderPrintoutItems) with hand-computed positioned-item fixtures. -// Geometry mirrors the real PDFs (Frank & Eileen PO 18573341, Hunter Bell -// PO 18908185): size-header labels at their real x positions, quantity +// Geometry mirrors real PDFs (two vendors' order printouts; the PO numbers and +// every price are invented -- this repo is public and a vendor's dealer costs +// are confidential): size-header labels at their real x positions, quantity // digits offset a few points from the label x, color text far left of the // grid. The bug shapes covered are the ones that made pdf-parse text // unusable for this layout: ambiguous digit runs (column binning), wrapped @@ -22,7 +23,7 @@ function pi(str: string, x: number, y: number): PositionedItem { function pageHeader(): PositionedItem[] { return [ pi("PO#:", 476, 791), - pi("18573341", 500, 791), + pi("19900001", 500, 791), pi("Created:", 55, 764), pi("10/07/2025", 84, 764), pi("Contact: Erica", 176, 764), @@ -68,7 +69,7 @@ function letterSizeHeader(y: number): PositionedItem[] { function eileenBlock(): PositionedItem[] { return [ pi("Relaxed Button-Up Shirt", 57, 687), - ...styleAnchor("EILEEN", "112.00", "258.00", 675), + ...styleAnchor("EILEEN", "128.00", "274.00", 675), ...letterSizeHeader(654), pi("PRBG", 183, 639), pi("Pink Red Blue", 202, 639), @@ -77,7 +78,7 @@ function eileenBlock(): PositionedItem[] { pi("1", 369, 636), pi("1", 387, 636), pi("4", 425, 636), - pi("USD 448.00", 480, 636), + pi("USD 512.00", 480, 636), pi("Flowers", 183, 632), ]; } @@ -97,17 +98,17 @@ function orderSummary(qty: string, total: string): PositionedItem[] { describe("parseNuOrderPrintoutItems — happy paths", () => { it("bins letter-grid digits to size columns by nearest header x", () => { const parsed = parseNuOrderPrintoutItems([ - [...pageHeader(), ...eileenBlock(), ...orderSummary("4", "448.00")], + [...pageHeader(), ...eileenBlock(), ...orderSummary("4", "512.00")], ]); expect(parsed.warnings).toEqual([]); expect(parsed.items).toHaveLength(1); const item = parsed.items[0]; expect(item.styleNumber).toBe("EILEEN"); expect(item.productName).toBe("Relaxed Button-Up Shirt"); - expect(item.unitPrice).toBe(112); - expect(item.msrp).toBe(258); + expect(item.unitPrice).toBe(128); + expect(item.msrp).toBe(274); expect(item.totalUnits).toBe(4); - expect(item.totalPrice).toBe(448); + expect(item.totalPrice).toBe(512); // Digits sit under XS/S/M/L — XXS and XL stay empty. expect(item.sizes).toEqual([ { size: "XS", quantity: 1 }, @@ -119,23 +120,23 @@ describe("parseNuOrderPrintoutItems — happy paths", () => { it("accumulates wrapped color rows, including the wrap below the quantity row", () => { const parsed = parseNuOrderPrintoutItems([ - [...pageHeader(), ...eileenBlock(), ...orderSummary("4", "448.00")], + [...pageHeader(), ...eileenBlock(), ...orderSummary("4", "512.00")], ]); expect(parsed.items[0].colorCode).toBe("PRBG Pink Red Blue Flowers"); }); it("reads the order header, season, and printed totals", () => { const parsed = parseNuOrderPrintoutItems([ - [...pageHeader(), ...eileenBlock(), ...orderSummary("4", "448.00")], + [...pageHeader(), ...eileenBlock(), ...orderSummary("4", "512.00")], ]); - expect(parsed.poNumber).toBe("18573341"); + expect(parsed.poNumber).toBe("19900001"); expect(parsed.orderDate).toBe("10/07/2025"); expect(parsed.deliveryStart).toBe("06/01/2026"); expect(parsed.deliveryEnd).toBe("06/15/2026"); expect(parsed.terms).toBe("PRE-PAID CREDIT CARD"); expect(parsed.season).toBe("JUNE '26"); expect(parsed.totalUnits).toBe(4); - expect(parsed.totalPrice).toBe(448); + expect(parsed.totalPrice).toBe(512); // The printout renders the brand as a logo image — never text. expect(parsed.vendorName).toBe(""); }); @@ -144,7 +145,7 @@ describe("parseNuOrderPrintoutItems — happy paths", () => { const page = [ ...pageHeader(), pi('Waterford 7.5"', 57, 700), - ...styleAnchor("GOLFSHORT", "121.00", "278.00", 690), + ...styleAnchor("GOLFSHORT", "137.00", "294.00", 690), pi("Colors", 147, 675), pi("Total", 504, 675), pi("00", 291, 671), @@ -167,15 +168,15 @@ describe("parseNuOrderPrintoutItems — happy paths", () => { pi("1", 370, 657), pi("1", 386, 657), pi("5", 438, 657), - pi("605.00", 497, 653), - ...orderSummary("5", "605.00"), + pi("685.00", 497, 653), + ...orderSummary("5", "685.00"), ]; const parsed = parseNuOrderPrintoutItems([page]); expect(parsed.warnings).toEqual([]); expect(parsed.items).toHaveLength(1); const item = parsed.items[0]; expect(item.colorCode).toBe("1984 Washed Blue"); - expect(item.totalPrice).toBe(605); + expect(item.totalPrice).toBe(685); expect(item.sizes).toEqual([ { size: "2", quantity: 1 }, { size: "4", quantity: 1 }, @@ -189,7 +190,7 @@ describe("parseNuOrderPrintoutItems — happy paths", () => { const page = [ ...pageHeader(), pi("Small HB Canvas Tote", 57, 700), - ...styleAnchor("26HSA4Nat", "42.00", "100.00", 690), + ...styleAnchor("26HSA4Nat", "48.00", "110.00", 690), pi("Colors", 147, 675), pi("Total", 473, 675), pi("OS", 352, 671), @@ -197,8 +198,8 @@ describe("parseNuOrderPrintoutItems — happy paths", () => { pi("Natural Natural", 183, 650), pi("1", 355, 650), pi("1", 376, 650), - pi("USD 42.00", 451, 650), - ...orderSummary("1", "42.00"), + pi("USD 48.00", 451, 650), + ...orderSummary("1", "48.00"), ]; const parsed = parseNuOrderPrintoutItems([page]); expect(parsed.warnings).toEqual([]); @@ -212,7 +213,7 @@ describe("parseNuOrderPrintoutItems — refuse-to-guess", () => { const page = [ ...pageHeader(), pi("Relaxed Button-Up Shirt", 57, 687), - ...styleAnchor("EILEEN", "112.00", "258.00", 675), + ...styleAnchor("EILEEN", "128.00", "274.00", 675), ...letterSizeHeader(654), pi("PRBG", 183, 639), pi("Pink Red Blue", 202, 639), @@ -221,8 +222,8 @@ describe("parseNuOrderPrintoutItems — refuse-to-guess", () => { pi("1", 351, 636), pi("1", 369, 636), pi("4", 425, 636), - pi("USD 448.00", 480, 636), - ...orderSummary("4", "448.00"), + pi("USD 512.00", 480, 636), + ...orderSummary("4", "512.00"), ]; const parsed = parseNuOrderPrintoutItems([page]); expect(parsed.items).toHaveLength(0); @@ -235,7 +236,7 @@ describe("parseNuOrderPrintoutItems — refuse-to-guess", () => { const page = [ ...pageHeader(), pi("Relaxed Button-Up Shirt", 57, 687), - ...styleAnchor("EILEEN", "112.00", "258.00", 675), + ...styleAnchor("EILEEN", "128.00", "274.00", 675), ...letterSizeHeader(654), pi("PRBG", 183, 639), pi("1", 333, 636), @@ -244,7 +245,7 @@ describe("parseNuOrderPrintoutItems — refuse-to-guess", () => { pi("1", 387, 636), pi("4", 425, 636), pi("USD 500.00", 480, 636), - ...orderSummary("4", "500.00"), + ...orderSummary("4", "560.00"), ]; const parsed = parseNuOrderPrintoutItems([page]); expect(parsed.items).toHaveLength(0); @@ -253,12 +254,12 @@ describe("parseNuOrderPrintoutItems — refuse-to-guess", () => { it("warns when the parsed items do not add up to the printed Grand Total", () => { const parsed = parseNuOrderPrintoutItems([ - [...pageHeader(), ...eileenBlock(), ...orderSummary("4", "500.00")], + [...pageHeader(), ...eileenBlock(), ...orderSummary("4", "560.00")], ]); // The block itself is internally consistent, so it is kept — the // mismatch against the document total is surfaced, not hidden. expect(parsed.items).toHaveLength(1); - expect(parsed.warnings.some((w) => /448\.00.*500\.00/.test(w))).toBe(true); + expect(parsed.warnings.some((w) => /512\.00.*560\.00/.test(w))).toBe(true); }); }); @@ -271,10 +272,10 @@ describe("parseNuOrderPrintoutItems — cancelled styles", () => { pi("Total Quantity:", 346, 605), pi("4", 519, 605), pi("Grand Total:", 346, 600), - pi("USD 448.00", 461, 600), + pi("USD 512.00", 461, 600), pi("Cancelled Styles:", 57, 560), pi("One-Size Button-Up Dress", 57, 550), - ...styleAnchor("MEGAN", "143.00", "328.00", 540), + ...styleAnchor("MEGAN", "166.00", "350.00", 540), pi("Colors", 147, 522), pi("Total", 473, 522), pi("O/S", 352, 518), @@ -282,21 +283,21 @@ describe("parseNuOrderPrintoutItems — cancelled styles", () => { pi("V000 White", 185, 500), pi("2", 355, 500), pi("2", 376, 500), - pi("USD 286.00", 451, 500), + pi("USD 332.00", 451, 500), // The summary box repeats the heading at x~346 — must not re-flip. pi("Cancelled Styles:", 346, 433), pi("Total Quantity:", 346, 426), pi("2", 534, 426), pi("Total:", 346, 419), - pi("USD 286.00", 497, 419), + pi("USD 332.00", 497, 419), ]; const parsed = parseNuOrderPrintoutItems([page]); expect(parsed.warnings).toEqual([]); expect(parsed.items).toHaveLength(1); expect(parsed.items[0].styleNumber).toBe("EILEEN"); - expect(parsed.cancelled).toEqual({ items: 1, units: 2, total: 286 }); + expect(parsed.cancelled).toEqual({ items: 1, units: 2, total: 332 }); // The cancelled section's own Total Quantity must not clobber the order's. expect(parsed.totalUnits).toBe(4); - expect(parsed.totalPrice).toBe(448); + expect(parsed.totalPrice).toBe(512); }); }); diff --git a/app/src/lib/adapters/ordorite/shared.ts b/app/src/lib/adapters/ordorite/shared.ts index 9495539b..5e150887 100644 --- a/app/src/lib/adapters/ordorite/shared.ts +++ b/app/src/lib/adapters/ordorite/shared.ts @@ -440,7 +440,7 @@ export function parseOrdoriteAddress(raw: unknown): ParsedAddress | null { parts.pop(); } - // Drop trailing zip code that ended up as its own part (e.g. "CT, 06033") + // Drop trailing zip code that ended up as its own part (e.g. "CT, 99999") const ZIP_RE = /^\d{5}(-\d{4})?$/; if (parts.length >= 4 && ZIP_RE.test(parts[parts.length - 1])) { parts.pop(); @@ -455,7 +455,7 @@ export function parseOrdoriteAddress(raw: unknown): ParsedAddress | null { const city = parts[parts.length - 2]; const address1 = parts.slice(0, parts.length - 2).join(", "); - // Strip zip code merged into state (e.g. "CT 06033" -> "CT") + // Strip zip code merged into state (e.g. "CT 99999" -> "CT") const stateZipMatch = state.match(/^([A-Z]{2})\s+\d{5}/); if (stateZipMatch) { state = stateZipMatch[1]; diff --git a/app/src/lib/homeAccessoryOrders.ts b/app/src/lib/homeAccessoryOrders.ts index 4cb4f690..93d8d770 100644 --- a/app/src/lib/homeAccessoryOrders.ts +++ b/app/src/lib/homeAccessoryOrders.ts @@ -135,7 +135,7 @@ export const HOME_ACCESSORY_FORMATS: readonly HomeAccessoryFormat[] = [ catalogVendorName: "Zodax", notes: "BrandWise is the platform; Zodax writes orders on it. The money line is qty + UOM + " + - 'unit price + line total with NO dollar sign ("4EA200.00800.00"), settled by qty x ' + + 'unit price + line total with NO dollar sign ("4EA250.001000.00"), settled by qty x ' + "price == total. There is NO UPC column, so barcodes stay blank. BrandWise does not " + "print the manufacturer, so the supplier defaults to Zodax -- edit it and Re-check for " + "another BrandWise vendor.", @@ -150,7 +150,7 @@ export const HOME_ACCESSORY_FORMATS: readonly HomeAccessoryFormat[] = [ // document and this one entry serves every brand on the form. notes: "Aesthetic Movement's PO form (Printworks writes orders on it). The money line has " + - 'dollar signs -- "12$33.00$396.00" (qty $unit price $line total) -- so the split is ' + + 'dollar signs -- "12$25.00$300.00" (qty $unit price $line total) -- so the split is ' + "unambiguous, and qty x price == total is still checked. UPCs are real 13-digit " + "manufacturer codes when present, but an out-of-stock item can print none, in which " + 'case the barcode stays blank. The supplier is read from the "Vendor:" line.', @@ -165,7 +165,7 @@ export const HOME_ACCESSORY_FORMATS: readonly HomeAccessoryFormat[] = [ // so the supplier is read from it and this entry serves every brand. notes: "SuperCatSolutions' order form (Dana Whitfield writes orders on it). Each item is one " + - 'run-together line -- "9BOATLINEG6$285.00$1,710.00Boa Table Lamp" (item + qty + $unit ' + + 'run-together line -- "9BOATLINEG6$210.00$1,260.00Boa Table Lamp" (item + qty + $unit ' + "price + $extension + description) -- split by the two dollar amounts and confirmed by " + "qty x price == extension. There is NO UPC column, so barcodes stay blank. An " + "order-level discount is NOT applied to the unit costs automatically -- it is surfaced " + @@ -714,7 +714,7 @@ export function normalizeAestheticMovementOrder( /** * A SuperCatSolutions (Dana Whitfield) order as export rows. The Price column * is the unit price already (verified in FC: qty x price == extension on - * all 20 lines of Ref 153642), so cost is taken as printed. No UPC column, + * all 20 lines of the reference order), so cost is taken as printed. No UPC column, * so barcodes stay blank. An order-level discount is NOT applied to the * unit costs automatically -- it is surfaced as a warning. */ diff --git a/app/src/lib/pricing/aestheticMovementOrderParser.ts b/app/src/lib/pricing/aestheticMovementOrderParser.ts index ce5c26cc..fea7ffd0 100644 --- a/app/src/lib/pricing/aestheticMovementOrderParser.ts +++ b/app/src/lib/pricing/aestheticMovementOrderParser.ts @@ -10,12 +10,12 @@ // Classic - Tic Tac Toe <- product name // ETA EARLY JULY <- optional status note(s), ignored // 7350108174152 <- optional UPC (some items carry none) -// 12$33.00$396.00 <- qty $unit-price $line-total +// 12$25.00$300.00 <- qty $unit-price $line-total // // Verified against a real order (its number and totals withheld -- this repo // is public and the vendor's dealer costs are confidential): // -// 1. The money line HAS dollar signs — "12$33.00$396.00" (qty $price $total), +// 1. The money line HAS dollar signs — "12$25.00$300.00" (qty $price $total), // the split is unambiguous, but qty x price == total is still checked. // 2. The UPC is OPTIONAL — an out-of-stock item ("Reverra - Mahjong", // OOS) prints no UPC, so its barcode exports blank and Ordorite assigns one. diff --git a/app/src/lib/pricing/brandWiseOrderParser.ts b/app/src/lib/pricing/brandWiseOrderParser.ts index c869af1f..06c6ac05 100644 --- a/app/src/lib/pricing/brandWiseOrderParser.ts +++ b/app/src/lib/pricing/brandWiseOrderParser.ts @@ -8,18 +8,19 @@ // // IN-8222The Cadier Wooden Wall Mirrors 23.75" x <- SKU + description, // 35.5" which wraps -// 4EA200.00800.00 <- qty + UOM + price + +// 4EA250.001000.00 <- qty + UOM + price + // total, concatenated // // or the whole block arrives on one line: // -// IN-8432Chevron Wood Box- 13"x 6.5"x 3.25"4EA57.00228.00 +// IN-8432Chevron Wood Box- 13"x 6.5"x 3.25"4EA70.00280.00 // // Three things this parser gets right, verified against the real order -// (B31600001 / PON00005, 8 items, $1,280.00): +// (its numbers and totals withheld -- this repo is public and the vendor's +// dealer costs are confidential): // // 1. The money line has NO "$" and NO separators — qty + UOM (letters) + unit -// price + line total, e.g. "4EA200.00800.00". So the price/total boundary is +// price + line total, e.g. "4EA250.001000.00". So the price/total boundary is // settled by the arithmetic: qty x price == total. // // 2. Descriptions carry inch marks ("23.75\" x 35.5\""), so a digit can sit diff --git a/app/src/lib/pricing/frankEileenParser.ts b/app/src/lib/pricing/frankEileenParser.ts index 880f9cdc..c622c8a1 100644 --- a/app/src/lib/pricing/frankEileenParser.ts +++ b/app/src/lib/pricing/frankEileenParser.ts @@ -11,7 +11,7 @@ // Relaxed Button-Up Shirt Pink Red Blue Flowers <- description // XXS XS S M L XL <- size scale header // 0010_ <- line number -// 1 1 1 1 112.00 4 448.00 <- qtys, price, units, extension +// 1 1 1 1 128.00 4 512.00 <- qtys, price, units, extension // // Per-size quantities are RIGHT-ALIGNED to their size label: a quantity // token's END offset equals the label's END offset in the size header diff --git a/app/src/lib/pricing/kkOrderParser.ts b/app/src/lib/pricing/kkOrderParser.ts index 532117f3..2ad08b6e 100644 --- a/app/src/lib/pricing/kkOrderParser.ts +++ b/app/src/lib/pricing/kkOrderParser.ts @@ -6,7 +6,7 @@ // Each line item is a fixed block in the pdf-parse text, anchored on the // unit price + UOM line: // -// 39.99EA <- unit price + UOM, concatenated +// 44.50EA <- unit price + UOM, concatenated // 15668B <- vendor item number // 8/1/26 4 <- required date + required qty // 0.00 <- ship qty (not carried on the item) diff --git a/app/src/lib/pricing/marketTimeOrderParser.ts b/app/src/lib/pricing/marketTimeOrderParser.ts index 401e84a6..ff740a76 100644 --- a/app/src/lib/pricing/marketTimeOrderParser.ts +++ b/app/src/lib/pricing/marketTimeOrderParser.ts @@ -8,7 +8,7 @@ // 6GL70TECH10GN16IN <- qty + item number, CONCATENATED // Merino Wool 16" Laptop Computer <- name, 1-2 wrapped lines // Sleeve - Granite V (Avail:07/10/26) with an availability marker -// 84002724051149.00$294.00 <- UPC + unit price + $line total, +// 84002724051135.00$210.00 <- UPC + unit price + $line total, // also concatenated // // Three things this parser exists to get right, all verified against the real @@ -65,7 +65,7 @@ export interface MarketTimeOrder { const MFR = /MFR:\s*(.+?)(?:Cust(?:omer)?\s*#?:?|$)/; const PO_NUMBER = /^PON\d+$/; // A MarketTime order that carries no buyer PON prints its own order id instead -// ("Purchase Order by - ID# 32008813MarketTime" on ACC Art Books). Used only +// ("Purchase Order by - ID# 32000002MarketTime" on ACC Art Books). Used only // as a fallback reference when no PON is found, so a real PON always wins. const ORDER_ID = /ID#\s*(\d+)/; // The labels sit on either side of their value depending on the cell's diff --git a/app/src/lib/pricing/nuorderPrintoutParser.ts b/app/src/lib/pricing/nuorderPrintoutParser.ts index c9c0e3c4..4715539e 100644 --- a/app/src/lib/pricing/nuorderPrintoutParser.ts +++ b/app/src/lib/pricing/nuorderPrintoutParser.ts @@ -5,11 +5,11 @@ // per field), the printout renders each item as a size GRID: a header row of // size labels (XXS–XL, numeric 00–14, or one-size) with quantity digits under // the ordered columns. Flat text extraction (pdf-parse) concatenates those -// digits into ambiguous runs ("1111 4USD 448.00" under "XXSXSSMLXLQty"), so +// digits into ambiguous runs ("1111 4USD 512.00" under "XXSXSSMLXLQty"), so // this parser works from positioned text items (pdfjs-dist getTextContent // x/y): each digit is assigned to the size column with the nearest header x. -// Verified against rendered pages of the Frank & Eileen (PO 18573341) and -// Hunter Bell (PO 18908185) printouts. +// Verified against rendered pages of the Frank & Eileen (PO 19900001) and +// Hunter Bell (PO 19900002) printouts. // // REFUSE-TO-GUESS: a block is dropped (with a warning) unless its per-size // quantities sum to its own Qty column AND unit price x quantity equals the @@ -142,7 +142,7 @@ function isDigits(s: string): boolean { return /^\d+$/.test(s); } -/** Money amount at the END of a grid token ("USD 448.00" or a bare +/** Money amount at the END of a grid token ("USD 512.00" or a bare * "1,057.00" when NuOrder stacks the currency and amount on separate * rows). Anchored so size labels and page numbers can't match. */ function trailingMoney(s: string): number | null { diff --git a/app/src/lib/pricing/simblistCsvOrderParser.ts b/app/src/lib/pricing/simblistCsvOrderParser.ts index f64e419b..1818fe32 100644 --- a/app/src/lib/pricing/simblistCsvOrderParser.ts +++ b/app/src/lib/pricing/simblistCsvOrderParser.ts @@ -11,11 +11,11 @@ // RepGroup,Manufacturer,PO #,Order Date,...,Order Total,... // Simblist Group,MAISON ZOE FORD,PON00001,2026-06-11,...,615.60,... // Sequence #,Item Number,Name,Description,Quantity,Unit Price,...,UPC,...,Total Price -// 3,ZFUSA03-C,Big Time Brownie Mix - case pack of 6,,2,53.94,...,10628678860152,...,$107.88 +// 3,ZFUSA03-C,Big Time Brownie Mix - case pack of 6,,2,48.00,...,10628678860152,...,$96.00 // // Verified against the real order (PO number and prices invented): // -// 1. qty x Unit Price == Total Price on every line (2 x 53.94 == 107.88). +// 1. qty x Unit Price == Total Price on every line (2 x 48.00 == 96.00). // 2. The UPCs are real 14-digit manufacturer codes, so new items carry them. // 3. The line Total Prices sum to $684.00 but the header Order Total is $615.60 // (10% less) -- an order-level discount that is NOT in the unit prices. It is diff --git a/app/src/lib/pricing/wendoverOrderParser.ts b/app/src/lib/pricing/wendoverOrderParser.ts index 3447117b..a5b37469 100644 --- a/app/src/lib/pricing/wendoverOrderParser.ts +++ b/app/src/lib/pricing/wendoverOrderParser.ts @@ -53,8 +53,8 @@ export interface WendoverOrderItem { treatment: string; size: string; frame: string; - /** Customer reference printed on made-to-order pieces ("SBOM41649/Erin - * Kelly") -- the item is already sold, not stock. */ + /** Customer reference printed on made-to-order pieces ("SBOM41649/Dana + * Whitl") -- the item is already sold, not stock. */ sideMark: string; extras: string[]; } From 8e4e9292a151953dadce0af5c8031463ad37b023 Mon Sep 17 00:00:00 2001 From: goetchstone Date: Wed, 26 Aug 2026 16:03:42 -0400 Subject: [PATCH 11/11] fix(tests): the tripwire was the tidiest re-identification kit in the repo An independent sweep over docs, config, scripts, prisma and file names came back clean except the two applied migrations already carried as exemptions -- which is the convergence signal worth having. But it surfaced something else by accident: the guard itself. The tripwire listed, in plain text and in one public file, the surnames, the company/town stem, both store towns, the town ZIP, the staff email local-parts and the phone numbers of a real business. It concentrated in a single place exactly what every other file had been scrubbed of, and search engines index it. The guard against publishing a company's identifying data was publishing it. Patterns are now base64. That is not secrecy -- anyone determined decodes it in a second, and the header says so. It stops the repo being a plain-text index of a real company's identifiers, which is the actual harm. `what` stays in plain English: the REASON an entry exists has to remain readable, only the identifier is encoded, and the header carries the one-liner for adding a new one. Removing the plaintext also removed an accident the suite had been leaning on: the file used to match its own patterns, so it always appeared in the hit set and doubled as proof the scan was live. That crutch is gone, so the canary is now deliberate -- a positive control asserting the scan reaches known content and known paths, and it runs first. Every other assertion here passes on an empty result, so a scan that silently reaches nothing would otherwise read as a spotless repo. Verified both ways: planting a scrubbed identifier still fails two cases, and the file no longer contains any of the identifiers it guards. 3,479 unit tests pass; tsc and prettier clean. --- app/__tests__/clientDataTripwire.test.ts | 132 ++++++++++++++--------- 1 file changed, 81 insertions(+), 51 deletions(-) diff --git a/app/__tests__/clientDataTripwire.test.ts b/app/__tests__/clientDataTripwire.test.ts index 1ef10b16..7c280c23 100644 --- a/app/__tests__/clientDataTripwire.test.ts +++ b/app/__tests__/clientDataTripwire.test.ts @@ -2,22 +2,39 @@ // // This repository is PUBLIC. It began as one retailer's internal system, and // their production data leaked into it as test fixtures: staff and customer -// names, contact details, the company's own domain, its store and town names. -// That was scrubbed once. This test is what stops it coming back. +// names, contact details, the company's own domain, its store and town names, +// and several vendors' confidential dealer pricing. That was scrubbed. This +// test is what stops it coming back. // // It exists because the failure is silent and asymmetric. Committing a real // customer's name breaks no test, blocks no build, and looks exactly like the // invented fixture beside it -- but once pushed it is in the history and in -// every fork, and no later commit takes it back. +// every fork, and no later commit takes it back. It has already earned its +// keep twice: it found 57 mentions the first scrub missed, and it caught a +// name walking back in through a merge from another branch. +// +// WHY THE PATTERNS ARE BASE64 AND NOT PLAIN TEXT. A guard listing the exact +// surnames, towns, ZIP and phone numbers of a real business is itself the +// tidiest re-identification kit in the repo -- it would concentrate in one +// public file precisely what every other file was scrubbed of, and search +// engines index it. Encoding is not secrecy: anyone determined can decode it +// in a second. It stops the repo from being a plain-text index of a real +// company's identifying data, which is the actual harm. The guard works +// exactly as before. +// +// To add a pattern: +// node -e 'process.stdout.write(Buffer.from("your-regex").toString("base64"))' +// and paste the result as `p`. Keep `what` in plain English -- the reason an +// entry exists must stay readable, only the identifier is encoded. // // SCOPE, deliberately narrow. This scans for the specific identifiers already // found in this repo, not for "PII" in general -- an open-ended heuristic here -// would flag invented fixtures constantly and get silenced, which is worse than -// no test. When a new deployment's data lands, add ITS identifiers here. +// would flag invented fixtures constantly and get silenced, which is worse +// than no test. When a new deployment's data lands, add ITS identifiers. // -// The one thing this test does NOT cover: the identifiers scrubbed at HEAD are -// still reachable in this repo's git history and in any existing fork. Removing -// them there means rewriting history, which is a separate decision. +// The one thing this does NOT cover: the identifiers scrubbed at HEAD are +// still reachable in this repo's git history and in any existing fork. +// Removing them there means rewriting history, which is a separate decision. import { execFileSync } from "node:child_process"; import { join } from "node:path"; @@ -25,58 +42,61 @@ import { join } from "node:path"; const REPO_ROOT = join(__dirname, "..", ".."); /** - * Identifiers belonging to a real business or a real person. Each is a regex - * source, matched case-insensitively against every tracked file. + * Identifiers belonging to a real business or a real person. `p` is a + * base64-encoded regex source (see the header), matched case-insensitively + * against every tracked and untracked-but-not-ignored file, and against every + * path. * * Anything added here must be genuinely identifying. A generic word that merely * appears in one deployment's data does not belong -- it would fire on ordinary - * code and train people to add exemptions. + * code and train people to add exemptions rather than fix leaks. */ -const FORBIDDEN: { pattern: string; what: string }[] = [ - // The STEM, not the full word. The codebase itself documents that the real - // data carried typo variants of the domain, so a pattern matching only the - // correct spelling would miss exactly the values that caused the incident the - // email guard exists for. +const FORBIDDEN: { p: string; what: string }[] = [ { - pattern: "sayb", + p: "c2F5Yg==", what: "the pilot deployment's company, town and email-domain stem, typos included", }, - { pattern: "cheshire|glastonbury", what: "the pilot deployment's store towns" }, - { pattern: "sammyg40|joneil|gstone|wcope", what: "real staff email local-parts" }, - { pattern: "greenstein|panagy|dransfield|matheny|tenerow", what: "real people's surnames" }, - { pattern: "sorbo|nordquist|calkins|barnum|homan|favale|vantongeren", what: "real people's surnames" }, - { pattern: "860-470-3653|213-623-1345|860-388-0891|860-388-3692", what: "real phone and fax numbers" }, + { p: "Y2hlc2hpcmV8Z2xhc3RvbmJ1cnk=", what: "the pilot deployment's store towns" }, + { p: "c2FtbXlnNDB8am9uZWlsfGdzdG9uZXx3Y29wZQ==", what: "real staff email local-parts" }, + { + p: "Z3JlZW5zdGVpbnxwYW5hZ3l8ZHJhbnNmaWVsZHxtYXRoZW55fHRlbmVyb3c=", + what: "real people's surnames", + }, { - pattern: "ciccone|dwyer|germano|filippone|levatino|sorbo|demik|sigal", + p: "c29yYm98bm9yZHF1aXN0fGNhbGtpbnN8YmFybnVtfGhvbWFufGZhdmFsZXx2YW50b25nZXJlbg==", what: "real people's surnames", }, - // Matched as whole names, because each of these surnames is far too common to - // use on its own -- a bare "kelly" or "warren" would fire on ordinary code and - // the entry would get deleted rather than fixed. { - pattern: - "erin kelly|alex robertson|rebecca warren|mary goodwin|reginald adams|madison baker|susan roberts|jamie young|sarah levatino|amy sage|shannon martin|lisa ritz", + p: "ODYwLTQ3MC0zNjUzfDIxMy02MjMtMTM0NXw4NjAtMzg4LTA4OTF8ODYwLTM4OC0zNjky", + what: "real phone and fax numbers", + }, + { + p: "Y2ljY29uZXxkd3llcnxnZXJtYW5vfGZpbGlwcG9uZXxsZXZhdGlub3xzb3Jib3xkZW1pa3xzaWdhbA==", + what: "real people's surnames", + }, + { + p: "ZXJpbiBrZWxseXxhbGV4IHJvYmVydHNvbnxyZWJlY2NhIHdhcnJlbnxtYXJ5IGdvb2R3aW58cmVnaW5hbGQgYWRhbXN8bWFkaXNvbiBiYWtlcnxzdXNhbiByb2JlcnRzfGphbWllIHlvdW5nfHNhcmFoIGxldmF0aW5vfGFteSBzYWdlfHNoYW5ub24gbWFydGlufGxpc2Egcml0eg==", what: "real people's full names", }, - { pattern: "rwarren@|sammyg40", what: "real staff email local-parts" }, - { pattern: "57 princeton lane|298 highland avenue|2 main street|8 monticello|east lyme", what: "real street addresses" }, - { pattern: "06475", what: "the pilot deployment's town ZIP" }, - // Vendor dealer/wholesale pricing and the document numbers that tie a price - // to a real order. Publishing another business's trade terms is the harm -- - // naming the vendor is not, which is why the brands themselves are absent here. + { p: "cndhcnJlbkB8c2FtbXlnNDA=", what: "real staff email local-parts" }, + { + p: "NTcgcHJpbmNldG9uIGxhbmV8Mjk4IGhpZ2hsYW5kIGF2ZW51ZXwyIG1haW4gc3RyZWV0fDggbW9udGljZWxsb3xlYXN0IGx5bWU=", + what: "real street addresses", + }, + { p: "MDY0NzU=", what: "the pilot deployment's town ZIP" }, { - pattern: - "PON090[0-9][0-9]|B31669979|153642-070126|1000292821|0063477|0063476|31680534|77234f1af6|0002592360|0002592361|18573341|18908185|32008813", + p: "UE9OMDkwWzAtOV1bMC05XXxCMzE2Njk5Nzl8MTUzNjQyLTA3MDEyNnwxMDAwMjkyODIxfDAwNjM0Nzd8MDA2MzQ3NnwzMTY4MDUzNHw3NzIzNGYxYWY2fDAwMDI1OTIzNjB8MDAwMjU5MjM2MXwxODU3MzM0MXwxODkwODE4NXwzMjAwODgxMw==", what: "real vendor order, PO and document numbers", }, { - pattern: - "10,976\\.49|32,108\\.67|1,057\\.62|1,188\\.57|22,373\\.00|2,688\\.00|3,322\\.00|2,196\\.00|722\\.74|2,368\\.50|9,?298\\.9|2,?484\\.65|352\\.54|53\\.94|107\\.88|24\\.75|39\\.99|1,?710\\.00|\\b285\\.00|\\b294\\.00|\\b396\\.00|\\b228\\.00|\\b688\\.00", + p: "MTAsOTc2XFwuNDl8MzIsMTA4XFwuNjd8MSwwNTdcXC42MnwxLDE4OFxcLjU3fDIyLDM3M1xcLjAwfDIsNjg4XFwuMDB8MywzMjJcXC4wMHwyLDE5NlxcLjAwfDcyMlxcLjc0fDIsMzY4XFwuNTB8OSw/Mjk4XFwuOXwyLD80ODRcXC42NXwzNTJcXC41NHw1M1xcLjk0fDEwN1xcLjg4fDI0XFwuNzV8MzlcXC45OXwxLD83MTBcXC4wMHxcXGIyODVcXC4wMHxcXGIyOTRcXC4wMHxcXGIzOTZcXC4wMHxcXGIyMjhcXC4wMHxcXGI2ODhcXC4wMA==", what: "real vendor order totals and dealer prices", }, - { pattern: "costs top out at", what: "a disclosure of a vendor's catalog price ceiling" }, + { p: "Y29zdHMgdG9wIG91dCBhdA==", what: "a disclosure of a vendor's catalog price ceiling" }, ]; +const decode = (p: string) => Buffer.from(p, "base64").toString("utf8"); + /** * Files allowed to contain a hit, each with the reason it cannot be scrubbed. * @@ -84,14 +104,14 @@ const FORBIDDEN: { pattern: string; what: string }[] = [ * constraint -- "it's only a comment" is not one. Prisma records a checksum of * every migration when it applies it, so editing an applied migration makes * `prisma migrate deploy` fail on every existing deployment until someone - * resolves it by hand. That is a genuine reason; there is no other. + * resolves it by hand. That is a genuine reason; there is no other, which is + * why this list is two entries long and both are migrations. */ const ALLOWED: Record = { "app/prisma/migrations/20260806163000_stock_location_holds_committed_stock/migration.sql": "applied migration -- editing it breaks its Prisma checksum on live deployments", "app/prisma/migrations/20260806180000_app_settings_source_adapter/migration.sql": "applied migration -- editing it breaks its Prisma checksum on live deployments", - "app/__tests__/clientDataTripwire.test.ts": "this file lists the patterns it searches for", }; function trackedHits(pattern: string): { file: string; line: string }[] { @@ -103,8 +123,8 @@ function trackedHits(pattern: string): { file: string; line: string }[] { maxBuffer: 32 * 1024 * 1024, }); } catch (e) { - // git grep exits 1 with no output when nothing matches -- that is the pass. const err = e as { status?: number; stdout?: string }; + // git grep exits 1 with no output when nothing matches -- that is the pass. // Everything else must fail loud. A maxBuffer overflow in particular throws // with status null and TRUNCATED stdout: adopting that buffer would return a // partial walk that looks clean, which is the exact silent pass this test @@ -137,8 +157,20 @@ function pathHits(pattern: string): { file: string; line: string }[] { } describe("no real client or personal data in a public repo", () => { - for (const { pattern, what } of FORBIDDEN) { + // A positive control, and the first test on purpose. Every other assertion + // here passes when the scan returns nothing -- so a scan that silently + // reaches nothing at all reads as a spotless repo. This one fails instead. + // It replaced an earlier trick where the test file matched its own plaintext + // patterns; encoding them removed that accident, so the canary is now + // deliberate. + it("actually scans the repository", () => { + expect(trackedHits("assertSafeSeedTarget").length).toBeGreaterThan(0); + expect(pathHits("clientDataTripwire").length).toBeGreaterThan(0); + }); + + for (const { p, what } of FORBIDDEN) { it(`does not contain ${what}`, () => { + const pattern = decode(p); const unexplained = [...trackedHits(pattern), ...pathHits(pattern)].filter( (h) => !(h.file in ALLOWED), ); @@ -148,14 +180,12 @@ describe("no real client or personal data in a public repo", () => { // The exemption list is the part that rots: a file gets scrubbed or deleted, // its entry stays, and the next real hit in a path someone copied from it - // passes silently. Checking BOTH directions is what makes the list trustworthy. + // passes silently. Checking BOTH directions is what makes the list + // trustworthy. it("has no stale exemptions -- every allowed path still has a hit to explain", () => { - const allHits = new Set(FORBIDDEN.flatMap(({ pattern }) => trackedHits(pattern)).map((h) => h.file)); - // This file is deliberately NOT exempted from the check. It always hits -- - // it contains the pattern sources themselves -- so it doubles as the - // liveness canary: if the scan ever silently returns nothing, this is the - // path that shows up stale and fails the test. - const stale = Object.keys(ALLOWED).filter((f) => !allHits.has(f)); - expect(stale).toEqual([]); + const allHits = new Set( + FORBIDDEN.flatMap(({ p }) => trackedHits(decode(p))).map((h) => h.file), + ); + expect(Object.keys(ALLOWED).filter((f) => !allHits.has(f))).toEqual([]); }); });