Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/start-session/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
19 changes: 14 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
29 changes: 15 additions & 14 deletions app/__tests__/aestheticMovementOrderParser.test.ts
Original file line number Diff line number Diff line change
@@ -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).
Expand All @@ -11,35 +12,35 @@ 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", () => {
const order = parseAestheticMovementOrderText(FIXTURE);

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");
});

Expand All @@ -49,8 +50,8 @@ describe("parseAestheticMovementOrderText", () => {
name: "Classic - Tic Tac Toe NEW",
upc: "7350108174152",
qty: 12,
unitPrice: 33,
lineTotal: 396,
unitPrice: 25,
lineTotal: 300,
});
});

Expand All @@ -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([]);
});

Expand Down
4 changes: 2 additions & 2 deletions app/__tests__/apparelOrderVendors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions app/__tests__/beatrizBallBuyerLines.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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");
Expand All @@ -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(),
);
Expand Down
37 changes: 19 additions & 18 deletions app/__tests__/beatrizBallOrderParser.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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");

Expand All @@ -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", () => {
Expand All @@ -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);
});
Expand Down
27 changes: 14 additions & 13 deletions app/__tests__/brandWiseOrderParser.test.ts
Original file line number Diff line number Diff line change
@@ -1,59 +1,60 @@
// /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.

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");

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"');
});

Expand Down
Loading
Loading