fixed issue in order cancellation
This commit is contained in:
@@ -3,6 +3,7 @@ import { Link, useLoaderData, useNavigation, useFetcher } from "react-router";
|
|||||||
|
|
||||||
import { authenticate } from "../shopify.server";
|
import { authenticate } from "../shopify.server";
|
||||||
import db from "../db.server";
|
import db from "../db.server";
|
||||||
|
import { buildRepresentativeInvoiceMap } from "../services/invoice/representativeInvoice";
|
||||||
|
|
||||||
interface RecentOrder {
|
interface RecentOrder {
|
||||||
id: string; // gid
|
id: string; // gid
|
||||||
@@ -102,10 +103,13 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
},
|
},
|
||||||
orderBy: [{ version: "desc" }, { createdAt: "desc" }],
|
orderBy: [{ version: "desc" }, { createdAt: "desc" }],
|
||||||
});
|
});
|
||||||
const latestByOrder = new Map<string, (typeof invoices)[number]>();
|
// Pick the representative invoice per order. `invoices` is sorted by
|
||||||
for (const inv of invoices) {
|
// version desc, but a cancelled invoice can carry a HIGHER version than
|
||||||
if (!latestByOrder.has(inv.orderId)) latestByOrder.set(inv.orderId, inv);
|
// 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) => {
|
orders = nodes.map((n) => {
|
||||||
const inv = latestByOrder.get(n.id);
|
const inv = latestByOrder.get(n.id);
|
||||||
|
|||||||
@@ -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<T extends RepresentativeInvoiceRow>(
|
||||||
|
invoices: T[],
|
||||||
|
): Map<string, T> {
|
||||||
|
const byOrder = new Map<string, T>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user