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..2b5f7a8c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -196,11 +196,20 @@ 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`, `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 + 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__/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__/apparelOrderVendors.test.ts b/app/__tests__/apparelOrderVendors.test.ts index f31ab0f1..17c785ed 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: [ @@ -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 cfc6997e..475da3b5 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 @@ -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"); @@ -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__/beatrizBallOrderParser.test.ts b/app/__tests__/beatrizBallOrderParser.test.ts index cd4cb98a..5fe23c06 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). + // "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: 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 new file mode 100644 index 00000000..7c280c23 --- /dev/null +++ b/app/__tests__/clientDataTripwire.test.ts @@ -0,0 +1,191 @@ +// /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, +// 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. 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. +// +// 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"; + +const REPO_ROOT = join(__dirname, "..", ".."); + +/** + * 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 rather than fix leaks. + */ +const FORBIDDEN: { p: string; what: string }[] = [ + { + p: "c2F5Yg==", + what: "the pilot deployment's company, town and email-domain stem, typos included", + }, + { p: "Y2hlc2hpcmV8Z2xhc3RvbmJ1cnk=", what: "the pilot deployment's store towns" }, + { p: "c2FtbXlnNDB8am9uZWlsfGdzdG9uZXx3Y29wZQ==", what: "real staff email local-parts" }, + { + p: "Z3JlZW5zdGVpbnxwYW5hZ3l8ZHJhbnNmaWVsZHxtYXRoZW55fHRlbmVyb3c=", + what: "real people's surnames", + }, + { + p: "c29yYm98bm9yZHF1aXN0fGNhbGtpbnN8YmFybnVtfGhvbWFufGZhdmFsZXx2YW50b25nZXJlbg==", + what: "real people's surnames", + }, + { + p: "ODYwLTQ3MC0zNjUzfDIxMy02MjMtMTM0NXw4NjAtMzg4LTA4OTF8ODYwLTM4OC0zNjky", + what: "real phone and fax numbers", + }, + { + p: "Y2ljY29uZXxkd3llcnxnZXJtYW5vfGZpbGlwcG9uZXxsZXZhdGlub3xzb3Jib3xkZW1pa3xzaWdhbA==", + what: "real people's surnames", + }, + { + p: "ZXJpbiBrZWxseXxhbGV4IHJvYmVydHNvbnxyZWJlY2NhIHdhcnJlbnxtYXJ5IGdvb2R3aW58cmVnaW5hbGQgYWRhbXN8bWFkaXNvbiBiYWtlcnxzdXNhbiByb2JlcnRzfGphbWllIHlvdW5nfHNhcmFoIGxldmF0aW5vfGFteSBzYWdlfHNoYW5ub24gbWFydGlufGxpc2Egcml0eg==", + what: "real people's full names", + }, + { p: "cndhcnJlbkB8c2FtbXlnNDA=", what: "real staff email local-parts" }, + { + p: "NTcgcHJpbmNldG9uIGxhbmV8Mjk4IGhpZ2hsYW5kIGF2ZW51ZXwyIG1haW4gc3RyZWV0fDggbW9udGljZWxsb3xlYXN0IGx5bWU=", + what: "real street addresses", + }, + { p: "MDY0NzU=", what: "the pilot deployment's town ZIP" }, + { + p: "UE9OMDkwWzAtOV1bMC05XXxCMzE2Njk5Nzl8MTUzNjQyLTA3MDEyNnwxMDAwMjkyODIxfDAwNjM0Nzd8MDA2MzQ3NnwzMTY4MDUzNHw3NzIzNGYxYWY2fDAwMDI1OTIzNjB8MDAwMjU5MjM2MXwxODU3MzM0MXwxODkwODE4NXwzMjAwODgxMw==", + what: "real vendor order, PO and document numbers", + }, + { + p: "MTAsOTc2XFwuNDl8MzIsMTA4XFwuNjd8MSwwNTdcXC42MnwxLDE4OFxcLjU3fDIyLDM3M1xcLjAwfDIsNjg4XFwuMDB8MywzMjJcXC4wMHwyLDE5NlxcLjAwfDcyMlxcLjc0fDIsMzY4XFwuNTB8OSw/Mjk4XFwuOXwyLD80ODRcXC42NXwzNTJcXC41NHw1M1xcLjk0fDEwN1xcLjg4fDI0XFwuNzV8MzlcXC45OXwxLD83MTBcXC4wMHxcXGIyODVcXC4wMHxcXGIyOTRcXC4wMHxcXGIzOTZcXC4wMHxcXGIyMjhcXC4wMHxcXGI2ODhcXC4wMA==", + what: "real vendor order totals and dealer prices", + }, + { 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. + * + * 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, 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", +}; + +function trackedHits(pattern: string): { file: string; line: string }[] { + let out = ""; + try { + out = execFileSync("git", ["grep", "-niE", "--untracked", pattern, "--", "."], { + cwd: REPO_ROOT, + encoding: "utf8", + maxBuffer: 32 * 1024 * 1024, + }); + } catch (e) { + 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 + // exists to prevent. + if (err.status === 1 && !err.stdout) return []; + throw e; + } + return out + .split("\n") + .filter(Boolean) + .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", () => { + // 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), + ); + 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(({ p }) => trackedHits(decode(p))).map((h) => h.file), + ); + expect(Object.keys(ALLOWED).filter((f) => !allHits.has(f))).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__/frankEileenParser.test.ts b/app/__tests__/frankEileenParser.test.ts index 2acc525b..6c34d8ea 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,62 +20,62 @@ 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 +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} 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", () => { - expect(order.poNumber).toBe("18573341"); + expect(order.poNumber).toBe("19900001"); }); it("reads season and the three header dates", () => { @@ -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__/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 2551955b..859d0a19 100644 --- a/app/__tests__/homeAccessoryOrders.test.ts +++ b/app/__tests__/homeAccessoryOrders.test.ts @@ -46,19 +46,19 @@ import type { BeatrizBallOrder } from "@/lib/pricing/beatrizBallOrderParser"; function bundle(overrides: Partial = {}): KKOrderBundle { return { vendorName: "", - customerPo: "PON09025", + customerPo: "PON00006", 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", }); }); @@ -339,13 +339,13 @@ 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"); }); 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); @@ -397,8 +397,8 @@ function wendoverItem(over: Partial = {}) { return { sku: "WLD3511", name: "Before the Rain Customized", - lineTotal: 1057.62, - unitPrice: 352.54, + lineTotal: 1200, + unitPrice: 400, qty: 3, medium: "Canvas", treatment: "Gallery Wrapped, Artist Enhanced", @@ -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, @@ -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); }); @@ -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", () => { @@ -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); }); @@ -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, @@ -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"); }); @@ -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,9 +620,9 @@ describe("normalizeAestheticMovementOrder", () => { function amOrder(over: Partial = {}): AestheticMovementOrder { return { vendorName: "Printworks", - poNumber: "PON09056", + 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"); }); @@ -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,20 +677,20 @@ 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", - 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"); }); @@ -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,19 +727,19 @@ 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", 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"); }); @@ -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: [ @@ -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"); }); @@ -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..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, }; } @@ -42,9 +42,9 @@ 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 }], + 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": "PON09025" } }), + input({ draft: twoOrderDraft(), poNumbers: { "0009900001": "PON00006" } }), ); - expect(rows.map((r) => r.reference)).toEqual(["PON09025", "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": "PON09025" }, + poNumbers: { "0009900001": "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__/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/homeAccessoryOrderCommit.integration.test.ts b/app/__tests__/integration/homeAccessoryOrderCommit.integration.test.ts index 4da6cc2f..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) { @@ -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); @@ -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/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/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..ab5ec7c2 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: "", }), ]; @@ -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"); }); }); @@ -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 f9b14e73..34998ca5 100644 --- a/app/__tests__/integration/runServiceCaseSheetImport.integration.test.ts +++ b/app/__tests__/integration/runServiceCaseSheetImport.integration.test.ts @@ -229,16 +229,16 @@ describe("runServiceCaseSheetImport — real-DB scenarios", () => { it("creates one ServiceCase + one initial-issue note per row", async () => { const customerId = await seedCustomer({ firstName: "Barbara", - lastName: "Panagy", - phone: "860-470-3653", + lastName: "Pallant", + phone: "860-555-0173", }); const buf = buildWorkbookBuffer({ inProcess: [ { Timestamp: new Date("2025-10-03T00:00:00Z"), - Name: "Barbara Panagy", - "Phone #": "860-470-3653", + Name: "Barbara Pallant", + "Phone #": "860-555-0173", "Preferred Contact Method": "Phone", Vendor: "Hallagan", Status: "Service Call", @@ -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. @@ -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.", @@ -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", @@ -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..c6e79910 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,12 +68,12 @@ 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: { orderno: "SO-CASE-WHITESPACE", - salesperson: " karen WEST ", + salesperson: " karen WESTON ", salesPersonId: null, }, }); 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__/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 904d33aa..83cbae64 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: Saybrook 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,13 +84,13 @@ 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"); }); 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"); @@ -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,38 +251,38 @@ 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", () => { - // 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", - "PON09057", + "Purchase Order by - ID# 32000002MarketTime", + "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__/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/__tests__/ordoriteReportRouter.test.ts b/app/__tests__/ordoriteReportRouter.test.ts index bda3b7a4..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"); @@ -24,17 +37,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 +55,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 +70,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 +81,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 +172,84 @@ 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. + 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(() => { + 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", () => { + 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("narrows to the pinned prefix -- another org's file is not an org report", () => { + process.env.ORDORITE_REPORT_PREFIX = "Acme_Furniture"; + 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"); + }); + }); + + 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..3b3d5337 100644 --- a/app/__tests__/ordoriteShared.test.ts +++ b/app/__tests__/ordoriteShared.test.ts @@ -257,17 +257,92 @@ 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 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(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", () => { // 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 (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 +354,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 +388,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,9 +397,9 @@ 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"; + process.env.COMPANY_EMAIL_DOMAIN = "rive"; } }); }); @@ -335,16 +410,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", }); }); @@ -404,10 +479,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", }); }); @@ -432,10 +507,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", }); }); @@ -468,19 +543,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", }); }); @@ -578,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/__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__/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 a7e682da..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); @@ -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..1913ff43 --- /dev/null +++ b/app/__tests__/seedTargetGuard.test.ts @@ -0,0 +1,121 @@ +// /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 { 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}`; +const safe = { forceUnsafe: false }; +const escapeRe = (t: string) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +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", "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, 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"); + 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", () => { + 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 56aa67e3..198394ed 100644 --- a/app/__tests__/serviceCaseSheetImport.test.ts +++ b/app/__tests__/serviceCaseSheetImport.test.ts @@ -23,13 +23,13 @@ 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("fffffff2-aaaa-bbbb-cccc-dddddddddddd")?.userId).toBe("rwarren@example.com"); + expect(map.get("aaaaaaa1-bbbb-cccc-dddd-eeeeeeeeeeee")?.displayName).toBe("Rebecca Wexford"); + expect(map.get("fffffff2-aaaa-bbbb-cccc-dddddddddddd")?.userId).toBe("rwexford@example.com"); }); it("tolerates attribute orderings in either direction", () => { @@ -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", () => { @@ -179,17 +179,17 @@ describe("normalizePhone", () => { describe("resolveAuthor", () => { const staffByEmail = new Map([["alex@example.com", 100]]); const staffByName = new Map([ - ["rebecca warren", 101], - ["alex robertson", 100], + ["rebecca wexford", 101], + ["alex rowntree", 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__/simblistCsvOrderParser.test.ts b/app/__tests__/simblistCsvOrderParser.test.ts index f232601c..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,SAYBROOK 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 e8d76e88..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 Goodwin", "Adam Calkins", "Reginald Adams"]) { + 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"); @@ -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__/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__/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/__tests__/wendoverOrderParser.test.ts b/app/__tests__/wendoverOrderParser.test.ts index 1c938828..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", - "7/16/26, 1:11 PMsaybrookhome.com Mail - Fwd: Your Wendover Art Group order confirmation", - "Page 3 of 8https://mail.google.com/mail/u/1/?ik=77234f1af6", + "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=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/__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": "", 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..978b4c32 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|ci)(_|$)/i; export class UnsafeSeedTargetError extends Error {} @@ -56,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; } @@ -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 ` + + `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/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..d9b69259 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,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): `saybrook`, `holt_saybrook` 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 @@ -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/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/scripts/setup.sh b/app/scripts/setup.sh index e3de7403..3355118d 100755 --- a/app/scripts/setup.sh +++ b/app/scripts/setup.sh @@ -57,12 +57,22 @@ 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)." ;; + # 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 + 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/reportRouter.ts b/app/src/lib/adapters/ordorite/reportRouter.ts index 6e8e5ce2..08c690a4 100644 --- a/app/src/lib/adapters/ordorite/reportRouter.ts +++ b/app/src/lib/adapters/ordorite/reportRouter.ts @@ -21,7 +21,25 @@ 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: `_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 @@ -46,7 +64,7 @@ const REPORT_ROUTES: RouteEntry[] = [ runner: runDepositsImport, }, { - pattern: /SH_Stock_by_Item/i, + orgReport: "Stock_by_Item", importType: "stock", runner: runStockByItemImport, }, @@ -66,12 +84,15 @@ 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, + // `_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, }, @@ -96,12 +117,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 +130,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 +154,48 @@ export interface ResolvedRoute { runner: (data: Record[], createdBy?: string) => Promise; } +/** + * 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, prefixAlt: string | null): RegExp | null { + if (route.pattern) return route.pattern; + 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 { // Check if this is a known-redundant file for (const skip of SKIP_PATTERNS) { if (skip.test(filename)) return "skip"; } + // 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 (route.pattern.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/runners.ts b/app/src/lib/adapters/ordorite/runners.ts index bdb6120f..75fd00b4 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; @@ -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 }) @@ -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 @@ -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..5e150887 100644 --- a/app/src/lib/adapters/ordorite/shared.ts +++ b/app/src/lib/adapters/ordorite/shared.ts @@ -213,10 +213,50 @@ 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 | 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); + return codes.length ? codes.join("|") : null; +} + +/** + * 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()) + .filter(Boolean) + .map((code) => code.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); +} // 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 +306,14 @@ 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); + // 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; + if (RETURN_ORDER_PREFIX.test(orderno)) return true; + const codes = storeCodeFragment(); + if (!codes) return false; + return new RegExp(`^(?:${codes})[A-Z]*A\\d`, "i").test(orderno); } /** @@ -395,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(); @@ -410,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/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..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 — -// saybrook.yaml, akritos.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 @@ -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/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..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,22 +150,22 @@ 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.', }, { 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 " + - 'run-together line -- "9BOATLINEG6$285.00$1,710.00Boa Table Lamp" (item + qty + $unit ' + + "SuperCatSolutions' order form (Dana Whitfield writes orders on it). Each item is one " + + '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 " + @@ -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,9 +712,9 @@ 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, + * 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/journalEntry.ts b/app/src/lib/journalEntry.ts index 6b404537..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 Saybrook 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/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/aestheticMovementOrderParser.ts b/app/src/lib/pricing/aestheticMovementOrderParser.ts index 5319d1d3..fea7ffd0 100644 --- a/app/src/lib/pricing/aestheticMovementOrderParser.ts +++ b/app/src/lib/pricing/aestheticMovementOrderParser.ts @@ -10,11 +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 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), +// 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/beatrizBallOrderParser.ts b/app/src/lib/pricing/beatrizBallOrderParser.ts index 7ed05482..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; @@ -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/brandWiseOrderParser.ts b/app/src/lib/pricing/brandWiseOrderParser.ts index d5d640c3..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 -// (B31669979 / PON09029, 8 items, $3,322.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 922fce22..ff740a76 100644 --- a/app/src/lib/pricing/marketTimeOrderParser.ts +++ b/app/src/lib/pricing/marketTimeOrderParser.ts @@ -8,15 +8,15 @@ // 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 -// 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 @@ -59,13 +59,13 @@ 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*#?:?|$)/; 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 7bd7038a..1818fe32 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 +// 3,ZFUSA03-C,Big Time Brownie Mix - case pack of 6,,2,48.00,...,10628678860152,...,$96.00 // -// 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). +// 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 $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..a5b37469 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 @@ -52,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[]; } @@ -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/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..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 @@ -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/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/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..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 — `saybrook.yaml`, `akritos.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 @@ -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..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 — `saybrook.yaml`, `akritos.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 @@ -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/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 diff --git a/docs/domains/import-pipeline.md b/docs/domains/import-pipeline.md index 1f078887..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. @@ -96,7 +106,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 +122,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 +135,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 +143,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**. @@ -262,7 +272,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/imports-overview.md b/docs/domains/imports-overview.md index 3773674d..d86c36a9 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 @@ -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 1538f94b..c4335b90 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 @@ -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/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/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/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/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/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..c4da0328 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,26 @@ 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 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. + +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/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. diff --git a/docs/tenant-literal-sweep.md b/docs/tenant-literal-sweep.md index ffc19a8c..20f97a08 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", "", "", ``` @@ -617,13 +617,13 @@ 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, ``` **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 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. @@ -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,9 +908,9 @@ 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` -`app/src/lib/adapters/ordorite/reportRouter.ts:49` — `pattern: /SH_Stock_by_Item/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: /_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. @@ -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 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 848ad74e..79a8c54e 100644 --- a/env.example +++ b/env.example @@ -9,14 +9,15 @@ # --- 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. -DATABASE_URL=postgresql://dbuser_fbc:your-password-here@db:5432/holt_dev?connection_limit=20&pool_timeout=10 +# 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 -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 @@ -32,6 +33,33 @@ POSTGRES_DB=holt_dev # GMAIL_IMPERSONATE_EMAIL=you@example.com # AUTO_IMPORT_API_KEY= +# --- Source-system conventions (POS/ERP you import FROM) --- +# 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 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. +# 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; diff --git a/scripts/restore-drill.sh b/scripts/restore-drill.sh index c1773a6f..6f1b9d75 100755 --- a/scripts/restore-drill.sh +++ b/scripts/restore-drill.sh @@ -120,10 +120,12 @@ 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. 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*) ;; @@ -135,7 +137,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 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