diff --git a/src/actions/__tests__/sponsor-reports-actions.test.js b/src/actions/__tests__/sponsor-reports-actions.test.js
index 0b5493eba..bb0678ccd 100644
--- a/src/actions/__tests__/sponsor-reports-actions.test.js
+++ b/src/actions/__tests__/sponsor-reports-actions.test.js
@@ -1233,7 +1233,7 @@ describe("sponsor-reports-actions", () => {
const rowC = { item_code: "C1", quantity: 3 };
const page1Summary = { total_orders: 11 };
- it("records the active filters on REQUEST and hits the lines endpoint without paymentMethod", async () => {
+ it("records the active filters on REQUEST and hits the lines endpoint with paymentMethod", async () => {
const store = mockStore(MOCK_STATE);
const filters = { sponsorIds: [17], paymentMethod: "Card" };
await store.dispatch(getPurchaseDetailsByItemRows(filters));
@@ -1250,11 +1250,12 @@ describe("sponsor-reports-actions", () => {
expect(capturedUrl).toContain(
"/summits/42/reports/purchase-details/lines"
);
- // buildPurchaseLinesQuery drops paymentMethod (order-level attribute).
+ // buildPurchaseLinesQuery now carries paymentMethod through (declared on
+ // the lines filter set via the parent hop).
const filterClauses = capturedParams["filter[]"] || [];
expect(
filterClauses.some((c) => String(c).includes("payment_method"))
- ).toBe(false);
+ ).toBe(true);
});
it("bulk-loads all pages (page 1, then the rest in parallel) into one atomic RECEIVE_PURCHASE_DETAILS_BY_ITEM_ROWS", async () => {
diff --git a/src/actions/__tests__/sponsor-reports-query.test.js b/src/actions/__tests__/sponsor-reports-query.test.js
index 0831d5af7..678dae15d 100644
--- a/src/actions/__tests__/sponsor-reports-query.test.js
+++ b/src/actions/__tests__/sponsor-reports-query.test.js
@@ -51,6 +51,42 @@ describe("buildReportQuery", () => {
});
});
+ it("sets include_cancelled from showCanceled with no status clause", () => {
+ // The point of the separate axis: `status==Canceled` resolves to the parent
+ // order's status at line grain, so deriving include_cancelled from the status
+ // value alone can never surface a canceled line inside a Paid order. The
+ // checkbox must open the flag WITHOUT emitting a status filter.
+ expect(buildReportQuery({ showCanceled: true })).toStrictEqual({
+ include_cancelled: "true"
+ });
+ });
+
+ it("keeps the status coupling when only the status is Canceled", () => {
+ // Guard against "fixing" this by replacing the coupling with the checkbox:
+ // the orders endpoint excludes Canceled by default, so a Canceled selection
+ // without the flag returns zero rows and the dropdown option goes dead.
+ expect(buildReportQuery({ status: "Canceled" }).include_cancelled).toBe(
+ "true"
+ );
+ });
+
+ it("emits include_cancelled once when both axes are on", () => {
+ expect(
+ buildReportQuery({ status: "Canceled", showCanceled: true })
+ ).toStrictEqual({
+ "filter[]": ["status==Canceled"],
+ include_cancelled: "true"
+ });
+ });
+
+ it("omits include_cancelled when showCanceled is false", () => {
+ expect(
+ buildReportQuery({ status: "Paid", showCanceled: false })
+ ).toStrictEqual({
+ "filter[]": ["status==Paid"]
+ });
+ });
+
it("passes through search/order/pagination", () => {
expect(
buildReportQuery({
@@ -132,13 +168,19 @@ describe("buildPurchaseLinesQuery", () => {
expect(q).not.toHaveProperty("order");
});
- it("drops payment_method — the lines filter set does not support it", () => {
- const q = buildPurchaseLinesQuery(
- { status: "Paid", paymentMethod: "Invoice" },
- { page: 1, perPage: 10 }
- );
- expect(q["filter[]"]).toEqual(["status==Paid"]);
- expect(q["filter[]"]).not.toContain("payment_method==Invoice");
+ it("carries paymentMethod through to the lines query", () => {
+ const query = buildPurchaseLinesQuery({ paymentMethod: "invoice" });
+ expect(query["filter[]"]).toContain("payment_method==invoice");
+ });
+
+ it("builds the same payment_method clause at both grains", () => {
+ // the regression: applying a filter and switching grain must not change the set
+ const orders = buildPurchaseQuery({ paymentMethod: "invoice" })["filter[]"];
+ const lines = buildPurchaseLinesQuery({ paymentMethod: "invoice" })[
+ "filter[]"
+ ];
+ const clause = (f) => f.find((c) => c.startsWith("payment_method"));
+ expect(clause(lines)).toEqual(clause(orders));
});
});
diff --git a/src/actions/sponsor-reports-actions.js b/src/actions/sponsor-reports-actions.js
index 3fa35df8d..757365e05 100644
--- a/src/actions/sponsor-reports-actions.js
+++ b/src/actions/sponsor-reports-actions.js
@@ -180,6 +180,7 @@ export const buildReportQuery = (filters = {}) => {
mediaRequestType,
dateFrom,
dateTo,
+ showCanceled,
search,
order,
page,
@@ -222,8 +223,14 @@ export const buildReportQuery = (filters = {}) => {
if (order) query.order = order;
if (page != null) query.page = page;
if (perPage != null) query.per_page = perPage;
- // Canceled is excluded server-side by default.
- if (status === "Canceled") query.include_cancelled = "true";
+ // Canceled is excluded server-side by default, on TWO independent axes: the
+ // order's status, and a line's own canceled_at (a soft-canceled line leaves its
+ // parent order Paid). Selecting the Canceled status must keep implying this, or
+ // that option would return nothing at order grain -- but it can't be the only
+ // way in, because `status==Canceled` resolves to `purchase__status` at line
+ // grain, which excludes the soft-canceled-line rows it is meant to reveal.
+ // Hence the OR: the checkbox opens the second axis without closing the first.
+ if (status === "Canceled" || showCanceled) query.include_cancelled = "true";
return query;
};
@@ -259,13 +266,11 @@ export const buildPurchaseQuery = (
// Lines grain: same date expansion, NO order (manifest relies on backend default
// ordering). Used by the on-screen lines fetch AND exportPurchaseDetailsLinesCsv.
-// The lines endpoint's filter set omits payment_method (it's an order-level
-// attribute), so drop it here rather than emit a clause BaseFilter silently
-// ignores. The UI also hides the Payment Method control in the Line Items view.
-export const buildPurchaseLinesQuery = (
- { paymentMethod: _paymentMethod, ...filters } = {},
- { page, perPage } = {}
-) => buildReportQuery({ ...expandDates(filters), page, perPage });
+// payment_method IS supported at line grain (declared on PurchaseLineDetailsFilter
+// via the parent hop). It used to be dropped here, which is why applying it and
+// switching grain silently widened the result set instead of narrowing it.
+export const buildPurchaseLinesQuery = (filters = {}, { page, perPage } = {}) =>
+ buildReportQuery({ ...expandDates(filters), page, perPage });
export const getPurchaseDetailsReport =
(filters = {}, pagination = {}) =>
@@ -516,7 +521,7 @@ export const getPurchaseDetailsByItemRows =
guardedDispatch(
createAction(REQUEST_PURCHASE_DETAILS_BY_ITEM)({ filters })
);
- // Lines-grain query (drops paymentMethod); one arg → no page/per_page emitted.
+ // Lines-grain query (carries paymentMethod through); one arg → no page/per_page emitted.
const baseQuery = buildPurchaseLinesQuery(filters);
const url = `${base(currentSummit.id)}/purchase-details/lines`;
const fetchPage = (page) =>
diff --git a/src/components/sponsors/reports/ByItemView.js b/src/components/sponsors/reports/ByItemView.js
index 1f644ef72..910901f3d 100644
--- a/src/components/sponsors/reports/ByItemView.js
+++ b/src/components/sponsors/reports/ByItemView.js
@@ -116,10 +116,14 @@ const accumulateRow = (itemMap, row) => {
sponsorBooth: row.sponsor_booth ?? null,
checkoutAt: row.purchase?.checkout_at ?? null,
rateName: row.rate_name ?? "",
- status: row.purchase?.status ?? "",
+ // 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,
- isCanceled: Boolean(row.is_canceled)
+ isCanceled: Boolean(row.is_canceled),
+ // line-grain freshness (decision 1): the contributor row IS a line
+ syncedAt: row.synced_at ?? null,
+ sourceUpdatedAt: row.source_updated_at ?? null
});
};
@@ -250,7 +254,9 @@ const CONTRIB_HEADERS = [
{ key: "col_used_rate" },
{ key: "col_status" },
{ key: "col_quantity", align: "right" },
- { key: "col_line_total", align: "right" }
+ { key: "col_line_total", align: "right" },
+ { key: "col_synced_at" },
+ { key: "col_source_updated" }
];
// One expansion key per (sponsor, item) so the same item code under two
@@ -468,6 +474,12 @@ const ItemTable = ({
? "—"
: currencyAmountFromCents(c.lineTotalCents)}
+
+ {formatCheckoutTime(c.syncedAt)}
+
+
+ {formatCheckoutTime(c.sourceUpdatedAt)}
+
))}
diff --git a/src/components/sponsors/reports/LinesManifestView.js b/src/components/sponsors/reports/LinesManifestView.js
index 04a875e34..a4d061a6e 100644
--- a/src/components/sponsors/reports/LinesManifestView.js
+++ b/src/components/sponsors/reports/LinesManifestView.js
@@ -83,10 +83,16 @@ const bucketLinesBySponsor = (rows = []) => {
groups.push({
sponsorId: id,
sponsorName: row.sponsor?.name ?? "",
- lines: []
+ lines: [],
+ // Canceled lines still RENDER (struck through) but must not be counted —
+ // the chip means live lines, matching the By Item units chip on the same
+ // screen, which already excludes them.
+ liveLineCount: 0
});
}
- groups[indexByKey.get(key)].lines.push(row);
+ const group = groups[indexByKey.get(key)];
+ group.lines.push(row);
+ if (!row.is_canceled) group.liveLineCount += 1;
});
return groups;
};
@@ -102,7 +108,9 @@ const HEADERS = [
{ key: "col_quantity", align: "right" },
{ key: "col_used_rate" },
{ key: "col_status" },
- { key: "col_line_total", align: "right" }
+ { key: "col_line_total", align: "right" },
+ { key: "col_synced_at" },
+ { key: "col_source_updated" }
];
const LinesManifestView = ({
@@ -126,7 +134,7 @@ const LinesManifestView = ({
size="small"
sx={{ ml: 1.5 }}
label={T.translate("sponsor_reports_page.lines_count", {
- count: group.lines.length
+ count: group.liveLineCount
})}
/>
@@ -178,16 +186,35 @@ const LinesManifestView = ({
{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)}
+
+ {formatCheckoutTime(line.synced_at)}
+
+
+ {formatCheckoutTime(line.source_updated_at)}
+
))}
diff --git a/src/components/sponsors/reports/OrdersTable.js b/src/components/sponsors/reports/OrdersTable.js
index bb0d6b08b..6cacd9ded 100644
--- a/src/components/sponsors/reports/OrdersTable.js
+++ b/src/components/sponsors/reports/OrdersTable.js
@@ -135,6 +135,30 @@ const columns = [
header: T.translate("sponsor_reports_page.col_sponsor_note"),
sortable: false // not a backend ordering field
// No render — MuiTable fallback reads row["sponsor_note"] directly.
+ },
+ {
+ columnKey: "refunded_amount",
+ header: T.translate("sponsor_reports_page.col_refunded"),
+ sortable: false, // not a backend ordering field
+ align: "right",
+ // Refunds never touch purchase status — a fully refunded order reads Paid
+ // forever, so this column is the only on-screen signal. The CSV already had it.
+ render: (row) =>
+ row.refunded_amount == null
+ ? "—"
+ : currencyAmountFromCents(row.refunded_amount)
+ },
+ {
+ columnKey: "synced_at",
+ header: T.translate("sponsor_reports_page.col_synced_at"),
+ sortable: false, // not a backend ordering field
+ render: (row) => formatCheckoutTime(row.synced_at)
+ },
+ {
+ columnKey: "source_updated_at",
+ header: T.translate("sponsor_reports_page.col_source_updated"),
+ sortable: false, // not a backend ordering field
+ render: (row) => formatCheckoutTime(row.source_updated_at)
}
];
diff --git a/src/components/sponsors/reports/__tests__/ByItemView.test.js b/src/components/sponsors/reports/__tests__/ByItemView.test.js
index 666df5c5b..3a1dc2ffe 100644
--- a/src/components/sponsors/reports/__tests__/ByItemView.test.js
+++ b/src/components/sponsors/reports/__tests__/ByItemView.test.js
@@ -1,5 +1,6 @@
import "@testing-library/jest-dom";
import React from "react";
+import moment from "moment-timezone";
import { render, screen, fireEvent, within } from "@testing-library/react";
import ByItemView, {
groupLinesByItem,
@@ -146,6 +147,8 @@ describe("groupLinesBySponsorItem", () => {
});
it("passes canceled lines through as contributors with isCanceled", () => {
+ // status carries the LINE's own state, resolved here rather than in the
+ // render: the fixture's parent order is Paid while this line is canceled.
const rows = [line({ is_canceled: true })];
const [group] = groupLinesBySponsorItem(rows);
const [contrib] = group.items[0].contributors;
@@ -157,13 +160,24 @@ describe("groupLinesBySponsorItem", () => {
sponsorBooth: null,
checkoutAt: 1735000000,
rateName: "Early",
- status: "Paid",
+ status: "Canceled",
qty: 2,
lineTotalCents: 100000,
- isCanceled: true
+ isCanceled: true,
+ syncedAt: null,
+ sourceUpdatedAt: null
});
});
+ it("carries line-grain freshness into the contributor", () => {
+ const [g] = groupLinesBySponsorItem([
+ line({ synced_at: 1755561600, source_updated_at: 1755558000 })
+ ]);
+ const [c] = g.items[0].contributors;
+ expect(c.syncedAt).toBe(1755561600);
+ expect(c.sourceUpdatedAt).toBe(1755558000);
+ });
+
it("EXCLUDES canceled lines from qty/money/purchasedCount/Σqty but keeps them as contributors", () => {
const rows = [
line({
@@ -415,6 +429,52 @@ describe("ByItemView", () => {
expect(screen.queryByText("OCP-1")).not.toBeInTheDocument();
});
+ it("renders the contributor status it is given", () => {
+ // Distinct epochs (not the same value for both fields) so a swap or a
+ // missing cell can't hide behind a shared string.
+ const synced = 1755561600; // 2025-08-19
+ const sourceUpdated = 1755648000; // 2025-08-20
+ renderView({
+ groups: [
+ group({
+ items: [
+ item({
+ contributors: [
+ {
+ sponsorName: "FNTECH",
+ number: "OCP-1",
+ formCode: "AV",
+ addOnName: null,
+ checkoutAt: null,
+ rateName: "Early",
+ // groupLinesByItem resolves the line's own state before this
+ // point (covered above), so the cell renders what it is handed.
+ status: "Canceled",
+ qty: 1,
+ lineTotalCents: 1000,
+ isCanceled: true,
+ syncedAt: synced,
+ sourceUpdatedAt: sourceUpdated
+ }
+ ]
+ })
+ ]
+ })
+ ]
+ });
+ fireEvent.click(screen.getByText("AV1")); // expand the item
+ expect(screen.getByText("Canceled")).toBeInTheDocument();
+ const syncedText = moment.unix(synced).utc().format("YYYY-MM-DD h:mm A");
+ const sourceUpdatedText = moment
+ .unix(sourceUpdated)
+ .utc()
+ .format("YYYY-MM-DD h:mm A");
+ const row = screen.getByText("OCP-1").closest("tr");
+ const cells = within(row).getAllByRole("cell");
+ expect(cells[cells.length - 2]).toHaveTextContent(syncedText);
+ expect(cells[cells.length - 1]).toHaveTextContent(sourceUpdatedText);
+ });
+
it("expand button toggles the drill-down and reflects aria-expanded", () => {
renderView();
const toggle = screen.getByRole("button", {
@@ -484,6 +544,23 @@ describe("ByItemView", () => {
});
});
+describe("byitem_sponsor_items_chip copy", () => {
+ it("does not describe canceled orders as purchased", () => {
+ // The chip read "13 of 13 items purchased" over a set of deliberately-filtered
+ // CANCELED orders. Under Jest the chip renders as its i18n key, so the
+ // wording is only checkable in the catalog — and only by exact equality:
+ // the template's own {purchased} interpolation key contains the substring
+ // "purchased", so a substring/regex guard either self-matches the
+ // placeholder or is too loose to pin the actual sentence (e.g. it would
+ // let "{purchased} of {items} purchased" or "...items shipped" through).
+ // eslint-disable-next-line global-require
+ const en = require("../../../../i18n/en.json");
+ expect(en.sponsor_reports_page.byitem_sponsor_items_chip).toBe(
+ "{purchased} of {items} items with purchases"
+ );
+ });
+});
+
describe("sortItems", () => {
const row = (over) => item(over);
@@ -842,6 +919,31 @@ describe("ByItemView all-sponsors layout", () => {
);
expect(onLayoutChange).not.toHaveBeenCalled();
});
+
+ // A positional last-two-cells check (as used elsewhere for the freshness
+ // columns) only proves a cell isn't missing — it says nothing about the
+ // HEADER row, which is a separate array (CONTRIB_HEADERS, optionally
+ // prepended with col_sponsor). If the two desync by one, every column right
+ // of the break silently misaligns and stays green. Assert exact
+ // header/cell cardinality on the NESTED contributor table specifically —
+ // the outer item table has its own separate ITEM_HEADERS and expansion
+ // colSpan that must not be counted here.
+ it.each([
+ ["by-sponsor", 10, () => renderView()],
+ ["all-sponsors", 11, () => renderAll()]
+ ])(
+ "the %s drill-down has %i headers matching that many cells per row",
+ (_name, count, mount) => {
+ mount();
+ fireEvent.click(screen.getByText("AV1"));
+ const contribTable = screen.getAllByRole("table").at(-1);
+ expect(within(contribTable).getAllByRole("columnheader")).toHaveLength(
+ count
+ );
+ const row = within(contribTable).getByText("OCP-1").closest("tr");
+ expect(within(row).getAllByRole("cell")).toHaveLength(count);
+ }
+ );
});
describe("Destination booth fallback (By Item drill-down)", () => {
diff --git a/src/components/sponsors/reports/__tests__/LinesManifestView.test.js b/src/components/sponsors/reports/__tests__/LinesManifestView.test.js
index 81ded0b53..4eb0e2569 100644
--- a/src/components/sponsors/reports/__tests__/LinesManifestView.test.js
+++ b/src/components/sponsors/reports/__tests__/LinesManifestView.test.js
@@ -1,5 +1,6 @@
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";
@@ -56,6 +57,67 @@ describe("LinesManifestView", () => {
expect(row).toHaveAttribute("data-canceled", "true");
});
+ it("renders the LINE's own state, not the parent order's status", () => {
+ // line() defaults to a Paid parent: the exact trap. A soft-canceled line leaves
+ // its order Paid, so rendering purchase.status printed "Paid" on a dead row.
+ renderView({ rows: [line({ is_canceled: true })] });
+ expect(
+ screen.getByText("sponsor_reports_page.status_canceled")
+ ).toBeInTheDocument();
+ expect(screen.queryByText("Paid")).not.toBeInTheDocument();
+ });
+
+ it("renders both freshness timestamps, in the row's final two cells", () => {
+ // formatCheckoutTime is moment.unix(v).utc().format("YYYY-MM-DD h:mm A").
+ // Distinct values (not the same epoch for both fields) so a swap or a
+ // missing cell can't hide behind a shared string.
+ const synced = 1755561600; // 2025-08-19
+ const sourceUpdated = 1755648000; // 2025-08-20
+ renderView({
+ rows: [line({ synced_at: synced, source_updated_at: sourceUpdated })]
+ });
+ const syncedText = moment.unix(synced).utc().format("YYYY-MM-DD h:mm A");
+ const sourceUpdatedText = moment
+ .unix(sourceUpdated)
+ .utc()
+ .format("YYYY-MM-DD h:mm A");
+ const row = screen.getByText("AV1").closest("tr");
+ const cells = within(row).getAllByRole("cell");
+ expect(cells[cells.length - 2]).toHaveTextContent(syncedText);
+ expect(cells[cells.length - 1]).toHaveTextContent(sourceUpdatedText);
+ });
+
+ it("counts only live lines per sponsor group", () => {
+ // Canceled lines still RENDER (struck through); the chip means LIVE lines,
+ // matching the By Item units chip on the same screen. The module's local
+ // i18n mock interpolates {count}, so the chip text carries the number.
+ renderView({
+ rows: [
+ line({ is_canceled: false }),
+ line({ is_canceled: true, item_code: "AV2" })
+ ]
+ });
+ // header row + the 2 line rows, exactly — proves neither line was dropped.
+ expect(screen.getAllByRole("row")).toHaveLength(3);
+ const canceledRow = screen.getByText("AV2").closest("tr");
+ expect(canceledRow).toHaveAttribute("data-canceled", "true");
+ expect(
+ screen.getByText("sponsor_reports_page.lines_count:1")
+ ).toBeInTheDocument();
+ });
+
+ // A positional last-two-cells check (as used above for the freshness
+ // columns) only proves a cell isn't missing — it says nothing about the
+ // HEADER row, which is a separate array (HEADERS). If the two desync by
+ // one, every column right of the break silently misaligns and stays
+ // green. Assert exact header/cell cardinality directly.
+ it("has exactly 13 column headers matching 13 cells per row", () => {
+ renderView();
+ expect(screen.getAllByRole("columnheader")).toHaveLength(13);
+ const row = screen.getByText("AV1").closest("tr");
+ expect(within(row).getAllByRole("cell")).toHaveLength(13);
+ });
+
// Sponsor bucketing (formerly bucketLinesBySponsor, now a private helper).
describe("sponsor bucketing", () => {
it("groups by sponsor.id preserving first-seen order", () => {
@@ -138,3 +200,17 @@ describe("Destination booth fallback", () => {
).toBeInTheDocument();
});
});
+
+describe("lines_count copy", () => {
+ it("says the count is of LIVE lines, not all rendered lines", () => {
+ // The chip is fed liveLineCount, but canceled lines still RENDER, so a group
+ // showing two rows reports one. The copy has to say which number it is.
+ // This module's i18n mock renders the chip from the KEY and COUNT only, so
+ // the count assertion above is value-independent: the English could regress
+ // to "{count} lines" and every DOM test would still pass. Pin it in the
+ // catalog by exact equality, mirroring the By Item chip's copy test.
+ // eslint-disable-next-line global-require
+ const en = require("../../../../i18n/en.json");
+ expect(en.sponsor_reports_page.lines_count).toBe("{count} live lines");
+ });
+});
diff --git a/src/components/sponsors/reports/__tests__/OrdersTable.test.js b/src/components/sponsors/reports/__tests__/OrdersTable.test.js
index 42851715d..94c089dcc 100644
--- a/src/components/sponsors/reports/__tests__/OrdersTable.test.js
+++ b/src/components/sponsors/reports/__tests__/OrdersTable.test.js
@@ -1,7 +1,8 @@
// src/components/sponsors/reports/__tests__/OrdersTable.test.js
import "@testing-library/jest-dom";
import React from "react";
-import { render, screen, fireEvent } from "@testing-library/react";
+import moment from "moment-timezone";
+import { render, screen, fireEvent, within } from "@testing-library/react";
import OrdersTable from "../OrdersTable";
// MuiTable uses i18n-react internally (no-items message, pagination labels).
@@ -70,12 +71,67 @@ describe("OrdersTable finance columns", () => {
payment_method: null,
invoice_reference: null,
invoice_sub_status: null,
- invoice_due_date: null
+ invoice_due_date: null,
+ refunded_amount: null
};
renderTable([nullRow]);
- // invoice_total (25000) still renders $250.00; the four finance cells render —.
- // Exactly four em-dash cells appear (one per null finance column).
- expect(screen.getAllByText("—")).toHaveLength(4);
+ // invoice_total (25000) still renders $250.00; the five finance cells render —.
+ // Exactly five em-dash cells appear (one per null finance column, now
+ // including refunded_amount added in Task 10).
+ expect(screen.getAllByText("—")).toHaveLength(5);
+ });
+});
+
+describe("OrdersTable refund and freshness columns", () => {
+ it("surfaces refunded_amount, which never changes purchase status", () => {
+ // a refund leaves the order Paid forever; refunded_amount is the only signal
+ renderTable([{ ...sampleRow, refunded_amount: 100 }]);
+ expect(screen.getByText("$1.00")).toBeInTheDocument();
+ });
+
+ it("renders both freshness timestamps, in the row's final two cells", () => {
+ // formatCheckoutTime is moment.unix(v).utc().format("YYYY-MM-DD h:mm A").
+ // Distinct values (not the same epoch for both fields) so a swap or a
+ // missing cell can't hide behind a shared string.
+ const synced = 1755561600; // 2025-08-19
+ const sourceUpdated = 1755648000; // 2025-08-20
+ renderTable([
+ { ...sampleRow, synced_at: synced, source_updated_at: sourceUpdated }
+ ]);
+ const syncedText = moment.unix(synced).utc().format("YYYY-MM-DD h:mm A");
+ const sourceUpdatedText = moment
+ .unix(sourceUpdated)
+ .utc()
+ .format("YYYY-MM-DD h:mm A");
+ const row = screen.getByText(sampleRow.purchase_number).closest("tr");
+ const cells = within(row).getAllByRole("cell");
+ expect(cells[cells.length - 2]).toHaveTextContent(syncedText);
+ expect(cells[cells.length - 1]).toHaveTextContent(sourceUpdatedText);
+ });
+
+ it("appends the new columns without disturbing the existing header order", () => {
+ renderTable();
+ const headers = screen
+ .getAllByRole("columnheader")
+ .map((h) => h.textContent);
+ // Full 14-key sequence, not just the appended tail — a reorder or swap
+ // among the original 11 (e.g. col_order/col_sponsor) must fail this too.
+ expect(headers).toEqual([
+ "sponsor_reports_page.col_order",
+ "sponsor_reports_page.col_sponsor",
+ "sponsor_reports_page.col_checkout_time",
+ "sponsor_reports_page.col_type",
+ "sponsor_reports_page.col_status",
+ "sponsor_reports_page.col_invoice_total",
+ "sponsor_reports_page.col_payment_method",
+ "sponsor_reports_page.col_invoice_reference",
+ "sponsor_reports_page.col_invoice_sub_status",
+ "sponsor_reports_page.col_invoice_due_date",
+ "sponsor_reports_page.col_sponsor_note",
+ "sponsor_reports_page.col_refunded",
+ "sponsor_reports_page.col_synced_at",
+ "sponsor_reports_page.col_source_updated"
+ ]);
});
});
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 58eb3a2c0..23606a892 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -4336,10 +4336,13 @@
"download_csv": "Download CSV",
"total_orders": "Total Sales",
"total_items": "Total Items",
- "total_paid": "Total Paid",
+ "total_paid": "Total Paid (before refunds)",
"total_pending": "Total Pending",
"total_refunded": "Total Refunded",
"filter_status": "Purchase Status",
+ "filter_status_info": "Canceled orders are excluded unless you select Canceled or check Show canceled",
+ "filter_status_info_lines": "Canceled orders and individually canceled line items are excluded unless you select Canceled or check Show canceled",
+ "filter_show_canceled": "Show canceled",
"filter_form": "Type",
"filter_payment_method": "Payment Method",
"any": "Any",
@@ -4360,6 +4363,7 @@
"status_completed": "Completed",
"status_in_progress": "In Progress",
"status_pending": "Pending",
+ "status_canceled": "Canceled",
"filter_asset_status": "Status",
"group_by": "Group by",
"report_filters": "Report Filters",
@@ -4372,7 +4376,7 @@
"view_orders": "Orders",
"view_line_items": "Line Items",
"view_by_item": "By Item",
- "lines_count": "{count} lines",
+ "lines_count": "{count} live lines",
"destination_booth_fallback": "Booth",
"col_checkout_time": "Checkout Time",
"col_invoice_total": "Invoice Total",
@@ -4394,9 +4398,12 @@
"col_invoice_sub_status": "Invoice Status",
"col_invoice_due_date": "Invoice Due",
"col_line_total": "Line Total",
+ "col_refunded": "Refunded",
+ "col_synced_at": "Synced",
+ "col_source_updated": "Source Updated",
"byitem_col_orders": "Orders",
"byitem_col_total": "Total",
- "byitem_sponsor_items_chip": "{purchased} of {items} items purchased",
+ "byitem_sponsor_items_chip": "{purchased} of {items} items with purchases",
"byitem_sum_qty": "{qty} units",
"byitem_contributing_orders": "Contributing orders",
"byitem_sponsors_per_page": "Sponsors per page:",
diff --git a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js
index 4a7b0f662..8690bc635 100644
--- a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js
+++ b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js
@@ -373,6 +373,17 @@ describe("PurchaseDetailsReportPage", () => {
);
});
+ it("keeps the Payment Method control visible on the line views", async () => {
+ renderPage();
+ await act(async () => {
+ fireEvent.click(screen.getByText("sponsor_reports_page.view_line_items"));
+ });
+ // aria-label is T.translate(...), which renders as the raw KEY under Jest
+ expect(
+ screen.getByLabelText("sponsor_reports_page.filter_payment_method")
+ ).toBeInTheDocument();
+ });
+
it("Line Items CSV export passes the lines slice filters to exportPurchaseDetailsLinesCsv", async () => {
// Export reads the applied filters from the lines slice (recorded on REQUEST
// in production); seed them directly since the mock store is inert.
@@ -575,7 +586,7 @@ describe("PurchaseDetailsReportPage", () => {
});
});
- it("hides the Payment Method filter in the By Item view (lines filter set omits it)", async () => {
+ it("keeps the Payment Method filter visible in the By Item view (lines endpoint honors it via the parent hop)", async () => {
const history = createMemoryHistory({ initialEntries: [PAGE_URL] });
renderWithRedux(
@@ -587,9 +598,10 @@ describe("PurchaseDetailsReportPage", () => {
await act(async () => {
fireEvent.click(screen.getByText("sponsor_reports_page.view_by_item"));
});
+ // aria-label is T.translate(...), which renders as the raw KEY under Jest
expect(
- document.querySelector("#pd-filter-payment-method")
- ).not.toBeInTheDocument();
+ screen.getByLabelText("sponsor_reports_page.filter_payment_method")
+ ).toBeInTheDocument();
});
describe("validation error — snackbar hook", () => {
@@ -599,4 +611,112 @@ describe("PurchaseDetailsReportPage", () => {
expect(mockErrorMessage).toHaveBeenCalledWith("Too many filters");
});
});
+
+ describe("self-describing chrome — gross Total Paid label + canceled default note", () => {
+ it("labels Total Paid as gross", () => {
+ // Decision 7 keeps the figure gross; the label is the whole fix. Under Jest the
+ // tile renders "sponsor_reports_page.total_paid" (the i18n mock is the identity
+ // function), so the catalog value is the only place this copy is checkable —
+ // and exact equality (not a substring match) is the only assertion that can
+ // both catch a wrong replacement string and notice "Total Paid" going missing.
+ const en = require("../../../../../i18n/en.json");
+ expect(en.sponsor_reports_page.total_paid).toBe(
+ "Total Paid (before refunds)"
+ );
+ });
+
+ it("still renders the gross figure unchanged beside Total Refunded", () => {
+ // currencyAmountFromCents (openstack-uicore-foundation) has no thousands
+ // separator — verified directly against the installed lib, so this is
+ // "$13297.00", not "$13,297.00". Out of scope to change here; decision 7
+ // only requires the gross figure itself stay untouched.
+ renderPage({ total_paid: 1329700, total_refunded: 100 });
+ expect(screen.getByText("$13297.00")).toBeInTheDocument();
+ expect(screen.getByText("$1.00")).toBeInTheDocument();
+ });
+
+ it("states the silent canceled default on the status control's info icon", () => {
+ // Carried as the repo's hover-info idiom rather than helper text under the
+ // control: helper text made this filter twice the height of its siblings,
+ // and the center-aligned filter row then rendered it out of line with
+ // them. jsdom has no layout, so the alignment itself is not assertable —
+ // what IS assertable is that the note is no longer a block under the
+ // control, which is the thing that changed the height.
+ renderPage();
+ const icon = document.querySelector("i.fa-info-circle");
+ expect(icon).toBeInTheDocument();
+ expect(icon).toHaveAttribute(
+ "title",
+ "sponsor_reports_page.filter_status_info"
+ );
+ });
+
+ it("offers Show canceled as its own control, not derived from the status", () => {
+ // The status dropdown cannot reach the line-level axis: `status==Canceled`
+ // resolves to the PARENT order's status server-side, so it can never
+ // surface a canceled line inside a Paid order. The checkbox is the only
+ // way to ask for those rows.
+ renderPage();
+ expect(
+ screen.getByLabelText("sponsor_reports_page.filter_show_canceled")
+ ).toBeInTheDocument();
+ });
+
+ it("applies showCanceled with no status selected", async () => {
+ renderPage();
+ await act(async () => {});
+ getPurchaseDetailsReport.mockClear();
+
+ await act(async () => {
+ fireEvent.click(
+ screen.getByLabelText("sponsor_reports_page.filter_show_canceled")
+ );
+ });
+ await act(async () => {
+ fireEvent.click(screen.getByText("sponsor_reports_page.apply"));
+ });
+
+ const [[calledFilters]] = getPurchaseDetailsReport.mock.calls;
+ expect(calledFilters).toMatchObject({ showCanceled: true });
+ // No status clause rides along — that is the whole point of the axis.
+ expect(calledFilters.status).toBeUndefined();
+ });
+
+ it("describes the extra line-level axis on the line views only", async () => {
+ // The order grain hides canceled ORDERS; the line grains additionally hide
+ // individually canceled LINES. One string for both is wrong on one of them.
+ renderPage();
+ await act(async () => {});
+ expect(document.querySelector("i.fa-info-circle")).toHaveAttribute(
+ "title",
+ "sponsor_reports_page.filter_status_info"
+ );
+
+ await act(async () => {
+ fireEvent.click(
+ screen.getByText("sponsor_reports_page.view_line_items")
+ );
+ });
+ expect(document.querySelector("i.fa-info-circle")).toHaveAttribute(
+ "title",
+ "sponsor_reports_page.filter_status_info_lines"
+ );
+ });
+
+ it("associates that note with the status control for screen readers", () => {
+ // Rendering the note NEAR the control is not the same as announcing it
+ // WITH the control. The note is a sibling, not a FormControl child, so
+ // MUI cannot wire this up itself — assert the aria-describedby actually
+ // resolves to the note's id rather than trusting visual adjacency.
+ renderPage();
+ const control = screen.getByLabelText(
+ "sponsor_reports_page.filter_status"
+ );
+ const noteId = control.getAttribute("aria-describedby");
+ expect(noteId).toBeTruthy();
+ expect(document.getElementById(noteId)).toHaveTextContent(
+ "sponsor_reports_page.filter_status_info"
+ );
+ });
+ });
});
diff --git a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js
index aa6df674c..c173561bc 100644
--- a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js
+++ b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js
@@ -16,7 +16,8 @@ import { connect } from "react-redux";
import { withRouter } from "react-router-dom";
import moment from "moment-timezone";
import T from "i18n-react/dist/i18n-react";
-import { Alert, Box, Button } from "@mui/material";
+import { Alert, Box, Button, Checkbox, FormControlLabel } from "@mui/material";
+import { visuallyHidden } from "@mui/utils";
import PrintIcon from "@mui/icons-material/Print";
import DownloadIcon from "@mui/icons-material/Download";
import ShoppingCartOutlinedIcon from "@mui/icons-material/ShoppingCartOutlined";
@@ -56,6 +57,17 @@ import { DEFAULT_CURRENT_PAGE } from "../../../../utils/constants";
// backend query contract is untouched.
const REPORT_DATE_TZ = "UTC";
const REPORT_DATE_FORMAT = "YYYY-MM-DD";
+// Shared by the visually-hidden copy of the Purchase Status note and the
+// aria-describedby that points at it, so the two cannot drift apart.
+const STATUS_NOTE_ID = "pd-filter-status-note";
+// The canceled-default note is grain-aware: the order grain hides canceled
+// ORDERS, while the line grains additionally hide individually canceled LINES
+// (a separate axis — a soft-canceled line sits inside a Paid order). One string
+// for both would be wrong on one of them.
+const statusNoteKey = (view) =>
+ view === "orders"
+ ? "sponsor_reports_page.filter_status_info"
+ : "sponsor_reports_page.filter_status_info_lines";
// The uicore picker emits moment(0) (epoch) on a CLEAR, not null — treat that
// sentinel as "no date" so clearing removes the filter instead of sending
// date>=1970-01-01. This matches the house filter convention (`.unix() || null`
@@ -340,7 +352,9 @@ const PurchaseDetailsReportPage = ({
const extraControls = (draft, update) => (
<>
-
+ {/* Icon is aria-hidden, so the same copy lives in a visually-hidden span
+ the Select points at via aria-describedby. */}
+
update({ status: e.target.value || undefined })}
+ aria-describedby={STATUS_NOTE_ID}
+ />
+
+
+ {T.translate(statusNoteKey(view))}
+
+ {/* Separate axis: status==Canceled resolves to the PARENT order's status at
+ line grain, so it can never reveal a canceled line inside a Paid order. */}
+
+ update({ showCanceled: e.target.checked || undefined })
+ }
+ />
+ }
+ label={T.translate("sponsor_reports_page.filter_show_canceled")}
+ />
update({ formCode: e.target.value || undefined })}
/>
- {/* Payment Method is an order-level attribute; only the orders endpoint
- filters on it (the lines filter set omits payment_method), so surface
- it in the orders view only — mirrors search being view-specific. */}
- {view === "orders" && (
-
-
+
- update({ paymentMethod: e.target.value || undefined })
- }
- />
-
- )}
+ )
+ }}
+ value={draft.paymentMethod || ""}
+ options={paymentMethodSelectOptions}
+ onChange={(e) =>
+ update({ paymentMethod: e.target.value || undefined })
+ }
+ />
+
{/* Date pickers keep draft.dateFrom/dateTo as "YYYY-MM-DD"; buildQuery
expands them to UTC ISO datetimes. Convert the picker's moment back to
"YYYY-MM-DD" at the update boundary so the query contract is unchanged. */}