diff --git a/src/components/sponsors/reports/ByItemView.js b/src/components/sponsors/reports/ByItemView.js
index 910901f3d..5815cedff 100644
--- a/src/components/sponsors/reports/ByItemView.js
+++ b/src/components/sponsors/reports/ByItemView.js
@@ -38,9 +38,15 @@ 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, PER_PAGE_OPTIONS } from "./LinesManifestView";
+import {
+ Destination,
+ LineStatusPill,
+ PER_PAGE_OPTIONS,
+ lineStatus,
+ liveQuantity,
+ liveAmountCents
+} from "./LinesManifestView";
import { formatCheckoutTime } from "./OrdersTable";
import {
DEFAULT_CURRENT_PAGE,
@@ -85,16 +91,11 @@ 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.
if (!row.is_canceled) {
- item.qty += row.quantity ?? 0;
- // 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;
+ item.qty += liveQuantity(row);
+ const liveCents = liveAmountCents(row);
+ if (liveCents != null) {
+ item.totalCents = (item.totalCents ?? 0) + liveCents;
}
const purchaseId = row.purchase?.id ?? null;
if (purchaseId != null) {
@@ -116,11 +117,15 @@ 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,
+ 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,
+ 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 +468,13 @@ const ItemTable = ({
{c.rateName}
-
+
+
+
+ {c.isPartiallyCanceled
+ ? `${c.qty} / ${c.orderedQty}`
+ : c.qty}
- {c.qty}
{c.lineTotalCents == null
? "—"
diff --git a/src/components/sponsors/reports/LinesManifestView.js b/src/components/sponsors/reports/LinesManifestView.js
index a4d061a6e..475b97127 100644
--- a/src/components/sponsors/reports/LinesManifestView.js
+++ b/src/components/sponsors/reports/LinesManifestView.js
@@ -49,6 +49,20 @@ export const PER_PAGE_OPTIONS = [
MAX_PER_PAGE
];
+// 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));
+};
+
+// 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;
+ 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"
@@ -113,6 +127,38 @@ const HEADERS = [
{ key: "col_source_updated" }
];
+// 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"
+};
+
+// 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];
+ return (
+
+ );
+};
+
const LinesManifestView = ({
rows = [],
total = 0,
@@ -183,31 +229,17 @@ const LinesManifestView = ({
{formatCheckoutTime(line.purchase?.checkout_at)}
{line.notes}
- {line.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
- ? "—"
- : currencyAmountFromCents(line.line_total)}
+ {line.line_total == null ? "—" : lineTotalLabel(line)}
{formatCheckoutTime(line.synced_at)}
diff --git a/src/components/sponsors/reports/StatusPill.js b/src/components/sponsors/reports/StatusPill.js
index 9353de00d..d676b5419 100644
--- a/src/components/sponsors/reports/StatusPill.js
+++ b/src/components/sponsors/reports/StatusPill.js
@@ -10,7 +10,8 @@ const TONE_BY_STATUS = {
in_progress: "info",
not_applicable: "default",
canceled: "default",
- cancelled: "default"
+ cancelled: "default",
+ partially_canceled: "warning"
};
export const statusTone = (status) =>
diff --git a/src/components/sponsors/reports/__tests__/ByItemView.test.js b/src/components/sponsors/reports/__tests__/ByItemView.test.js
index 3a1dc2ffe..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
});
@@ -162,8 +165,10 @@ describe("groupLinesBySponsorItem", () => {
rateName: "Early",
status: "Canceled",
qty: 2,
+ orderedQty: 2,
lineTotalCents: 100000,
isCanceled: true,
+ isPartiallyCanceled: false,
syncedAt: null,
sourceUpdatedAt: null
});
@@ -253,6 +258,73 @@ 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("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("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);
+ 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 and its contributor alike", () => {
+ const [group] = groupLinesBySponsorItem([
+ partial({ line_total: 100000, canceled_amount: 40000 })
+ ]);
+ expect(group.items[0].totalCents).toBe(60000);
+ 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("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 = [
@@ -356,8 +428,10 @@ const item = (over = {}) => ({
rateName: "Early",
status: "Paid",
qty: 3,
+ orderedQty: 3,
lineTotalCents: 150000,
- isCanceled: false
+ isCanceled: false,
+ isPartiallyCanceled: false
},
{
sponsorName: "Nvidia",
@@ -366,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
@@ -463,7 +539,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)
@@ -475,6 +553,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"));
+ 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", {
diff --git a/src/components/sponsors/reports/__tests__/LinesManifestView.test.js b/src/components/sponsors/reports/__tests__/LinesManifestView.test.js
index 4eb0e2569..eb755993d 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) =>
@@ -29,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
});
@@ -214,3 +220,118 @@ describe("lines_count copy", () => {
expect(en.sponsor_reports_page.lines_count).toBe("{count} live lines");
});
});
+
+describe("liveQuantity", () => {
+ 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("clamps at 0 when a legacy line cancels more than it ordered", () => {
+ // legacy shape: purchases-api defaults quantity to 1, the sync to 0
+ 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("subtracts the frozen cancelled amount on a partially cancelled line", () => {
+ // the source's own sum, not a proration of line_total
+ expect(
+ liveAmountCents(
+ line({
+ line_total: 100000,
+ canceled_amount: 40000,
+ quantity: 5,
+ canceled_quantity: 2,
+ is_partially_canceled: true
+ })
+ )
+ ).toBe(60000);
+ });
+
+ it("clamps at 0 when the cancelled amount exceeds the line total", () => {
+ expect(
+ liveAmountCents(line({ line_total: 1000, canceled_amount: 5000 }))
+ ).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("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(
+ screen.getByText("sponsor_reports_page.status_partially_canceled")
+ ).toBeInTheDocument();
+ expect(screen.queryByText("Paid")).not.toBeInTheDocument();
+ });
+
+ it("does not strike through a partially cancelled row", () => {
+ 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", () => {
+ 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
+ });
+ renderView({ rows: [full], total: 1 });
+ const row = screen.getByText("AV1").closest("tr");
+ expect(row).toHaveAttribute("data-canceled", "true");
+ expect(
+ within(row).getByText("sponsor_reports_page.status_canceled")
+ ).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 });
+ 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 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"
+ );
+ });
});
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",