diff --git a/app/routes/app.invoices.tsx b/app/routes/app.invoices.tsx index 58f17be..4d7d1ea 100644 --- a/app/routes/app.invoices.tsx +++ b/app/routes/app.invoices.tsx @@ -3,6 +3,7 @@ import { Link, useLoaderData, useNavigation, useFetcher } from "react-router"; import { authenticate } from "../shopify.server"; import db from "../db.server"; +import { buildRepresentativeInvoiceMap } from "../services/invoice/representativeInvoice"; interface RecentOrder { id: string; // gid @@ -102,10 +103,13 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { }, orderBy: [{ version: "desc" }, { createdAt: "desc" }], }); - const latestByOrder = new Map(); - for (const inv of invoices) { - if (!latestByOrder.has(inv.orderId)) latestByOrder.set(inv.orderId, inv); - } + // Pick the representative invoice per order. `invoices` is sorted by + // version desc, but a cancelled invoice can carry a HIGHER version than + // the current active one (cancel-and-reissue bumps versions), so a naive + // "first row wins" would surface a stale cancelled invoice and hide the + // live one. Prefer the latest non-cancelled invoice; only fall back to a + // cancelled row when no active invoice exists. + const latestByOrder = buildRepresentativeInvoiceMap(invoices); orders = nodes.map((n) => { const inv = latestByOrder.get(n.id); diff --git a/app/services/invoice/representativeInvoice.ts b/app/services/invoice/representativeInvoice.ts new file mode 100644 index 0000000..4d93b6e --- /dev/null +++ b/app/services/invoice/representativeInvoice.ts @@ -0,0 +1,47 @@ +/** + * Selection helper for the "recent orders" / "recent drafts" lists. + * + * A single order can accumulate several invoice rows over its lifetime + * (regenerations bump the `version`, cancel-and-reissue cancels the old row + * and issues a new one). Crucially, a CANCELLED invoice can carry a HIGHER + * `version` than the current active one, so picking "highest version wins" + * would surface a stale cancelled invoice and hide the live one — the order + * would render as if it had no invoice ("Generate" button) even though a + * valid issued invoice exists. + * + * The correct representative for the UI is the latest NON-cancelled invoice; + * only when every row is cancelled do we fall back to the latest cancelled + * one (so the order can still show its "cancelled" state). + */ + +export interface RepresentativeInvoiceRow { + orderId: string; + version: number; + cancelledAt: Date | null; +} + +/** + * Build a map of orderId -> representative invoice. + * + * @param invoices Invoice rows for the relevant orders. MUST already be sorted + * by `version` descending (then `createdAt` descending), matching the + * Prisma query order, so the first non-cancelled row encountered per order + * is the highest-version active invoice. + */ +export function buildRepresentativeInvoiceMap( + invoices: T[], +): Map { + const byOrder = new Map(); + for (const inv of invoices) { + const existing = byOrder.get(inv.orderId); + if (!existing) { + byOrder.set(inv.orderId, inv); + continue; + } + // Upgrade from a cancelled placeholder to the first active invoice seen. + if (existing.cancelledAt && !inv.cancelledAt) { + byOrder.set(inv.orderId, inv); + } + } + return byOrder; +} diff --git a/tests/representative-invoice.test.ts b/tests/representative-invoice.test.ts new file mode 100644 index 0000000..b48d668 --- /dev/null +++ b/tests/representative-invoice.test.ts @@ -0,0 +1,63 @@ +import { strict as assert } from "node:assert"; +import { describe, it } from "node:test"; + +import { + buildRepresentativeInvoiceMap, + type RepresentativeInvoiceRow, +} from "../app/services/invoice/representativeInvoice"; + +const d = (iso: string) => new Date(iso); + +describe("buildRepresentativeInvoiceMap", () => { + it("prefers the active invoice even when a cancelled row has a higher version (regression: order #1032)", () => { + // Rows as returned by Prisma: version desc, then createdAt desc. + // This mirrors the real #1032 state: a cancelled v8 sorted ahead of the + // live, issued v7. The naive "first row wins" picked v8 and the order + // rendered as if it had no invoice. + const rows: RepresentativeInvoiceRow[] = [ + { orderId: "gid://shopify/Order/1032", version: 8, cancelledAt: d("2026-05-31T08:59:05Z") }, + { orderId: "gid://shopify/Order/1032", version: 7, cancelledAt: null }, + { orderId: "gid://shopify/Order/1032", version: 1, cancelledAt: d("2026-05-15T13:05:14Z") }, + ]; + + const map = buildRepresentativeInvoiceMap(rows); + const rep = map.get("gid://shopify/Order/1032"); + + assert.ok(rep, "expected a representative invoice"); + assert.equal(rep!.version, 7, "should select the active v7, not the cancelled v8"); + assert.equal(rep!.cancelledAt, null); + }); + + it("falls back to the latest cancelled invoice when none are active", () => { + const rows: RepresentativeInvoiceRow[] = [ + { orderId: "gid://shopify/Order/1", version: 3, cancelledAt: d("2026-05-31T10:00:00Z") }, + { orderId: "gid://shopify/Order/1", version: 1, cancelledAt: d("2026-05-15T10:00:00Z") }, + ]; + + const rep = buildRepresentativeInvoiceMap(rows).get("gid://shopify/Order/1"); + assert.ok(rep); + assert.equal(rep!.version, 3, "highest-version cancelled wins when nothing is active"); + }); + + it("keeps the highest-version active invoice when multiple are active", () => { + const rows: RepresentativeInvoiceRow[] = [ + { orderId: "gid://shopify/Order/2", version: 5, cancelledAt: null }, + { orderId: "gid://shopify/Order/2", version: 4, cancelledAt: null }, + ]; + + const rep = buildRepresentativeInvoiceMap(rows).get("gid://shopify/Order/2"); + assert.equal(rep!.version, 5); + }); + + it("handles multiple orders independently", () => { + const rows: RepresentativeInvoiceRow[] = [ + { orderId: "A", version: 9, cancelledAt: d("2026-05-31T00:00:00Z") }, + { orderId: "A", version: 2, cancelledAt: null }, + { orderId: "B", version: 1, cancelledAt: null }, + ]; + + const map = buildRepresentativeInvoiceMap(rows); + assert.equal(map.get("A")!.version, 2); + assert.equal(map.get("B")!.version, 1); + }); +});