Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
3e3f98a
fix(sponsor-reports): keep Payment Method applied across grain switches
caseylocker Aug 20, 2026
d545ce5
docs(sponsor-reports): fix second stale paymentMethod-drop comment
caseylocker Aug 20, 2026
552b469
fix(sponsor-reports): show the line's own state in the manifest
caseylocker Aug 20, 2026
dbc5521
test(sponsor-reports): strengthen manifest freshness and live-count a…
caseylocker Aug 20, 2026
5046275
fix(sponsor-reports): By Item drill-down shows line state and freshness
caseylocker Aug 20, 2026
d7885ea
test(sponsor-reports): pin By Item chip copy and drill-down column pa…
caseylocker Aug 20, 2026
1d95ea3
feat(sponsor-reports): surface refunded amount and freshness on Orders
caseylocker Aug 20, 2026
3afa8b0
fix(sponsor-reports): right-align refunded amount, pin full Orders he…
caseylocker Aug 20, 2026
c8562c9
fix(sponsor-reports): label the gross total and the canceled default
caseylocker Aug 20, 2026
b9c1c39
fix(sponsor-reports): flow the status helper text in-page instead of …
caseylocker Aug 20, 2026
5eb2fa9
fix(sponsor-reports): reword lines chip, add header/cell guard, fix t…
caseylocker Aug 20, 2026
e34b5a6
test(sponsor-reports): pin the lines_count copy in the catalog
caseylocker Aug 20, 2026
0fab48b
fix(sponsor-reports): announce the canceled-default note with its con…
caseylocker Aug 20, 2026
e077974
fix(sponsor-reports): carry the canceled-default note as a hover info…
caseylocker Aug 20, 2026
c8bc53f
feat(sponsor-reports): give canceled rows their own filter axis
caseylocker Aug 21, 2026
d0b0ec6
refactor(sponsor-reports): resolve line status in the mapper, trim co…
caseylocker Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/actions/__tests__/sponsor-reports-actions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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 () => {
Expand Down
56 changes: 49 additions & 7 deletions src/actions/__tests__/sponsor-reports-query.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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));
});
});

Expand Down
25 changes: 15 additions & 10 deletions src/actions/sponsor-reports-actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ export const buildReportQuery = (filters = {}) => {
mediaRequestType,
dateFrom,
dateTo,
showCanceled,
search,
order,
page,
Expand Down Expand Up @@ -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;
};
Expand Down Expand Up @@ -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 = {}) =>
Expand Down Expand Up @@ -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) =>
Expand Down
18 changes: 15 additions & 3 deletions src/components/sponsors/reports/ByItemView.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
};

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -468,6 +474,12 @@ const ItemTable = ({
? "—"
: currencyAmountFromCents(c.lineTotalCents)}
</TableCell>
<TableCell>
{formatCheckoutTime(c.syncedAt)}
</TableCell>
<TableCell>
{formatCheckoutTime(c.sourceUpdatedAt)}
</TableCell>
</TableRow>
))}
</TableBody>
Expand Down
43 changes: 35 additions & 8 deletions src/components/sponsors/reports/LinesManifestView.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand All @@ -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 = ({
Expand All @@ -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
})}
/>
</AccordionSummary>
Expand Down Expand Up @@ -178,16 +186,35 @@ const LinesManifestView = ({
<TableCell align="right">{line.quantity}</TableCell>
<TableCell>{line.rate_name}</TableCell>
<TableCell>
<StatusPill
status={line.purchase?.status}
label={line.purchase?.status}
/>
{/* 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 ? (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@caseylocker This branch cannot render once sponsor-reports-api#40 is deployed: the only
line rows that reach it are ones whose parent order is already Canceled, where both
branches print the same label with the same tone.

include_cancelled is written in exactly one place — sponsor-reports-actions.js:226,
if (status === "Canceled") — so asking for canceled rows also emits status==Canceled,
which PurchaseLineDetailsFilter resolves to purchase__status
(purchase_line_details_filter.py:20). A soft-canceled line on a Paid order — the exact
row this change targets — is excluded by default, then excluded again by the status clause
the moment you try to un-hide it. statusTone("Canceled") returns default either way, so
in every reachable state this renders identically to the previous line.purchase?.status.

The API deliberately made this a separate axis, and it is already exercised there:
test_include_cancelled_true_restores_line_level_canceled_items
(apps/reports/tests/test_purchase_details_lines_endpoints.py:123) sends
?include_cancelled=true with no status filter and gets AV3 back — is_canceled: true
inside a Paid order. The fixture comment states it directly: "NO status selection could
ever remove it -- line-level cancellation is a separate axis". The frontend is the only
side coupling the two.

Suggested fix: give include_cancelled its own control rather than deriving it from the
status value — a "Show canceled" checkbox in FilterBar, carried as its own filter key and
emitted independently in buildReportQuery. That is the shape this repo already uses for
showArchived (.claude/rules/summit-admin-archive-pattern.md). The current coupling stays
correct at order grain; only the line grain needs the extra axis. The same fix makes the
By Item contributor branch at ByItemView.js:467 reachable, and lets the Purchase Status
note describe both axes truthfully on the line views.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in c8bc53f.

You're right that the branch was unreachable. include_cancelled had exactly one write site, sponsor-reports-actions.js:226, and buildReportQuery is the single builder for the details, lines, By Item and both CSV paths, so the coupling closed the axis everywhere at once. Every row that could reach the pill already had purchase.status === "Canceled", and since StatusPill maps both spellings of canceled to tone default and status_canceled is literally "Canceled", the two arms rendered an identical chip. Same for ByItemView.js:467.

Took the separate control. One change to the shape you suggested: the status coupling stays, as an OR rather than a replacement.

if (status === "Canceled" || showCanceled) query.include_cancelled = "true";

Emitting include_cancelled only from the checkbox would regress the Orders view. purchase_details_report_view.py:44 excludes status='Canceled' by default, so a Canceled selection without the flag returns zero rows and the dropdown option goes dead. There's a test pinning that direction specifically, so a later cleanup can't quietly drop it.

The checkbox is on every grain, not the line views only. Filters are carried across a view switch, so a control that appears and vanishes would leave a live filter behind an invisible control, which is the same silent-filter class this report set out to fix.

Also updated the Purchase Status note per your last point: it's now grain-aware, since the line grains hide a second kind of row the order grain has no concept of.

One correction on the citation: .claude/rules/summit-admin-archive-pattern.md isn't in this repo, there's no .claude/ directory here. The doc lives in the fn-skills vault at patterns/show-admin/summit-admin-archive-pattern.md. The precedent itself is real in-repo, showArchived in form-template-actions.js, show-pages-actions.js and others, and the shape matched, so the checkbox follows it.

Full suite 1528 passing.

<StatusPill
status="Canceled"
label={T.translate(
"sponsor_reports_page.status_canceled"
)}
/>
) : (
<StatusPill
status={line.purchase?.status}
label={line.purchase?.status}
/>
)}
</TableCell>
<TableCell align="right">
{line.line_total == null
? "—"
: currencyAmountFromCents(line.line_total)}
</TableCell>
<TableCell>
{formatCheckoutTime(line.synced_at)}
</TableCell>
<TableCell>
{formatCheckoutTime(line.source_updated_at)}
</TableCell>
</TableRow>
))}
</TableBody>
Expand Down
24 changes: 24 additions & 0 deletions src/components/sponsors/reports/OrdersTable.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
];

Expand Down
Loading
Loading