From 6bbae202f8eaabdddeaee9a971e5a73df1ee7361 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Fri, 28 Aug 2026 15:55:53 -0500 Subject: [PATCH 1/7] feat(reports): add live-unit and live-money helpers and a partial-cancel tone --- .../sponsors/reports/LinesManifestView.js | 24 ++++++ src/components/sponsors/reports/StatusPill.js | 5 +- .../__tests__/LinesManifestView.test.js | 84 ++++++++++++++++++- .../reports/__tests__/StatusPill.test.js | 5 ++ src/i18n/en.json | 1 + 5 files changed, 117 insertions(+), 2 deletions(-) diff --git a/src/components/sponsors/reports/LinesManifestView.js b/src/components/sponsors/reports/LinesManifestView.js index a4d061a6e..c8c9b8218 100644 --- a/src/components/sponsors/reports/LinesManifestView.js +++ b/src/components/sponsors/reports/LinesManifestView.js @@ -49,6 +49,30 @@ export const PER_PAGE_OPTIONS = [ MAX_PER_PAGE ]; +// Units still purchased on a line. Cancellation is not binary at the source: a line +// can be cancelled in quantities, and canceled_at is set only by the event that +// completes it, so a partially cancelled line arrives with is_canceled false and a +// real cancelled portion. Clamped at 0 because a legacy line can carry quantity 0 +// with canceled_quantity 1 (purchases-api 0022 defaults a missing +// description['quantity'] to 1, the reports sync defaults it to 0). +// Shared with ByItemView, which imports from this module (never the reverse). +export const liveQuantity = (row) => { + if (row?.is_canceled) return 0; + return Math.max(0, (row?.quantity ?? 0) - (row?.canceled_quantity ?? 0)); +}; + +// Money still purchased on a line, in cents. canceled_amount is the source's own +// FROZEN sum of the cancelled event rows, so this subtraction is exact: never +// line_total prorated by quantity, because the source charges partial events at +// floor unit price and gives the completing event the exact remainder, so +// proration drifts by cents. null in, null out, so a caller whose whole item has +// no money still renders "—" rather than 0. +export const liveAmountCents = (row) => { + if (row?.line_total == null) return null; + if (row.is_canceled) return 0; + return Math.max(0, row.line_total - (row.canceled_amount ?? 0)); +}; + // Destination = the line's add-on (e.g. "Meeting Room T"); when absent, the // logistics convention is the sponsor's booth — the API supplies it as // `sponsor_booth` (the sponsor's Booth-type add-on name(s)). The muted "Booth" diff --git a/src/components/sponsors/reports/StatusPill.js b/src/components/sponsors/reports/StatusPill.js index 9353de00d..1bfc0a022 100644 --- a/src/components/sponsors/reports/StatusPill.js +++ b/src/components/sponsors/reports/StatusPill.js @@ -10,7 +10,10 @@ const TONE_BY_STATUS = { in_progress: "info", not_applicable: "default", canceled: "default", - cancelled: "default" + cancelled: "default", + // A partially cancelled line is still live and still needs fulfilling, so it + // must not share the muted tone that means "ignore this row". + partially_canceled: "warning" }; export const statusTone = (status) => diff --git a/src/components/sponsors/reports/__tests__/LinesManifestView.test.js b/src/components/sponsors/reports/__tests__/LinesManifestView.test.js index 4eb0e2569..8d100b618 100644 --- a/src/components/sponsors/reports/__tests__/LinesManifestView.test.js +++ b/src/components/sponsors/reports/__tests__/LinesManifestView.test.js @@ -2,7 +2,10 @@ import "@testing-library/jest-dom"; import React from "react"; import moment from "moment-timezone"; import { render, screen, within } from "@testing-library/react"; -import LinesManifestView from "../LinesManifestView"; +import LinesManifestView, { + liveQuantity, + liveAmountCents +} from "../LinesManifestView"; jest.mock("i18n-react/dist/i18n-react", () => ({ translate: (k, opts) => @@ -214,3 +217,82 @@ describe("lines_count copy", () => { expect(en.sponsor_reports_page.lines_count).toBe("{count} live lines"); }); }); + +describe("liveQuantity", () => { + it("returns the full quantity for an active line", () => { + expect(liveQuantity(line({ quantity: 2, canceled_quantity: 0 }))).toBe(2); + }); + + it("nets the cancelled portion off a partially cancelled line", () => { + expect( + liveQuantity( + line({ + quantity: 5, + canceled_quantity: 2, + is_partially_canceled: true + }) + ) + ).toBe(3); + }); + + it("returns 0 for a fully cancelled line", () => { + expect( + liveQuantity( + line({ quantity: 5, canceled_quantity: 5, is_canceled: true }) + ) + ).toBe(0); + }); + + it("clamps at 0 when a legacy line cancels more than it ordered", () => { + // purchases-api 0022 defaults a missing description['quantity'] to 1 while + // the reports sync defaults it to 0, so this shape reaches the client. + expect(liveQuantity(line({ quantity: 0, canceled_quantity: 1 }))).toBe(0); + }); + + it("treats missing fields as zero rather than NaN", () => { + expect(liveQuantity({})).toBe(0); + expect(liveQuantity(undefined)).toBe(0); + }); +}); + +describe("liveAmountCents", () => { + it("returns the full line total for an active line", () => { + expect( + liveAmountCents(line({ line_total: 100000, canceled_amount: 0 })) + ).toBe(100000); + }); + + it("subtracts the frozen cancelled amount on a partially cancelled line", () => { + // 40000 is the source's own sum for the cancelled units, NOT line_total + // prorated by quantity, which would give 40000 only by coincidence. + expect( + liveAmountCents( + line({ + line_total: 100000, + canceled_amount: 40000, + quantity: 5, + canceled_quantity: 2, + is_partially_canceled: true + }) + ) + ).toBe(60000); + }); + + it("returns 0 for a fully cancelled line", () => { + expect( + liveAmountCents( + line({ line_total: 100000, canceled_amount: 100000, is_canceled: true }) + ) + ).toBe(0); + }); + + it("preserves null so an all-null item still renders as unknown", () => { + expect(liveAmountCents(line({ line_total: null }))).toBeNull(); + }); + + it("clamps at 0 when the cancelled amount exceeds the line total", () => { + expect( + liveAmountCents(line({ line_total: 1000, canceled_amount: 5000 })) + ).toBe(0); + }); +}); diff --git a/src/components/sponsors/reports/__tests__/StatusPill.test.js b/src/components/sponsors/reports/__tests__/StatusPill.test.js index 25634455b..bddc18bd2 100644 --- a/src/components/sponsors/reports/__tests__/StatusPill.test.js +++ b/src/components/sponsors/reports/__tests__/StatusPill.test.js @@ -11,6 +11,11 @@ describe("statusTone", () => { expect(statusTone("paid")).toBe("success"); expect(statusTone("Confirmed")).toBe("success"); }); + + it("gives a partially canceled line a warning tone, distinct from canceled", () => { + expect(statusTone("partially_canceled")).toBe("warning"); + expect(statusTone("canceled")).toBe("default"); + }); }); describe("StatusPill", () => { diff --git a/src/i18n/en.json b/src/i18n/en.json index 9f787b323..ee09ba95a 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -4377,6 +4377,7 @@ "status_in_progress": "In Progress", "status_pending": "Pending", "status_canceled": "Canceled", + "status_partially_canceled": "Partially Canceled", "filter_asset_status": "Status", "group_by": "Group by", "report_filters": "Report Filters", From a03f253f751d1a4a0f08450da85fd7b3004d855f Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Fri, 28 Aug 2026 16:00:53 -0500 Subject: [PATCH 2/7] feat(reports): show partial cancellation state on lines manifest rows --- .../sponsors/reports/LinesManifestView.js | 53 ++++++++++------ .../__tests__/LinesManifestView.test.js | 60 +++++++++++++++++++ 2 files changed, 95 insertions(+), 18 deletions(-) diff --git a/src/components/sponsors/reports/LinesManifestView.js b/src/components/sponsors/reports/LinesManifestView.js index c8c9b8218..89ce2d482 100644 --- a/src/components/sponsors/reports/LinesManifestView.js +++ b/src/components/sponsors/reports/LinesManifestView.js @@ -137,6 +137,33 @@ const HEADERS = [ { key: "col_source_updated" } ]; +// The LINE's own state, three-way. A soft-canceled line leaves its parent order Paid, +// so rendering purchase.status printed "Paid" on a dead row. A partially cancelled line +// leaves BOTH canceled_at null and the order Paid, so it would print "Paid" too while +// carrying real cancelled units. is_canceled is checked first: the two flags are +// mutually exclusive at the API, and this keeps that ordering explicit here. +const LineStatusPill = ({ line: row }) => { + if (row.is_canceled) { + return ( + + ); + } + if (row.is_partially_canceled) { + return ( + + ); + } + return ( + + ); +}; + const LinesManifestView = ({ rows = [], total = 0, @@ -207,26 +234,16 @@ const LinesManifestView = ({ {formatCheckoutTime(line.purchase?.checkout_at)} {line.notes} - {line.quantity} + + {/* "3 / 5" only where a portion is cancelled; an active or + fully cancelled line keeps its bare ordered quantity. */} + {line.is_partially_canceled + ? `${liveQuantity(line)} / ${line.quantity}` + : line.quantity} + {line.rate_name} - {/* The LINE's state, not the parent order's. A soft-canceled - line leaves its order Paid, so rendering purchase.status - printed "Paid" on a dead row — and the strikethrough that - was the only other signal does not survive CSV export. */} - {line.is_canceled ? ( - - ) : ( - - )} + {line.line_total == null diff --git a/src/components/sponsors/reports/__tests__/LinesManifestView.test.js b/src/components/sponsors/reports/__tests__/LinesManifestView.test.js index 8d100b618..4d48617cd 100644 --- a/src/components/sponsors/reports/__tests__/LinesManifestView.test.js +++ b/src/components/sponsors/reports/__tests__/LinesManifestView.test.js @@ -296,3 +296,63 @@ describe("liveAmountCents", () => { ).toBe(0); }); }); + +describe("partially cancelled lines", () => { + const partial = () => + line({ + quantity: 5, + canceled_quantity: 2, + canceled_amount: 40000, + is_canceled: false, + is_partially_canceled: true, + canceled_at: null + }); + + it("shows live units over ordered units in the quantity cell", () => { + renderView({ rows: [partial()], total: 1 }); + expect(screen.getByText("3 / 5")).toBeInTheDocument(); + }); + + it("renders the partially canceled pill, not the order status", () => { + renderView({ rows: [partial()], total: 1 }); + expect( + screen.getByText("sponsor_reports_page.status_partially_canceled") + ).toBeInTheDocument(); + expect(screen.queryByText("Paid")).not.toBeInTheDocument(); + }); + + it("does not strike through a partially cancelled row", () => { + const { container } = renderView({ rows: [partial()], total: 1 }); + expect(container.querySelector("tr[data-canceled=\"true\"]")).toBeNull(); + }); + + it("still counts a partially cancelled line as one live line", () => { + renderView({ rows: [partial()], total: 1 }); + expect( + screen.getByText("sponsor_reports_page.lines_count:1") + ).toBeInTheDocument(); + }); + + it("leaves a fully cancelled line struck through with the canceled pill", () => { + const full = line({ + quantity: 5, + canceled_quantity: 5, + is_canceled: true, + is_partially_canceled: false + }); + const { container } = renderView({ rows: [full], total: 1 }); + expect( + container.querySelector("tr[data-canceled=\"true\"]") + ).toBeInTheDocument(); + expect( + screen.getByText("sponsor_reports_page.status_canceled") + ).toBeInTheDocument(); + expect(screen.getByText("5")).toBeInTheDocument(); + }); + + it("leaves an active line's quantity as a bare number", () => { + renderView({ rows: [line({ quantity: 2 })], total: 1 }); + expect(screen.getByText("2")).toBeInTheDocument(); + expect(screen.queryByText("2 / 2")).not.toBeInTheDocument(); + }); +}); From 39b39f094483461fb4b577e9f6bc5a0fa5d320fe Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Fri, 28 Aug 2026 16:07:14 -0500 Subject: [PATCH 3/7] feat(reports): net live units and money into the by-item aggregates --- src/components/sponsors/reports/ByItemView.js | 89 ++++++++++++--- .../reports/__tests__/ByItemView.test.js | 104 +++++++++++++++++- 2 files changed, 174 insertions(+), 19 deletions(-) diff --git a/src/components/sponsors/reports/ByItemView.js b/src/components/sponsors/reports/ByItemView.js index 910901f3d..7aba04e41 100644 --- a/src/components/sponsors/reports/ByItemView.js +++ b/src/components/sponsors/reports/ByItemView.js @@ -40,7 +40,12 @@ import T from "i18n-react/dist/i18n-react"; import { currencyAmountFromCents } from "openstack-uicore-foundation/lib/utils/money"; import StatusPill from "./StatusPill"; import ChipList from "../../mui/chip-list"; -import { Destination, PER_PAGE_OPTIONS } from "./LinesManifestView"; +import { + Destination, + PER_PAGE_OPTIONS, + liveQuantity, + liveAmountCents +} from "./LinesManifestView"; import { formatCheckoutTime } from "./OrdersTable"; import { DEFAULT_CURRENT_PAGE, @@ -85,16 +90,22 @@ const accumulateRow = (itemMap, row) => { item.label = row.description.trim(); } item.lines += 1; - // Canceled lines are shown struck-through in the drill-down (as contributors - // below) but excluded from ALL "purchased" aggregates: qty, money, orders, - // and statusMix. Counting them would let a canceled-only line report an item - // as purchased (Qty 0 next to Orders 1 / Paid 1). A mixed order keeps its - // count because the live line for the same item still adds the order id. + // Fully canceled lines are shown struck-through in the drill-down (as contributors + // below) but excluded from ALL "purchased" aggregates: qty, money, orders, and + // statusMix. Counting them would let a canceled-only line report an item as + // purchased (Qty 0 next to Orders 1 / Paid 1). A PARTIALLY cancelled line is not + // excluded: it is still a live purchase, and only its cancelled units come off qty. + // A mixed order keeps its count because the live line for the same item still adds + // the order id. if (!row.is_canceled) { - item.qty += row.quantity ?? 0; + // Units, not lines: a partially cancelled line contributes only its live remainder. + item.qty += liveQuantity(row); // Null-safe money: all-null stays null (renders "—"); mixed sums non-nulls. - if (row.line_total != null) { - item.totalCents = (item.totalCents ?? 0) + row.line_total; + // Money nets the same way, and exactly: canceled_amount is the source's own frozen + // sum for the cancelled units, never line_total prorated by quantity. + const liveCents = liveAmountCents(row); + if (liveCents != null) { + item.totalCents = (item.totalCents ?? 0) + liveCents; } const purchaseId = row.purchase?.id ?? null; if (purchaseId != null) { @@ -116,11 +127,25 @@ const accumulateRow = (itemMap, row) => { sponsorBooth: row.sponsor_booth ?? null, checkoutAt: row.purchase?.checkout_at ?? null, rateName: row.rate_name ?? "", - // the line's own state: a soft-canceled line leaves its parent order Paid - status: row.is_canceled ? "Canceled" : row.purchase?.status ?? "", - qty: row.quantity ?? 0, - lineTotalCents: row.line_total ?? null, + // the line's own state: a soft-canceled line leaves its parent order Paid, and a + // partially cancelled line leaves both canceled_at null and the order Paid + status: (() => { + if (row.is_canceled) return "Canceled"; + if (row.is_partially_canceled) return "partially_canceled"; + return row.purchase?.status ?? ""; + })(), + // A fully cancelled contributor keeps what was ORDERED and CHARGED: the row is + // struck through, and "0 / $0.00" would erase what the cancellation was for. + // Live figures apply to the two states that still contribute to the item total. + qty: row.is_canceled ? row.quantity ?? 0 : liveQuantity(row), + orderedQty: row.quantity ?? 0, + // Live money, matching the item total this row rolls up into. A drill-down whose + // rows summed to more than their own header would be its own bug report. + lineTotalCents: row.is_canceled + ? row.line_total ?? null + : liveAmountCents(row), isCanceled: Boolean(row.is_canceled), + isPartiallyCanceled: Boolean(row.is_partially_canceled), // line-grain freshness (decision 1): the contributor row IS a line syncedAt: row.synced_at ?? null, sourceUpdatedAt: row.source_updated_at ?? null @@ -463,12 +488,40 @@ const ItemTable = ({ {c.rateName} - + {(() => { + if (c.isCanceled) { + return ( + + ); + } + if (c.isPartiallyCanceled) { + return ( + + ); + } + return ( + + ); + })()} + + + {c.isPartiallyCanceled + ? `${c.qty} / ${c.orderedQty}` + : c.qty} - {c.qty} {c.lineTotalCents == null ? "—" diff --git a/src/components/sponsors/reports/__tests__/ByItemView.test.js b/src/components/sponsors/reports/__tests__/ByItemView.test.js index 3a1dc2ffe..eaf706b4f 100644 --- a/src/components/sponsors/reports/__tests__/ByItemView.test.js +++ b/src/components/sponsors/reports/__tests__/ByItemView.test.js @@ -162,8 +162,10 @@ describe("groupLinesBySponsorItem", () => { rateName: "Early", status: "Canceled", qty: 2, + orderedQty: 2, lineTotalCents: 100000, isCanceled: true, + isPartiallyCanceled: false, syncedAt: null, sourceUpdatedAt: null }); @@ -253,6 +255,104 @@ describe("groupLinesBySponsorItem", () => { }); }); +describe("partial cancellation", () => { + const partial = (over = {}) => + line({ + quantity: 5, + canceled_quantity: 2, + canceled_amount: 40000, + is_canceled: false, + is_partially_canceled: true, + canceled_at: null, + ...over + }); + + it("counts only live units toward an item's qty", () => { + const [group] = groupLinesBySponsorItem([partial()]); + expect(group.items[0].qty).toBe(3); + }); + + it("keeps a fully cancelled line at zero units", () => { + const [group] = groupLinesBySponsorItem([ + partial({ + quantity: 5, + canceled_quantity: 5, + is_canceled: true, + is_partially_canceled: false + }) + ]); + expect(group.items[0].qty).toBe(0); + }); + + it("leaves an uncancelled line's units unchanged", () => { + const [group] = groupLinesBySponsorItem([line({ quantity: 4 })]); + expect(group.items[0].qty).toBe(4); + }); + + it("never lets a legacy over-cancelled line drive qty negative", () => { + const [group] = groupLinesBySponsorItem([ + partial({ quantity: 0, canceled_quantity: 1 }) + ]); + expect(group.items[0].qty).toBe(0); + }); + + it("flags the contributor as partially cancelled rather than Paid", () => { + const [group] = groupLinesBySponsorItem([partial()]); + const [contributor] = group.items[0].contributors; + expect(contributor.isPartiallyCanceled).toBe(true); + expect(contributor.isCanceled).toBe(false); + expect(contributor.status).toBe("partially_canceled"); + expect(contributor.qty).toBe(3); + expect(contributor.orderedQty).toBe(5); + }); + + it("still counts the parent order as an order for the item", () => { + const [group] = groupLinesBySponsorItem([partial()]); + expect(group.items[0].orders).toBe(1); + }); + + it("nets the cancelled money off the item total", () => { + const [group] = groupLinesBySponsorItem([ + partial({ line_total: 100000, canceled_amount: 40000 }) + ]); + expect(group.items[0].totalCents).toBe(60000); + }); + + it("gives the contributor the same live money as it contributes", () => { + const [group] = groupLinesBySponsorItem([ + partial({ line_total: 100000, canceled_amount: 40000 }) + ]); + expect(group.items[0].contributors[0].lineTotalCents).toBe(60000); + }); + + it("keeps an all-null-money item at null so it still renders as unknown", () => { + const [group] = groupLinesBySponsorItem([ + partial({ line_total: null, canceled_amount: 0 }) + ]); + expect(group.items[0].totalCents).toBeNull(); + }); + + it("contributes no money from a fully cancelled line", () => { + const [group] = groupLinesBySponsorItem([ + line({ + line_total: 100000, + is_canceled: true, + is_partially_canceled: false + }) + ]); + expect(group.items[0].totalCents).toBeNull(); + }); + + it("keeps a fully cancelled contributor at its ordered units and charged money", () => { + const [group] = groupLinesBySponsorItem([ + line({ quantity: 5, line_total: 100000, is_canceled: true }) + ]); + const [contributor] = group.items[0].contributors; + expect(contributor.qty).toBe(5); + expect(contributor.lineTotalCents).toBe(100000); + }); +}); + describe("groupLinesByItem", () => { it("merges an item_code ACROSS sponsors into one row and names each sponsor in the drill-down", () => { const rows = [ @@ -463,7 +563,9 @@ describe("ByItemView", () => { ] }); fireEvent.click(screen.getByText("AV1")); // expand the item - expect(screen.getByText("Canceled")).toBeInTheDocument(); + expect( + screen.getByText("sponsor_reports_page.status_canceled") + ).toBeInTheDocument(); const syncedText = moment.unix(synced).utc().format("YYYY-MM-DD h:mm A"); const sourceUpdatedText = moment .unix(sourceUpdated) From bc812aebff2443080371ef10e5e56a260b00a938 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Fri, 28 Aug 2026 16:19:41 -0500 Subject: [PATCH 4/7] test(reports): cover the by-item partial-cancel contributor render --- src/components/sponsors/reports/ByItemView.js | 6 ++- .../sponsors/reports/LinesManifestView.js | 13 ++++--- .../reports/__tests__/ByItemView.test.js | 37 +++++++++++++++++++ 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/components/sponsors/reports/ByItemView.js b/src/components/sponsors/reports/ByItemView.js index 7aba04e41..326267584 100644 --- a/src/components/sponsors/reports/ByItemView.js +++ b/src/components/sponsors/reports/ByItemView.js @@ -139,8 +139,10 @@ const accumulateRow = (itemMap, row) => { // Live figures apply to the two states that still contribute to the item total. qty: row.is_canceled ? row.quantity ?? 0 : liveQuantity(row), orderedQty: row.quantity ?? 0, - // Live money, matching the item total this row rolls up into. A drill-down whose - // rows summed to more than their own header would be its own bug report. + // Live money, matching the item total for the two states that contribute to + // it (live and partially cancelled). A fully cancelled row is the deliberate + // exception above: it shows what was charged, not what's live, so the + // drill-down row can sum to more than the header on purpose. lineTotalCents: row.is_canceled ? row.line_total ?? null : liveAmountCents(row), diff --git a/src/components/sponsors/reports/LinesManifestView.js b/src/components/sponsors/reports/LinesManifestView.js index 89ce2d482..53a2d7672 100644 --- a/src/components/sponsors/reports/LinesManifestView.js +++ b/src/components/sponsors/reports/LinesManifestView.js @@ -137,11 +137,14 @@ const HEADERS = [ { key: "col_source_updated" } ]; -// The LINE's own state, three-way. A soft-canceled line leaves its parent order Paid, -// so rendering purchase.status printed "Paid" on a dead row. A partially cancelled line -// leaves BOTH canceled_at null and the order Paid, so it would print "Paid" too while -// carrying real cancelled units. is_canceled is checked first: the two flags are -// mutually exclusive at the API, and this keeps that ordering explicit here. +// The LINE's own state, three-way. Without this pill, the strikethrough styling is +// the only other signal that a line was cancelled, and strikethrough does not survive +// CSV export — so the pill is not redundant with it. A soft-canceled line leaves its +// parent order Paid, so rendering purchase.status printed "Paid" on a dead row. A +// partially cancelled line leaves BOTH canceled_at null and the order Paid, so it +// would print "Paid" too while carrying real cancelled units. is_canceled is checked +// first: the two flags are mutually exclusive at the API, and this keeps that +// ordering explicit here. const LineStatusPill = ({ line: row }) => { if (row.is_canceled) { return ( diff --git a/src/components/sponsors/reports/__tests__/ByItemView.test.js b/src/components/sponsors/reports/__tests__/ByItemView.test.js index eaf706b4f..32803e88b 100644 --- a/src/components/sponsors/reports/__tests__/ByItemView.test.js +++ b/src/components/sponsors/reports/__tests__/ByItemView.test.js @@ -577,6 +577,43 @@ describe("ByItemView", () => { expect(cells[cells.length - 1]).toHaveTextContent(sourceUpdatedText); }); + it("renders a partially cancelled contributor's split quantity and pill (regression: catches the isPartiallyCanceled render branch being dropped)", () => { + renderView({ + groups: [ + group({ + items: [ + item({ + contributors: [ + { + sponsorName: "FNTECH", + number: "OCP-1", + formCode: "AV", + addOnName: null, + checkoutAt: null, + rateName: "Early", + status: "partially_canceled", + qty: 3, + orderedQty: 5, + lineTotalCents: 60000, + isCanceled: false, + isPartiallyCanceled: true, + syncedAt: null, + sourceUpdatedAt: null + } + ] + }) + ] + }) + ] + }); + fireEvent.click(screen.getByText("AV1")); // expand the item + expect( + screen.getByText("sponsor_reports_page.status_partially_canceled") + ).toBeInTheDocument(); + const row = screen.getByText("OCP-1").closest("tr"); + expect(within(row).getByText("3 / 5")).toBeInTheDocument(); + }); + it("expand button toggles the drill-down and reflects aria-expanded", () => { renderView(); const toggle = screen.getByRole("button", { From 2baa2b8f967b0d86ae3df99201df8a50368e4834 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Fri, 28 Aug 2026 17:28:39 -0500 Subject: [PATCH 5/7] refactor(reports): share the line-status token and pill between the report views The three-way line state (canceled / partially canceled / parent order status) was written three times: LineStatusPill, accumulateRow's contributor token, and the ByItemView drill-down render. Collapse to lineStatus() plus a token-driven LineStatusPill, both exported from LinesManifestView, which ByItemView already imports from. Also drop six tests that pass with the code deleted (a tone-map lookup, two identity assertions, unreachable is_canceled branches) and five that duplicated a surviving assertion, and trim the new comments to the eight that stop a plausible wrong edit. Co-Authored-By: Claude --- src/components/sponsors/reports/ByItemView.js | 59 ++--------------- .../sponsors/reports/LinesManifestView.js | 65 +++++++------------ src/components/sponsors/reports/StatusPill.js | 2 - .../reports/__tests__/ByItemView.test.js | 58 +++++------------ .../__tests__/LinesManifestView.test.js | 61 +++++------------ .../reports/__tests__/StatusPill.test.js | 5 -- 6 files changed, 62 insertions(+), 188 deletions(-) diff --git a/src/components/sponsors/reports/ByItemView.js b/src/components/sponsors/reports/ByItemView.js index 326267584..5815cedff 100644 --- a/src/components/sponsors/reports/ByItemView.js +++ b/src/components/sponsors/reports/ByItemView.js @@ -38,11 +38,12 @@ import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; import T from "i18n-react/dist/i18n-react"; import { currencyAmountFromCents } from "openstack-uicore-foundation/lib/utils/money"; -import StatusPill from "./StatusPill"; import ChipList from "../../mui/chip-list"; import { Destination, + LineStatusPill, PER_PAGE_OPTIONS, + lineStatus, liveQuantity, liveAmountCents } from "./LinesManifestView"; @@ -90,19 +91,8 @@ const accumulateRow = (itemMap, row) => { item.label = row.description.trim(); } item.lines += 1; - // Fully canceled lines are shown struck-through in the drill-down (as contributors - // below) but excluded from ALL "purchased" aggregates: qty, money, orders, and - // statusMix. Counting them would let a canceled-only line report an item as - // purchased (Qty 0 next to Orders 1 / Paid 1). A PARTIALLY cancelled line is not - // excluded: it is still a live purchase, and only its cancelled units come off qty. - // A mixed order keeps its count because the live line for the same item still adds - // the order id. if (!row.is_canceled) { - // Units, not lines: a partially cancelled line contributes only its live remainder. item.qty += liveQuantity(row); - // Null-safe money: all-null stays null (renders "—"); mixed sums non-nulls. - // Money nets the same way, and exactly: canceled_amount is the source's own frozen - // sum for the cancelled units, never line_total prorated by quantity. const liveCents = liveAmountCents(row); if (liveCents != null) { item.totalCents = (item.totalCents ?? 0) + liveCents; @@ -127,22 +117,10 @@ const accumulateRow = (itemMap, row) => { sponsorBooth: row.sponsor_booth ?? null, checkoutAt: row.purchase?.checkout_at ?? null, rateName: row.rate_name ?? "", - // the line's own state: a soft-canceled line leaves its parent order Paid, and a - // partially cancelled line leaves both canceled_at null and the order Paid - status: (() => { - if (row.is_canceled) return "Canceled"; - if (row.is_partially_canceled) return "partially_canceled"; - return row.purchase?.status ?? ""; - })(), - // A fully cancelled contributor keeps what was ORDERED and CHARGED: the row is - // struck through, and "0 / $0.00" would erase what the cancellation was for. - // Live figures apply to the two states that still contribute to the item total. + status: lineStatus(row), + // Struck-through rows show what was ordered, not 0. qty: row.is_canceled ? row.quantity ?? 0 : liveQuantity(row), orderedQty: row.quantity ?? 0, - // Live money, matching the item total for the two states that contribute to - // it (live and partially cancelled). A fully cancelled row is the deliberate - // exception above: it shows what was charged, not what's live, so the - // drill-down row can sum to more than the header on purpose. lineTotalCents: row.is_canceled ? row.line_total ?? null : liveAmountCents(row), @@ -490,34 +468,7 @@ const ItemTable = ({ {c.rateName} - {(() => { - if (c.isCanceled) { - return ( - - ); - } - if (c.isPartiallyCanceled) { - return ( - - ); - } - return ( - - ); - })()} + {c.isPartiallyCanceled diff --git a/src/components/sponsors/reports/LinesManifestView.js b/src/components/sponsors/reports/LinesManifestView.js index 53a2d7672..aaae9778f 100644 --- a/src/components/sponsors/reports/LinesManifestView.js +++ b/src/components/sponsors/reports/LinesManifestView.js @@ -49,24 +49,14 @@ export const PER_PAGE_OPTIONS = [ MAX_PER_PAGE ]; -// Units still purchased on a line. Cancellation is not binary at the source: a line -// can be cancelled in quantities, and canceled_at is set only by the event that -// completes it, so a partially cancelled line arrives with is_canceled false and a -// real cancelled portion. Clamped at 0 because a legacy line can carry quantity 0 -// with canceled_quantity 1 (purchases-api 0022 defaults a missing -// description['quantity'] to 1, the reports sync defaults it to 0). -// Shared with ByItemView, which imports from this module (never the reverse). +// Clamped: legacy lines can have quantity 0, canceled_quantity 1. +// ByItemView imports from here, never the reverse (cycle). export const liveQuantity = (row) => { if (row?.is_canceled) return 0; return Math.max(0, (row?.quantity ?? 0) - (row?.canceled_quantity ?? 0)); }; -// Money still purchased on a line, in cents. canceled_amount is the source's own -// FROZEN sum of the cancelled event rows, so this subtraction is exact: never -// line_total prorated by quantity, because the source charges partial events at -// floor unit price and gives the completing event the exact remainder, so -// proration drifts by cents. null in, null out, so a caller whose whole item has -// no money still renders "—" rather than 0. +// Never prorate line_total — partial events charge floor unit price. export const liveAmountCents = (row) => { if (row?.line_total == null) return null; if (row.is_canceled) return 0; @@ -137,33 +127,26 @@ const HEADERS = [ { key: "col_source_updated" } ]; -// The LINE's own state, three-way. Without this pill, the strikethrough styling is -// the only other signal that a line was cancelled, and strikethrough does not survive -// CSV export — so the pill is not redundant with it. A soft-canceled line leaves its -// parent order Paid, so rendering purchase.status printed "Paid" on a dead row. A -// partially cancelled line leaves BOTH canceled_at null and the order Paid, so it -// would print "Paid" too while carrying real cancelled units. is_canceled is checked -// first: the two flags are mutually exclusive at the API, and this keeps that -// ordering explicit here. -const LineStatusPill = ({ line: row }) => { - if (row.is_canceled) { - return ( - - ); - } - if (row.is_partially_canceled) { - return ( - - ); - } +// Both cancellation states leave the parent order Paid, so purchase.status lies. +export const lineStatus = (row) => { + if (row?.is_canceled) return "Canceled"; + if (row?.is_partially_canceled) return "partially_canceled"; + return row?.purchase?.status ?? ""; +}; + +const LABEL_KEY_BY_LINE_STATUS = { + Canceled: "sponsor_reports_page.status_canceled", + partially_canceled: "sponsor_reports_page.status_partially_canceled" +}; + +// Not redundant with the strikethrough, which no CSV export carries. +export const LineStatusPill = ({ status }) => { + const labelKey = LABEL_KEY_BY_LINE_STATUS[status]; return ( - + ); }; @@ -238,15 +221,13 @@ const LinesManifestView = ({ {line.notes} - {/* "3 / 5" only where a portion is cancelled; an active or - fully cancelled line keeps its bare ordered quantity. */} {line.is_partially_canceled ? `${liveQuantity(line)} / ${line.quantity}` : line.quantity} {line.rate_name} - + {line.line_total == null diff --git a/src/components/sponsors/reports/StatusPill.js b/src/components/sponsors/reports/StatusPill.js index 1bfc0a022..d676b5419 100644 --- a/src/components/sponsors/reports/StatusPill.js +++ b/src/components/sponsors/reports/StatusPill.js @@ -11,8 +11,6 @@ const TONE_BY_STATUS = { not_applicable: "default", canceled: "default", cancelled: "default", - // A partially cancelled line is still live and still needs fulfilling, so it - // must not share the muted tone that means "ignore this row". partially_canceled: "warning" }; diff --git a/src/components/sponsors/reports/__tests__/ByItemView.test.js b/src/components/sponsors/reports/__tests__/ByItemView.test.js index 32803e88b..5be09df09 100644 --- a/src/components/sponsors/reports/__tests__/ByItemView.test.js +++ b/src/components/sponsors/reports/__tests__/ByItemView.test.js @@ -32,6 +32,9 @@ const line = (over = {}) => ({ notes: "dock B", is_canceled: false, canceled_at: null, + canceled_quantity: 0, + canceled_amount: 0, + is_partially_canceled: false, ...over }); @@ -267,37 +270,23 @@ describe("partial cancellation", () => { ...over }); - it("counts only live units toward an item's qty", () => { - const [group] = groupLinesBySponsorItem([partial()]); - expect(group.items[0].qty).toBe(3); - }); - - it("keeps a fully cancelled line at zero units", () => { + it("keeps a fully cancelled line out of both aggregates", () => { const [group] = groupLinesBySponsorItem([ partial({ quantity: 5, canceled_quantity: 5, + line_total: 100000, is_canceled: true, is_partially_canceled: false }) ]); expect(group.items[0].qty).toBe(0); + expect(group.items[0].totalCents).toBeNull(); }); - it("leaves an uncancelled line's units unchanged", () => { - const [group] = groupLinesBySponsorItem([line({ quantity: 4 })]); - expect(group.items[0].qty).toBe(4); - }); - - it("never lets a legacy over-cancelled line drive qty negative", () => { - const [group] = groupLinesBySponsorItem([ - partial({ quantity: 0, canceled_quantity: 1 }) - ]); - expect(group.items[0].qty).toBe(0); - }); - - it("flags the contributor as partially cancelled rather than Paid", () => { + it("counts only live units and flags the contributor rather than Paid", () => { const [group] = groupLinesBySponsorItem([partial()]); + expect(group.items[0].qty).toBe(3); const [contributor] = group.items[0].contributors; expect(contributor.isPartiallyCanceled).toBe(true); expect(contributor.isCanceled).toBe(false); @@ -311,17 +300,11 @@ describe("partial cancellation", () => { expect(group.items[0].orders).toBe(1); }); - it("nets the cancelled money off the item total", () => { + it("nets the cancelled money off the item total and its contributor alike", () => { const [group] = groupLinesBySponsorItem([ partial({ line_total: 100000, canceled_amount: 40000 }) ]); expect(group.items[0].totalCents).toBe(60000); - }); - - it("gives the contributor the same live money as it contributes", () => { - const [group] = groupLinesBySponsorItem([ - partial({ line_total: 100000, canceled_amount: 40000 }) - ]); expect(group.items[0].contributors[0].lineTotalCents).toBe(60000); }); @@ -332,17 +315,6 @@ describe("partial cancellation", () => { expect(group.items[0].totalCents).toBeNull(); }); - it("contributes no money from a fully cancelled line", () => { - const [group] = groupLinesBySponsorItem([ - line({ - line_total: 100000, - is_canceled: true, - is_partially_canceled: false - }) - ]); - expect(group.items[0].totalCents).toBeNull(); - }); - it("keeps a fully cancelled contributor at its ordered units and charged money", () => { const [group] = groupLinesBySponsorItem([ line({ quantity: 5, line_total: 100000, is_canceled: true }) @@ -456,8 +428,10 @@ const item = (over = {}) => ({ rateName: "Early", status: "Paid", qty: 3, + orderedQty: 3, lineTotalCents: 150000, - isCanceled: false + isCanceled: false, + isPartiallyCanceled: false }, { sponsorName: "Nvidia", @@ -466,10 +440,12 @@ const item = (over = {}) => ({ addOnName: null, checkoutAt: null, rateName: "Standard", - status: "Pending Payment", + status: "Canceled", qty: 2, + orderedQty: 2, lineTotalCents: 100000, - isCanceled: true + isCanceled: true, + isPartiallyCanceled: false } ], ...over @@ -606,7 +582,7 @@ describe("ByItemView", () => { }) ] }); - fireEvent.click(screen.getByText("AV1")); // expand the item + fireEvent.click(screen.getByText("AV1")); expect( screen.getByText("sponsor_reports_page.status_partially_canceled") ).toBeInTheDocument(); diff --git a/src/components/sponsors/reports/__tests__/LinesManifestView.test.js b/src/components/sponsors/reports/__tests__/LinesManifestView.test.js index 4d48617cd..c4ddb3337 100644 --- a/src/components/sponsors/reports/__tests__/LinesManifestView.test.js +++ b/src/components/sponsors/reports/__tests__/LinesManifestView.test.js @@ -32,6 +32,9 @@ const line = (over = {}) => ({ notes: "dock B", is_canceled: false, canceled_at: null, + canceled_quantity: 0, + canceled_amount: 0, + is_partially_canceled: false, ...over }); @@ -219,10 +222,6 @@ describe("lines_count copy", () => { }); describe("liveQuantity", () => { - it("returns the full quantity for an active line", () => { - expect(liveQuantity(line({ quantity: 2, canceled_quantity: 0 }))).toBe(2); - }); - it("nets the cancelled portion off a partially cancelled line", () => { expect( liveQuantity( @@ -235,17 +234,8 @@ describe("liveQuantity", () => { ).toBe(3); }); - it("returns 0 for a fully cancelled line", () => { - expect( - liveQuantity( - line({ quantity: 5, canceled_quantity: 5, is_canceled: true }) - ) - ).toBe(0); - }); - it("clamps at 0 when a legacy line cancels more than it ordered", () => { - // purchases-api 0022 defaults a missing description['quantity'] to 1 while - // the reports sync defaults it to 0, so this shape reaches the client. + // legacy shape: purchases-api defaults quantity to 1, the sync to 0 expect(liveQuantity(line({ quantity: 0, canceled_quantity: 1 }))).toBe(0); }); @@ -256,15 +246,8 @@ describe("liveQuantity", () => { }); describe("liveAmountCents", () => { - it("returns the full line total for an active line", () => { - expect( - liveAmountCents(line({ line_total: 100000, canceled_amount: 0 })) - ).toBe(100000); - }); - it("subtracts the frozen cancelled amount on a partially cancelled line", () => { - // 40000 is the source's own sum for the cancelled units, NOT line_total - // prorated by quantity, which would give 40000 only by coincidence. + // the source's own sum, not a proration of line_total expect( liveAmountCents( line({ @@ -278,18 +261,6 @@ describe("liveAmountCents", () => { ).toBe(60000); }); - it("returns 0 for a fully cancelled line", () => { - expect( - liveAmountCents( - line({ line_total: 100000, canceled_amount: 100000, is_canceled: true }) - ) - ).toBe(0); - }); - - it("preserves null so an all-null item still renders as unknown", () => { - expect(liveAmountCents(line({ line_total: null }))).toBeNull(); - }); - it("clamps at 0 when the cancelled amount exceeds the line total", () => { expect( liveAmountCents(line({ line_total: 1000, canceled_amount: 5000 })) @@ -322,8 +293,10 @@ describe("partially cancelled lines", () => { }); it("does not strike through a partially cancelled row", () => { - const { container } = renderView({ rows: [partial()], total: 1 }); - expect(container.querySelector("tr[data-canceled=\"true\"]")).toBeNull(); + renderView({ rows: [partial()], total: 1 }); + expect(screen.getByText("AV1").closest("tr")).not.toHaveAttribute( + "data-canceled" + ); }); it("still counts a partially cancelled line as one live line", () => { @@ -340,19 +313,19 @@ describe("partially cancelled lines", () => { is_canceled: true, is_partially_canceled: false }); - const { container } = renderView({ rows: [full], total: 1 }); - expect( - container.querySelector("tr[data-canceled=\"true\"]") - ).toBeInTheDocument(); + renderView({ rows: [full], total: 1 }); + const row = screen.getByText("AV1").closest("tr"); + expect(row).toHaveAttribute("data-canceled", "true"); expect( - screen.getByText("sponsor_reports_page.status_canceled") + within(row).getByText("sponsor_reports_page.status_canceled") ).toBeInTheDocument(); - expect(screen.getByText("5")).toBeInTheDocument(); + expect(within(row).getByText("5")).toBeInTheDocument(); }); it("leaves an active line's quantity as a bare number", () => { renderView({ rows: [line({ quantity: 2 })], total: 1 }); - expect(screen.getByText("2")).toBeInTheDocument(); - expect(screen.queryByText("2 / 2")).not.toBeInTheDocument(); + const row = screen.getByText("AV1").closest("tr"); + expect(within(row).getByText("2")).toBeInTheDocument(); + expect(within(row).queryByText("2 / 2")).not.toBeInTheDocument(); }); }); diff --git a/src/components/sponsors/reports/__tests__/StatusPill.test.js b/src/components/sponsors/reports/__tests__/StatusPill.test.js index bddc18bd2..25634455b 100644 --- a/src/components/sponsors/reports/__tests__/StatusPill.test.js +++ b/src/components/sponsors/reports/__tests__/StatusPill.test.js @@ -11,11 +11,6 @@ describe("statusTone", () => { expect(statusTone("paid")).toBe("success"); expect(statusTone("Confirmed")).toBe("success"); }); - - it("gives a partially canceled line a warning tone, distinct from canceled", () => { - expect(statusTone("partially_canceled")).toBe("warning"); - expect(statusTone("canceled")).toBe("default"); - }); }); describe("StatusPill", () => { From 819b63a53730e2b4d5b8528422f8e167a5a6de19 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Fri, 28 Aug 2026 17:58:44 -0500 Subject: [PATCH 6/7] fix(reports): show live over charged money on partially cancelled manifest rows The Qty cell reads "3 / 5" while the money cell priced all 5, so the row contradicted itself. Render both figures, matching the Qty cell: netting alone would drop the charged amount, and this is the only per-line money surface finance has (the Orders serializer carries no cancellation field). Co-Authored-By: Claude --- .../sponsors/reports/LinesManifestView.js | 13 ++++++++++--- .../reports/__tests__/LinesManifestView.test.js | 6 ++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/components/sponsors/reports/LinesManifestView.js b/src/components/sponsors/reports/LinesManifestView.js index aaae9778f..475b97127 100644 --- a/src/components/sponsors/reports/LinesManifestView.js +++ b/src/components/sponsors/reports/LinesManifestView.js @@ -139,6 +139,15 @@ const LABEL_KEY_BY_LINE_STATUS = { partially_canceled: "sponsor_reports_page.status_partially_canceled" }; +// Live over charged, matching the Qty cell. Finance reads this column and this is +// the only per-line money surface, so netting alone would drop the charged figure. +const lineTotalLabel = (row) => + row.is_partially_canceled + ? `${currencyAmountFromCents( + liveAmountCents(row) + )} / ${currencyAmountFromCents(row.line_total)}` + : currencyAmountFromCents(row.line_total); + // Not redundant with the strikethrough, which no CSV export carries. export const LineStatusPill = ({ status }) => { const labelKey = LABEL_KEY_BY_LINE_STATUS[status]; @@ -230,9 +239,7 @@ const LinesManifestView = ({ - {line.line_total == null - ? "—" - : currencyAmountFromCents(line.line_total)} + {line.line_total == null ? "—" : lineTotalLabel(line)} {formatCheckoutTime(line.synced_at)} diff --git a/src/components/sponsors/reports/__tests__/LinesManifestView.test.js b/src/components/sponsors/reports/__tests__/LinesManifestView.test.js index c4ddb3337..eb755993d 100644 --- a/src/components/sponsors/reports/__tests__/LinesManifestView.test.js +++ b/src/components/sponsors/reports/__tests__/LinesManifestView.test.js @@ -284,6 +284,12 @@ describe("partially cancelled lines", () => { expect(screen.getByText("3 / 5")).toBeInTheDocument(); }); + it("shows live over charged money, so the row does not price cancelled units", () => { + renderView({ rows: [partial()], total: 1 }); + const row = screen.getByText("AV1").closest("tr"); + expect(within(row).getByText("$600.00 / $1000.00")).toBeInTheDocument(); + }); + it("renders the partially canceled pill, not the order status", () => { renderView({ rows: [partial()], total: 1 }); expect( From d1c8ed23d714e7bf1bc885541a88da30ddbeddba Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Fri, 28 Aug 2026 18:08:47 -0500 Subject: [PATCH 7/7] test(reports): assert the partial-cancel pill renders in the warning color Spec requires the partial state be visually distinct; only its label was guarded. Asserts the rendered chip class rather than the tone lookup, so it fails on real breakage instead of restating the map. Co-Authored-By: Claude --- .../sponsors/reports/__tests__/StatusPill.test.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/components/sponsors/reports/__tests__/StatusPill.test.js b/src/components/sponsors/reports/__tests__/StatusPill.test.js index 25634455b..d68499b7b 100644 --- a/src/components/sponsors/reports/__tests__/StatusPill.test.js +++ b/src/components/sponsors/reports/__tests__/StatusPill.test.js @@ -18,4 +18,11 @@ describe("StatusPill", () => { renderWithRedux(); expect(screen.getByText("pending")).toBeInTheDocument(); }); + + it("colors a partially canceled pill as a warning, not muted", () => { + renderWithRedux(); + expect(screen.getByText("Partial").closest(".MuiChip-root")).toHaveClass( + "MuiChip-colorWarning" + ); + }); });