diff --git a/src/components/mui/SponsorOrderGrid/__tests__/SponsorOrderGrid.test.js b/src/components/mui/SponsorOrderGrid/__tests__/SponsorOrderGrid.test.js
index b4422a86..e6ec00ad 100644
--- a/src/components/mui/SponsorOrderGrid/__tests__/SponsorOrderGrid.test.js
+++ b/src/components/mui/SponsorOrderGrid/__tests__/SponsorOrderGrid.test.js
@@ -17,7 +17,9 @@ jest.mock("i18n-react/dist/i18n-react", () => ({
}));
jest.mock("../../../../utils/money", () => ({
- currencyAmountFromCents: (amount) => `$${(amount / 100).toFixed(2)}`
+ currencyAmountFromCents: (amount) => `$${(amount / 100).toFixed(2)}`,
+ formatDiscount: (amount, type) =>
+ type === "Rate" ? `${amount / 100}%` : `$${(amount / 100).toFixed(2)}`
}));
jest.mock("../../../../utils/constants", () => ({
@@ -170,3 +172,95 @@ describe("SponsorOrderGrid", () => {
expect(screen.queryByText("sponsor_order_grid.reconciliation")).not.toBeInTheDocument();
});
});
+
+// ─── Ledger-driven fixes ────────────────────────────────────────────────────
+// Regression coverage for the three ways this grid used to diverge from the
+// invoice PDF before both started consuming utils/order-ledger.
+
+describe("SponsorOrderGrid — ledger-driven fixes", () => {
+ test("falls back to item.title for the details column when item.type is null (matches the invoice PDF)", () => {
+ const order = {
+ forms: [makeForm({ items: [makeItem({ type: null, title: "Booth Space" })] })],
+ total: 0
+ };
+ render();
+ expect(screen.getByText(/Booth Space/)).toBeInTheDocument();
+ });
+
+ test("renders an item whose quantity is missing (undefined), defaulting to 1", () => {
+ const order = {
+ forms: [makeForm({ items: [makeItem({ quantity: undefined })] })],
+ total: 0
+ };
+ render();
+ expect(screen.getAllByText("$100.00").length).toBeGreaterThan(0);
+ });
+
+ test("gives distinct row keys to multiple fees carrying line_id but no id (purchases-api v2 shape)", () => {
+ const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
+ const order = {
+ forms: [makeForm({ items: [] })],
+ fees: [
+ { line_id: 7001, title: "Processing Fee", amount: 500 },
+ { line_id: 7002, title: "Late Fee", amount: 250 }
+ ],
+ total: 0
+ };
+ render();
+
+ expect(screen.getByText("Processing Fee")).toBeInTheDocument();
+ expect(screen.getByText("Late Fee")).toBeInTheDocument();
+ expect(consoleErrorSpy.mock.calls.join(" ")).not.toMatch(/same key/);
+ consoleErrorSpy.mockRestore();
+ });
+
+ test("renders no discount row when discount_in_cents is 0", () => {
+ const order = {
+ forms: [
+ makeForm({
+ discount_in_cents: 0,
+ discount_amount: 1000,
+ discount_type: "Rate",
+ items: [makeItem()]
+ })
+ ],
+ total: 0
+ };
+ render();
+ expect(screen.queryByText("mui_table.dis")).not.toBeInTheDocument();
+ });
+
+ test("formats the discount description from discount_amount/discount_type when the form carries no pre-formatted discount string (raw API shape)", () => {
+ const order = {
+ forms: [
+ makeForm({
+ discount: null,
+ discount_in_cents: 5000,
+ discount_amount: 1000,
+ discount_type: "Rate",
+ items: [makeItem()]
+ })
+ ],
+ total: 0
+ };
+ render();
+ expect(screen.getByText("10%")).toBeInTheDocument();
+ });
+
+ test("uses the pre-formatted discount string when the form is already normalized", () => {
+ const order = {
+ forms: [
+ makeForm({
+ discount: "10% off",
+ discount_in_cents: 5000,
+ discount_amount: 1000,
+ discount_type: "Rate",
+ items: [makeItem()]
+ })
+ ],
+ total: 0
+ };
+ render();
+ expect(screen.getByText("10% off")).toBeInTheDocument();
+ });
+});
diff --git a/src/components/mui/SponsorOrderGrid/index.js b/src/components/mui/SponsorOrderGrid/index.js
index 0e0878f5..4d299719 100644
--- a/src/components/mui/SponsorOrderGrid/index.js
+++ b/src/components/mui/SponsorOrderGrid/index.js
@@ -28,7 +28,8 @@ import DeleteIcon from "@mui/icons-material/Delete";
import {DiscountRow, FeeRow, NotesRow, PaymentRow, RefundRow, TotalRow} from "../table/extra-rows";
import {SPONSOR_ORDER_GRID_ITEM_TYPES} from "../../../utils/constants";
import InfoNote from "../InfoNote";
-import {currencyAmountFromCents} from "../../../utils/money";
+import {currencyAmountFromCents, formatDiscount} from "../../../utils/money";
+import {buildOrderLedger} from "../../../utils/order-ledger";
import TransactionType from "./components/TransactionType";
import {formatEpoch} from "../../../utils/methods";
import TotalFooter from "./components/TotalFooter";
@@ -36,36 +37,30 @@ import ReconciliationBox from "./components/ReconciliationBox";
import CancelledItems from "./components/CancelledItems";
import BalanceValue from "./components/BalanceValue";
-const mapOrderData = (forms) => {
- if (!forms) return [];
+// Maps a ledger "item" entry to the row shape rendered by the columns below
+// AND handed as-is to onCancelForm/onUndoCancelForm — that object shape is a
+// public contract for consumers (e.g. sponsor-services), so it must keep the
+// same fields mapOrderData used to produce.
+const toItemRow = (entry, itemIndexByForm) => {
+ const {form, item, quantity, cancelled} = entry;
+ const idx = itemIndexByForm.get(form.id) ?? 0;
+ itemIndexByForm.set(form.id, idx + 1);
- return forms.map((form) => ({
- ...form,
- items: form.items
- .filter((it) => it.quantity)
- .map((it, i) => {
- const amount = currencyAmountFromCents(it.amount || 0);
- const itemId = it.type?.id || `${form.id}-${i}`;
- const cancelled = !!it.canceled_by_id;
- const type = cancelled ? SPONSOR_ORDER_GRID_ITEM_TYPES.CANCELLED : SPONSOR_ORDER_GRID_ITEM_TYPES.CHARGE;
-
- return {
- id: itemId,
- formCode: form.code,
- itemName: it.type?.name,
- itemCode: it.type?.code,
- quantity: it.quantity,
- type,
- amount,
- amountValue: it.amount,
- cancelled,
- cancelledBy: T.translate("sponsor_order_grid.cancelled_by", {
- user: it.canceled_by_full_name,
- date: formatEpoch(it.canceled_at)
- }),
- };
- })
- }));
+ return {
+ id: item.type?.id || `${form.id}-${idx}`,
+ formCode: form.code,
+ itemName: item.type?.name || item.title,
+ itemCode: item.type?.code,
+ quantity,
+ type: cancelled ? SPONSOR_ORDER_GRID_ITEM_TYPES.CANCELLED : SPONSOR_ORDER_GRID_ITEM_TYPES.CHARGE,
+ amount: currencyAmountFromCents(item.amount || 0),
+ amountValue: item.amount,
+ cancelled,
+ cancelledBy: T.translate("sponsor_order_grid.cancelled_by", {
+ user: item.canceled_by_full_name,
+ date: formatEpoch(item.canceled_at)
+ })
+ };
};
const SponsorOrderGrid = ({
@@ -79,26 +74,24 @@ const SponsorOrderGrid = ({
const {
forms = [],
- fees = [],
- payments = [],
- refunds = [],
- notes = [],
total = 0,
retained = 0,
credited_to_payment_method: credited = 0,
cancelled_total: cancelledTotal = 0,
refunds_total: refundsTotal = 0
} = order || {};
- const data = mapOrderData(forms);
- const cancelledItems = data.flatMap((form) => form.items.filter((it) => it.cancelled));
+ const ledger = buildOrderLedger(order);
+ const hasNoRows = ledger.length === 0;
+ const itemIndexByForm = new Map();
+ const itemRowsByKey = new Map();
+ ledger
+ .filter((entry) => entry.type === "item")
+ .forEach((entry) => {
+ itemRowsByKey.set(entry.rowKey, toItemRow(entry, itemIndexByForm));
+ });
+ const cancelledItems = [...itemRowsByKey.values()].filter((row) => row.cancelled);
const canCancel = onCancelForm && onUndoCancelForm;
const trailingCols = canCancel ? 1 : 0;
- let balance = 0;
-
- const calculateBalance = (rowAmount, op = 1) => {
- balance = balance + (rowAmount * op);
- return balance;
- }
const columns = [
{
@@ -136,11 +129,6 @@ const SponsorOrderGrid = ({
const colCount = columns.length + 1 + trailingCols; // 1 for balance, 1 for action col
- const paymentsAndRefundsOrdered = [
- ...payments?.map((payment) => ({...payment, type: "payment"})) || [],
- ...refunds?.map((refund) => ({...refund, type: "refund"})) || []
- ].sort((a, b) => a.created - b.created);
-
return (
@@ -186,128 +174,134 @@ const SponsorOrderGrid = ({
- {data.map((form) => {
- const rows = form.items.map((row) => (
-
- {(() => {
- const cols = columns.map((col) => (
-
- {col.render ? (
- col.render(row)
- ) : (
- row[col.columnKey]
- )}
-
- ));
+ {ledger.map((entry) => {
+ switch (entry.type) {
+ case "item": {
+ const row = itemRowsByKey.get(entry.rowKey);
+ return (
+
+ {(() => {
+ const cols = columns.map((col) => (
+
+ {col.render ? (
+ col.render(row)
+ ) : (
+ row[col.columnKey]
+ )}
+
+ ));
- // BALANCE COLUMN
- cols.push(
-
-
-
- )
+ // BALANCE COLUMN
+ cols.push(
+
+
+
+ )
- // ACTION COLUMN
- if (canCancel) {
- cols.push(
-
- {row.cancelled ? (
- onUndoCancelForm(row)}>
-
-
- ) : (
- onCancelForm(row)}>
-
-
- )}
-
- )
- }
+ // ACTION COLUMN
+ if (canCancel) {
+ cols.push(
+
+ {row.cancelled ? (
+ onUndoCancelForm(row)}>
+
+
+ ) : (
+ onCancelForm(row)}>
+
+
+ )}
+
+ )
+ }
- return cols;
- })()}
+ return cols;
+ })()}
-
- ));
+
+ );
+ }
- const discountCents = form.discount_in_cents ?? 0;
- rows.push(
-
- );
+ case "discount":
+ return (
+
+ );
- return rows;
- })}
+ case "fee":
+ return (
+
+ );
- {fees && fees.map((fee) => (
-
- ))}
+ case "payment":
+ return (
+
+ );
+
+ case "refund":
+ return (
+
+ );
+
+ case "note":
+ return (
+
+ );
- {paymentsAndRefundsOrdered.map((item) => {
- if (item.type === "payment") {
- return (
-
- )
- } else if (item.type === "refund") {
- return (
-
- )
+ default:
+ return null;
}
})}
- {notes && notes.map((note) => (
-
- ))}
-
{/* When using reconciliation, we show the total at the end */}
{!withReconciliation &&
}
- {data.length === 0 && (
+ {hasNoRows && (
{T.translate("mui_table.no_items")}
diff --git a/src/components/order-invoice-pdf/__tests__/ledger-consistency.test.js b/src/components/order-invoice-pdf/__tests__/ledger-consistency.test.js
new file mode 100644
index 00000000..5398cfae
--- /dev/null
+++ b/src/components/order-invoice-pdf/__tests__/ledger-consistency.test.js
@@ -0,0 +1,87 @@
+/**
+ * Copyright 2026 OpenStack Foundation
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * */
+
+// One raw v2 order, run through both real consumers (the invoice PDF's
+// buildRows and SponsorOrderGrid's render), asserting they land on the same
+// row order/keys and the same running balance. This is the regression net
+// against the two copies silently drifting apart again.
+
+jest.mock("i18n-react/dist/i18n-react", () => ({
+ __esModule: true,
+ default: {
+ translate: (key, tokens) => {
+ let text = key;
+ if (tokens) {
+ Object.entries(tokens).forEach(([token, value]) => {
+ text = text.replace(new RegExp(`{${token}}`, "g"), value);
+ });
+ }
+ return text;
+ }
+ }
+}));
+
+jest.mock("@react-pdf/renderer", () => ({
+ Document: () => null,
+ Page: () => null,
+ Text: () => null,
+ View: () => null,
+ Image: () => null,
+ Svg: () => null,
+ Path: () => null,
+ StyleSheet: { create: (s) => s },
+ Font: { register: () => {}, getRegisteredFontFamilies: () => [] },
+ pdf: jest.fn()
+}));
+
+import React from "react";
+import { render } from "@testing-library/react";
+import { buildOrderLedger } from "../../../utils/order-ledger";
+import { formatBalance } from "../../../utils/money";
+import { buildRows } from "../helpers";
+import SponsorOrderGrid from "../../mui/SponsorOrderGrid";
+import purchaseV2Fixture from "./fixtures/purchase-v2.json";
+
+describe("order ledger consistency — PDF buildRows and SponsorOrderGrid on the same fixture", () => {
+ const ledger = buildOrderLedger(purchaseV2Fixture);
+
+ it("has a non-trivial fixture covering every entry type", () => {
+ expect(new Set(ledger.map((e) => e.type))).toEqual(
+ new Set(["item", "discount", "fee", "payment", "refund", "note"])
+ );
+ });
+
+ it("buildRows (PDF) preserves the ledger's row order, keys and running balance", () => {
+ const pdfRows = buildRows(purchaseV2Fixture);
+ expect(pdfRows).toHaveLength(ledger.length);
+
+ ledger.forEach((entry, i) => {
+ expect(pdfRows[i].rowKey).toBe(entry.rowKey);
+ expect(pdfRows[i].type).toBe(entry.type);
+ if (entry.type !== "note") {
+ expect(pdfRows[i].balanceCents).toBe(entry.balanceCents);
+ }
+ });
+ });
+
+ it("SponsorOrderGrid renders the same running balance, in the same order, as the ledger", () => {
+ const { container } = render();
+ const rows = container.querySelectorAll("tbody tr");
+
+ ledger.forEach((entry, i) => {
+ if (entry.type === "note") return;
+ const balanceCell = rows[i].querySelector("td:last-child");
+ expect(balanceCell.textContent).toBe(formatBalance(entry.balanceCents));
+ });
+ });
+});
diff --git a/src/components/order-invoice-pdf/__tests__/order-invoice-pdf.test.js b/src/components/order-invoice-pdf/__tests__/order-invoice-pdf.test.js
index 3337829c..e85e7f8b 100644
--- a/src/components/order-invoice-pdf/__tests__/order-invoice-pdf.test.js
+++ b/src/components/order-invoice-pdf/__tests__/order-invoice-pdf.test.js
@@ -107,8 +107,6 @@ jest.mock("@react-pdf/renderer", () => {
// A field this component reads that the fixture doesn't carry (or carries under a different key)
// now surfaces as a failing assertion instead of a silently blank cell in the PDF.
-const MOCK_SUMMIT = { time_zone_id: "UTC" };
-
const baseForm = purchaseV2Fixture.forms[0];
const baseItem = baseForm.items[0]; // not cancelled
const baseCancelledItem = baseForm.items[1]; // cancelled
@@ -155,13 +153,19 @@ const makeRenderSummit = (overrides = {}) => ({
...overrides
});
+// buildRows is now a thin presentational mapper over utils/order-ledger —
+// derivation rules (filtering, ordering, sign conventions, row keys) are
+// unit-tested against raw cents in utils/__tests__/order-ledger.test.js.
+// What's left here is the mapping from ledger entries to presentational
+// fields: i18n labels, currency/date formatting, and description composition.
+
// ─── Empty / missing collections ─────────────────────────────────────────────
describe("buildRows — empty / missing collections", () => {
it("returns [] without throwing for any empty input", () => {
- expect(buildRows({}, MOCK_SUMMIT)).toEqual([]);
+ expect(buildRows({})).toEqual([]);
expect(
- buildRows({ forms: [], fees: [], payments: [], refunds: [] }, MOCK_SUMMIT)
+ buildRows({ forms: [], fees: [], payments: [], refunds: [] })
).toEqual([]);
});
});
@@ -170,10 +174,7 @@ describe("buildRows — empty / missing collections", () => {
describe("buildRows — item rows", () => {
it("emits item rows directly with no group row", () => {
- const rows = buildRows(
- { forms: [makeForm({ items: [makeItem()] })] },
- MOCK_SUMMIT
- );
+ const rows = buildRows({ forms: [makeForm({ items: [makeItem()] })] });
expect(rows[0].type).toBe("item");
expect(rows.every((r) => r.type !== "group")).toBe(true);
});
@@ -183,9 +184,7 @@ describe("buildRows — item rows", () => {
code: "ABC-1",
items: [makeItem({ amount: 5000, quantity: 3 })]
});
- const itemRow = buildRows({ forms: [form] }, MOCK_SUMMIT).find(
- (r) => r.type === "item"
- );
+ const itemRow = buildRows({ forms: [form] }).find((r) => r.type === "item");
expect(itemRow.price).toBe("$50.00");
expect(itemRow.qty).toBe("3");
expect(itemRow.code).toBe("ABC-1");
@@ -194,41 +193,23 @@ describe("buildRows — item rows", () => {
it("prefers item.type.name over item.title for description", () => {
const withType = makeItem(); // base item already carries type.name = "Platinum Sponsor"
const withoutType = makeItem({ type: null }); // falls back to title = "Logo Placement"
- const rows = buildRows(
- {
- forms: [
- makeForm({ id: 1, items: [withType] }),
- makeForm({ id: 2, items: [withoutType] })
- ]
- },
- MOCK_SUMMIT
- );
+ const rows = buildRows({
+ forms: [
+ makeForm({ id: 1, items: [withType] }),
+ makeForm({ id: 2, items: [withoutType] })
+ ]
+ });
const itemRows = rows.filter((r) => r.type === "item");
expect(itemRows[0].description).toBe("Platinum Sponsor");
expect(itemRows[1].description).toBe("Logo Placement");
});
-
- it("excludes items with quantity 0", () => {
- const rows = buildRows(
- {
- forms: [
- makeForm({ discount_in_cents: 0, items: [makeItem({ quantity: 0 })] })
- ]
- },
- MOCK_SUMMIT
- );
- expect(rows).toHaveLength(0);
- });
});
// ─── Cancelled items (per-item, not per-form) ─────────────────────────────────
describe("buildRows — cancelled items", () => {
it("sets cancelled: true and populates cancelledBy when item.canceled_by_id is set", () => {
- const rows = buildRows(
- { forms: [makeForm({ items: [makeCancelledItem()] })] },
- MOCK_SUMMIT
- );
+ const rows = buildRows({ forms: [makeForm({ items: [makeCancelledItem()] })] });
expect(rows[0].cancelled).toBe(true);
expect(rows[0].cancelledBy).toMatch(/Admin User/);
});
@@ -244,79 +225,34 @@ describe("buildRows — cancelled items", () => {
discount_in_cents: 0,
items: [makeItem()]
});
- const rows = buildRows({ forms: [withNull, withAbsent] }, MOCK_SUMMIT);
+ const rows = buildRows({ forms: [withNull, withAbsent] });
rows.forEach((r) => {
expect(r.cancelled).toBe(false);
expect(r.cancelledBy).toBe("");
});
});
-
- it("cancelled items still accumulate into the running balance", () => {
- const normalItem = makeItem({ amount: 8000 });
- const cancelledItem = makeCancelledItem({ amount: 10000 });
- const rows = buildRows(
- {
- forms: [
- makeForm({ id: 1, discount_in_cents: 0, items: [normalItem] }),
- makeForm({ id: 2, discount_in_cents: 0, items: [cancelledItem] })
- ]
- },
- MOCK_SUMMIT
- );
- const normal = rows.find((r) => !r.cancelled);
- const cancelled = rows.find((r) => r.cancelled);
- expect(normal.balanceCents).toBe(8000);
- expect(cancelled.balanceCents).toBe(18000); // 8000 + 10000
- });
-
- it("a form-level canceled_by_id does not mark items as cancelled", () => {
- const form = makeForm({ canceled_by_id: 99, items: [makeItem()] });
- const rows = buildRows({ forms: [form] }, MOCK_SUMMIT);
- expect(rows[0].cancelled).toBe(false);
- });
});
// ─── Fee rows ─────────────────────────────────────────────────────────────────
describe("buildRows — fee rows", () => {
it("emits code PAYFEE with formatted amount", () => {
- const feeRow = buildRows(
- { fees: [makeFee({ title: "Processing Fee", amount: 200 })] },
- MOCK_SUMMIT
- ).find((r) => r.type === "fee");
+ const feeRow = buildRows({
+ fees: [makeFee({ title: "Processing Fee", amount: 200 })]
+ }).find((r) => r.type === "fee");
expect(feeRow.code).toBe("PAYFEE");
expect(feeRow.price).toBe("$2.00");
});
-
- it("gives distinct row keys to multiple fees, none of which carry an `id` field", () => {
- // Real purchases-api v2 fees only ever carry line_id/position/title/amount (see fixture) —
- // no `id`. Two fees on the same order must not collide on rowKey.
- expect(baseFee.id).toBeUndefined();
- const rows = buildRows(
- { fees: [purchaseV2Fixture.fees[0], purchaseV2Fixture.fees[1]] },
- MOCK_SUMMIT
- ).filter((r) => r.type === "fee");
- expect(rows).toHaveLength(2);
- expect(new Set(rows.map((r) => r.rowKey)).size).toBe(2);
- });
});
// ─── Discount rows ────────────────────────────────────────────────────────────
describe("buildRows — discount rows", () => {
- it("emits no discount rows when discount_in_cents is 0", () => {
- const rows = buildRows(
- { forms: [makeForm({ discount_in_cents: 0 })] },
- MOCK_SUMMIT
- );
- expect(rows.filter((r) => r.type === "discount")).toHaveLength(0);
- });
-
it("emits one discount row with code DIS and formatted amount, describing a Rate discount from raw discount_amount/discount_type", () => {
// Base form already carries discount_in_cents/discount_amount/discount_type
// as raw fields — never a pre-formatted `discount` string (the API doesn't send one).
const form = makeForm();
- const discountRows = buildRows({ forms: [form] }, MOCK_SUMMIT).filter(
+ const discountRows = buildRows({ forms: [form] }).filter(
(r) => r.type === "discount"
);
expect(discountRows).toHaveLength(1);
@@ -332,7 +268,7 @@ describe("buildRows — discount rows", () => {
discount_amount: 500,
discount_type: "Amount"
});
- const discountRows = buildRows({ forms: [form] }, MOCK_SUMMIT).filter(
+ const discountRows = buildRows({ forms: [form] }).filter(
(r) => r.type === "discount"
);
expect(discountRows[0].description).toBe("$5.00");
@@ -343,17 +279,15 @@ describe("buildRows — discount rows", () => {
describe("buildRows — payment rows", () => {
it("sets description to 'Paid via ' and defaults method to card", () => {
- const withMethod = buildRows(
- { payments: [makePayment({ method: "wire" })] },
- MOCK_SUMMIT
- ).find((r) => r.type === "payment");
+ const withMethod = buildRows({
+ payments: [makePayment({ method: "wire" })]
+ }).find((r) => r.type === "payment");
expect(withMethod.price).toBe("$600.00");
expect(withMethod.description).toBe("Paid via wire");
- const withoutMethod = buildRows(
- { payments: [makePayment({ id: 2, method: undefined })] },
- MOCK_SUMMIT
- ).find((r) => r.type === "payment");
+ const withoutMethod = buildRows({
+ payments: [makePayment({ id: 2, method: undefined })]
+ }).find((r) => r.type === "payment");
expect(withoutMethod.description).toBe("Paid via card");
});
});
@@ -362,22 +296,18 @@ describe("buildRows — payment rows", () => {
describe("buildRows — refund rows", () => {
it("maps reason to description and status to subDescription, with defaults when absent", () => {
- const withFields = buildRows(
- { refunds: [makeRefund()] },
- MOCK_SUMMIT
- ).find((r) => r.type === "refund");
+ const withFields = buildRows({ refunds: [makeRefund()] }).find(
+ (r) => r.type === "refund"
+ );
expect(withFields.price).toBe("$30.00");
expect(withFields.description).toBe("duplicate charge");
expect(withFields.subDescription).toBe("approved");
- const withDefaults = buildRows(
- {
- refunds: [
- makeRefund({ id: 2, reason: undefined, status: undefined, amount: 1000 })
- ]
- },
- MOCK_SUMMIT
- ).find((r) => r.type === "refund");
+ const withDefaults = buildRows({
+ refunds: [
+ makeRefund({ id: 2, reason: undefined, status: undefined, amount: 1000 })
+ ]
+ }).find((r) => r.type === "refund");
expect(withDefaults.description).toBe("Refund");
expect(withDefaults.subDescription).toBe("");
});
@@ -387,37 +317,17 @@ describe("buildRows — refund rows", () => {
describe("buildRows — note rows", () => {
it("emits type 'note' with content, defaulting to empty string when absent", () => {
- const withContent = buildRows({ notes: [makeNote()] }, MOCK_SUMMIT);
+ const withContent = buildRows({ notes: [makeNote()] });
expect(withContent[0].type).toBe("note");
expect(withContent[0].content).toBe("Call client to confirm shipping address");
- const withoutContent = buildRows(
- { notes: [makeNote({ id: 2, content: undefined })] },
- MOCK_SUMMIT
- );
+ const withoutContent = buildRows({
+ notes: [makeNote({ id: 2, content: undefined })]
+ });
expect(withoutContent[0].content).toBe("");
});
});
-// ─── Balance accumulation ─────────────────────────────────────────────────────
-
-describe("buildRows — balance accumulation", () => {
- it("interleaves payments and refunds by created date and updates balance correctly", () => {
- const rows = buildRows(
- {
- payments: [makePayment({ amount: 10000, created: 2 })],
- refunds: [makeRefund({ amount: 3000, created: 1 })]
- },
- MOCK_SUMMIT
- );
- // refund first (created: 1), then payment (created: 2)
- expect(rows[0].type).toBe("refund");
- expect(rows[0].balanceCents).toBe(3000);
- expect(rows[1].type).toBe("payment");
- expect(rows[1].balanceCents).toBe(-7000); // 3000 - 10000
- });
-});
-
// ─── getThemeFontFamily ─────────────────────────────────────────────────────
//
// Exercised directly (not just via a full OrderPdf render) because the real
diff --git a/src/components/order-invoice-pdf/helpers.js b/src/components/order-invoice-pdf/helpers.js
index 36e0ee62..a02b16bf 100644
--- a/src/components/order-invoice-pdf/helpers.js
+++ b/src/components/order-invoice-pdf/helpers.js
@@ -16,6 +16,7 @@ import T from "i18n-react/dist/i18n-react";
import { Font } from "@react-pdf/renderer";
import { currencyAmountFromCents, formatDiscount } from "../../utils/money";
import { MILLISECONDS_IN_SECOND } from "../../utils/constants";
+import { buildOrderLedger } from "../../utils/order-ledger";
export const DEFAULT_FONT_FAMILY = "Helvetica";
@@ -69,16 +70,15 @@ export const getThemeFontFamily = (theme) => {
: DEFAULT_FONT_FAMILY;
};
-export const buildRows = (order) => {
- const rows = [];
- let balanceCents = 0;
-
- (order.forms || []).forEach((form) => {
- (form.items || [])
- .filter((item) => (item.quantity ?? 1) > 0)
- .forEach((item) => {
- // Cancelled is per-item
- const cancelled = !!item.canceled_by_id;
+// Thin presentational mapper: buildOrderLedger holds the derivation rules
+// (sign conventions, ordering, quantity filtering, row keys) shared with
+// SponsorOrderGrid — this only translates labels, formats currency/dates,
+// and shapes the row fields PdfTableRow expects.
+export const buildRows = (order) =>
+ buildOrderLedger(order).map((entry) => {
+ switch (entry.type) {
+ case "item": {
+ const { form, item, quantity, cancelled, amountCents, balanceCents } = entry;
const cancelledBy = cancelled
? T.translate("sponsor_order_grid.cancelled_by", {
user: item.canceled_by_full_name,
@@ -86,97 +86,87 @@ export const buildRows = (order) => {
})
: "";
- // Cancelled items still accumulate
- balanceCents += item.amount;
-
- rows.push({
- rowKey: `item-${item.line_id ?? item.id}`,
+ return {
+ rowKey: entry.rowKey,
type: "item",
// Table shows form.code per item row (columnKey: "formCode", value: form.code)
code: String(form.code || ""),
description: String(item.type?.name || item.title || ""),
addon: String(form.add_on?.name || ""),
- qty: String(item.quantity ?? 1),
- price: currencyAmountFromCents(item.amount),
+ qty: String(quantity),
+ price: currencyAmountFromCents(amountCents),
balanceCents,
cancelled,
cancelledBy
- });
- });
-
- const discountCents = form.discount_in_cents ?? 0;
- if (discountCents) {
- balanceCents -= discountCents;
- rows.push({
- rowKey: `discount-${form.id}`,
- type: "discount",
- code: T.translate("mui_table.dis"),
- description: formatDiscount(form.discount_amount, form.discount_type),
- addon: "",
- qty: "",
- price: currencyAmountFromCents(discountCents),
- balanceCents
- });
- }
- });
-
- (order.fees || []).forEach((fee) => {
- balanceCents += fee.amount;
- rows.push({
- rowKey: `fee-${fee.line_id ?? fee.id}`,
- type: "fee",
- code: T.translate("mui_table.payfee"),
- description: String(fee.title || ""),
- addon: "",
- qty: "1",
- price: currencyAmountFromCents(fee.amount),
- balanceCents
- });
- });
-
- // Payments and refunds interleaved and sorted by created:
- const paymentsAndRefundsOrdered = [
- ...(order.payments || []).map((p) => ({ ...p, _rowType: "payment" })),
- ...(order.refunds || []).map((r) => ({ ...r, _rowType: "refund" }))
- ].sort((a, b) => a.created - b.created);
-
- paymentsAndRefundsOrdered.forEach((item) => {
- if (item._rowType === "payment") {
- balanceCents -= item.amount;
- rows.push({
- rowKey: `payment-${item.id}`,
- type: "payment",
- code: T.translate("mui_table.pay"),
- description: `${T.translate("mui_table.paid_via")} ${item.method || T.translate("mui_table.card")}`,
- subDescription: formatDate(item.created, "LOC", "YYYY/MM/DD HH:mm"),
- addon: "",
- qty: "1",
- price: currencyAmountFromCents(item.amount),
- balanceCents
- });
- } else {
- balanceCents += item.amount;
- rows.push({
- rowKey: `refund-${item.id}`,
- type: "refund",
- code: T.translate("mui_table.ref"),
- description: String(item.reason || T.translate("mui_table.refund")),
- subDescription: String(item.status || ""),
- addon: "",
- qty: "1",
- price: currencyAmountFromCents(item.amount),
- balanceCents
- });
+ };
+ }
+
+ case "discount": {
+ const { form, amountCents, balanceCents } = entry;
+ return {
+ rowKey: entry.rowKey,
+ type: "discount",
+ code: T.translate("mui_table.dis"),
+ description: formatDiscount(form.discount_amount, form.discount_type),
+ addon: "",
+ qty: "",
+ price: currencyAmountFromCents(amountCents),
+ balanceCents
+ };
+ }
+
+ case "fee": {
+ const { fee, amountCents, balanceCents } = entry;
+ return {
+ rowKey: entry.rowKey,
+ type: "fee",
+ code: T.translate("mui_table.payfee"),
+ description: String(fee.title || ""),
+ addon: "",
+ qty: "1",
+ price: currencyAmountFromCents(amountCents),
+ balanceCents
+ };
+ }
+
+ case "payment": {
+ const { payment, amountCents, balanceCents } = entry;
+ return {
+ rowKey: entry.rowKey,
+ type: "payment",
+ code: T.translate("mui_table.pay"),
+ description: `${T.translate("mui_table.paid_via")} ${payment.method || T.translate("mui_table.card")}`,
+ subDescription: formatDate(payment.created, "LOC", "YYYY/MM/DD HH:mm"),
+ addon: "",
+ qty: "1",
+ price: currencyAmountFromCents(amountCents),
+ balanceCents
+ };
+ }
+
+ case "refund": {
+ const { refund, amountCents, balanceCents } = entry;
+ return {
+ rowKey: entry.rowKey,
+ type: "refund",
+ code: T.translate("mui_table.ref"),
+ description: String(refund.reason || T.translate("mui_table.refund")),
+ subDescription: String(refund.status || ""),
+ addon: "",
+ qty: "1",
+ price: currencyAmountFromCents(amountCents),
+ balanceCents
+ };
+ }
+
+ case "note":
+ return {
+ rowKey: entry.rowKey,
+ type: "note",
+ content: String(entry.note.content || "")
+ };
+
+ default:
+ return null;
}
- });
-
- (order.notes || []).forEach((note) => {
- rows.push({
- rowKey: `note-${note.id}`,
- type: "note",
- content: String(note.content || "")
- });
- });
-
- return rows;
-};
+ }).filter(Boolean);
diff --git a/src/components/order-invoice-pdf/index.js b/src/components/order-invoice-pdf/index.js
index 32996024..7217de11 100644
--- a/src/components/order-invoice-pdf/index.js
+++ b/src/components/order-invoice-pdf/index.js
@@ -44,7 +44,7 @@ export const OrderPdf = ({ order, summit, logoSrc, theme }) => {
const fontFamily = getThemeFontFamily(theme);
const styles = createStyles(fontFamily);
const rowStyles = createRowStyles(styles);
- const rows = buildRows(order, summit);
+ const rows = buildRows(order);
const mainLocation =
summit.main_locations?.[0] ??
summit.locations?.find((location) => location.is_main);
diff --git a/src/utils/__tests__/order-ledger.test.js b/src/utils/__tests__/order-ledger.test.js
new file mode 100644
index 00000000..3231be4f
--- /dev/null
+++ b/src/utils/__tests__/order-ledger.test.js
@@ -0,0 +1,244 @@
+/**
+ * Copyright 2026 OpenStack Foundation
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * */
+
+import { buildOrderLedger } from "../order-ledger";
+import purchaseV2Fixture from "../../components/order-invoice-pdf/__tests__/fixtures/purchase-v2.json";
+
+// ─── Fixture-derived builders ──────────────────────────────────────────────
+// Same pattern as order-invoice-pdf.test.js: start from a real slice of the
+// PurchaseV2Serializer fixture and override only what a given test cares
+// about, instead of hand-authoring object literals.
+
+const baseForm = purchaseV2Fixture.forms[0];
+const baseItem = baseForm.items[0]; // not cancelled
+const baseCancelledItem = baseForm.items[1]; // cancelled
+const baseFee = purchaseV2Fixture.fees[0];
+const basePayment = purchaseV2Fixture.payments[0];
+const baseRefund = purchaseV2Fixture.refunds[0];
+const baseNote = purchaseV2Fixture.notes[0];
+
+const makeForm = (overrides = {}) => ({ ...baseForm, ...overrides });
+const makeItem = (overrides = {}) => ({ ...baseItem, ...overrides });
+const makeCancelledItem = (overrides = {}) => ({
+ ...baseCancelledItem,
+ ...overrides
+});
+const makeFee = (overrides = {}) => ({ ...baseFee, ...overrides });
+const makePayment = (overrides = {}) => ({ ...basePayment, ...overrides });
+const makeRefund = (overrides = {}) => ({ ...baseRefund, ...overrides });
+const makeNote = (overrides = {}) => ({ ...baseNote, ...overrides });
+
+// ─── Empty / missing collections ───────────────────────────────────────────
+
+describe("buildOrderLedger — empty / missing collections", () => {
+ it("returns [] without throwing for any empty input", () => {
+ expect(buildOrderLedger(undefined)).toEqual([]);
+ expect(buildOrderLedger({})).toEqual([]);
+ expect(
+ buildOrderLedger({ forms: [], fees: [], payments: [], refunds: [] })
+ ).toEqual([]);
+ });
+});
+
+// ─── Item entries ───────────────────────────────────────────────────────────
+
+describe("buildOrderLedger — item entries", () => {
+ it("carries the raw form/item and amount in cents, keyed by line_id", () => {
+ const item = makeItem({ line_id: 9001, amount: 5000, quantity: 3 });
+ const form = makeForm({ id: 156, discount_in_cents: 0, items: [item] });
+ const entries = buildOrderLedger({ forms: [form] });
+
+ expect(entries).toHaveLength(1);
+ expect(entries[0].type).toBe("item");
+ expect(entries[0].rowKey).toBe("item-156-9001");
+ expect(entries[0].form).toBe(form);
+ expect(entries[0].item).toBe(item);
+ expect(entries[0].amountCents).toBe(5000);
+ expect(entries[0].quantity).toBe(3);
+ expect(entries[0].balanceCents).toBe(5000);
+ });
+
+ it("defaults quantity to 1 but still excludes items with quantity 0 (fix: previously the grid dropped undefined-quantity items)", () => {
+ const undefinedQty = makeItem({ line_id: 1, quantity: undefined });
+ const nullQty = makeItem({ line_id: 2, quantity: null });
+ const zeroQty = makeItem({ line_id: 3, quantity: 0 });
+ const form = makeForm({
+ discount_in_cents: 0,
+ items: [undefinedQty, nullQty, zeroQty]
+ });
+ const entries = buildOrderLedger({ forms: [form] });
+
+ expect(entries).toHaveLength(2);
+ expect(entries.map((e) => e.rowKey)).toEqual(["item-156-1", "item-156-2"]);
+ entries.forEach((e) => expect(e.quantity).toBe(1));
+ });
+
+ it("gives distinct, form-scoped row keys to items in different forms that carry no line_id/id (fix: previously collided order-wide, corrupting SponsorOrderGrid's row lookup)", () => {
+ const itemA = makeItem({ line_id: undefined, id: undefined });
+ const itemB = makeItem({ line_id: undefined, id: undefined });
+ const entries = buildOrderLedger({
+ forms: [
+ makeForm({ id: 1, discount_in_cents: 0, items: [itemA] }),
+ makeForm({ id: 2, discount_in_cents: 0, items: [itemB] })
+ ]
+ });
+ expect(entries).toHaveLength(2);
+ expect(new Set(entries.map((e) => e.rowKey)).size).toBe(2);
+ expect(entries[0].rowKey).toBe("item-1-0");
+ expect(entries[1].rowKey).toBe("item-2-0");
+ });
+
+ it("sets cancelled: true when item.canceled_by_id is set, false otherwise", () => {
+ const entries = buildOrderLedger({
+ forms: [
+ makeForm({ id: 1, discount_in_cents: 0, items: [makeCancelledItem()] }),
+ makeForm({ id: 2, discount_in_cents: 0, items: [makeItem()] })
+ ]
+ });
+ expect(entries[0].cancelled).toBe(true);
+ expect(entries[1].cancelled).toBe(false);
+ });
+
+ it("a form-level canceled_by_id does not mark items as cancelled", () => {
+ const form = makeForm({ canceled_by_id: 99, items: [makeItem()] });
+ const entries = buildOrderLedger({ forms: [form] });
+ expect(entries[0].cancelled).toBe(false);
+ });
+
+ it("cancelled items still accumulate into the running balance", () => {
+ const normalItem = makeItem({ amount: 8000 });
+ const cancelledItem = makeCancelledItem({ amount: 10000 });
+ const entries = buildOrderLedger({
+ forms: [
+ makeForm({ id: 1, discount_in_cents: 0, items: [normalItem] }),
+ makeForm({ id: 2, discount_in_cents: 0, items: [cancelledItem] })
+ ]
+ });
+ expect(entries[0].balanceCents).toBe(8000);
+ expect(entries[1].balanceCents).toBe(18000); // 8000 + 10000
+ });
+});
+
+// ─── Discount entries ───────────────────────────────────────────────────────
+
+describe("buildOrderLedger — discount entries", () => {
+ it("emits no discount entry when discount_in_cents is 0 (fix: the grid used to render one anyway)", () => {
+ const form = makeForm({ discount_in_cents: 0 });
+ const entries = buildOrderLedger({ forms: [form] });
+ expect(entries.filter((e) => e.type === "discount")).toHaveLength(0);
+ });
+
+ it("emits no discount entry when discount_in_cents is absent", () => {
+ const form = makeForm({ items: [] });
+ delete form.discount_in_cents;
+ const entries = buildOrderLedger({ forms: [form] });
+ expect(entries.filter((e) => e.type === "discount")).toHaveLength(0);
+ });
+
+ it("emits one discount entry keyed by form.id, subtracting from the balance", () => {
+ const item = makeItem({ amount: 10000 });
+ const form = makeForm({ id: 156, discount_in_cents: 5000, items: [item] });
+ const entries = buildOrderLedger({ forms: [form] });
+ const discountEntry = entries.find((e) => e.type === "discount");
+
+ expect(discountEntry.rowKey).toBe("discount-156");
+ expect(discountEntry.form).toBe(form);
+ expect(discountEntry.amountCents).toBe(5000);
+ expect(discountEntry.balanceCents).toBe(5000); // 10000 - 5000
+ });
+});
+
+// ─── Fee entries ─────────────────────────────────────────────────────────────
+
+describe("buildOrderLedger — fee entries", () => {
+ it("gives distinct row keys to multiple fees, none of which carry an `id` field (fix: PDF already used line_id, the grid used fee.id)", () => {
+ expect(baseFee.id).toBeUndefined();
+ const entries = buildOrderLedger({
+ fees: [purchaseV2Fixture.fees[0], purchaseV2Fixture.fees[1]]
+ });
+ expect(entries).toHaveLength(2);
+ expect(new Set(entries.map((e) => e.rowKey)).size).toBe(2);
+ expect(entries[0].rowKey).toBe(`fee-${purchaseV2Fixture.fees[0].line_id}`);
+ expect(entries[1].rowKey).toBe(`fee-${purchaseV2Fixture.fees[1].line_id}`);
+ });
+
+ it("adds fee amount to the running balance and carries the raw fee", () => {
+ const fee = makeFee({ line_id: 7001, amount: 500 });
+ const entries = buildOrderLedger({ fees: [fee] });
+ expect(entries[0].fee).toBe(fee);
+ expect(entries[0].amountCents).toBe(500);
+ expect(entries[0].balanceCents).toBe(500);
+ });
+});
+
+// ─── Payment / refund entries ────────────────────────────────────────────────
+
+describe("buildOrderLedger — payment and refund entries", () => {
+ it("interleaves payments and refunds by created date, subtracting payments and adding refunds", () => {
+ const entries = buildOrderLedger({
+ payments: [makePayment({ id: 1, amount: 10000, created: 2 })],
+ refunds: [makeRefund({ id: 1, amount: 3000, created: 1 })]
+ });
+
+ // refund first (created: 1), then payment (created: 2)
+ expect(entries[0].type).toBe("refund");
+ expect(entries[0].balanceCents).toBe(3000);
+ expect(entries[1].type).toBe("payment");
+ expect(entries[1].balanceCents).toBe(-7000); // 3000 - 10000
+ });
+
+ it("keys payment and refund entries by id and carries the raw source object", () => {
+ const payment = makePayment({ id: 3001 });
+ const refund = makeRefund({ id: 4001 });
+ const entries = buildOrderLedger({ payments: [payment], refunds: [refund] });
+
+ const paymentEntry = entries.find((e) => e.type === "payment");
+ const refundEntry = entries.find((e) => e.type === "refund");
+ expect(paymentEntry.rowKey).toBe("payment-3001");
+ expect(paymentEntry.payment).toBe(payment);
+ expect(refundEntry.rowKey).toBe("refund-4001");
+ expect(refundEntry.refund).toBe(refund);
+ });
+});
+
+// ─── Note entries ─────────────────────────────────────────────────────────────
+
+describe("buildOrderLedger — note entries", () => {
+ it("carries the raw note, with no amount or balance (notes have no monetary effect)", () => {
+ const note = makeNote();
+ const entries = buildOrderLedger({ notes: [note] });
+ expect(entries[0].type).toBe("note");
+ expect(entries[0].rowKey).toBe(`note-${note.id}`);
+ expect(entries[0].note).toBe(note);
+ expect(entries[0].amountCents).toBeUndefined();
+ expect(entries[0].balanceCents).toBeUndefined();
+ });
+});
+
+// ─── Ordering ─────────────────────────────────────────────────────────────────
+
+describe("buildOrderLedger — overall ordering", () => {
+ it("orders entries as: per-form (items then discount), fees, payments/refunds interleaved, notes", () => {
+ const entries = buildOrderLedger(purchaseV2Fixture);
+ expect(entries.map((e) => e.type)).toEqual([
+ "item",
+ "item",
+ "discount",
+ "fee",
+ "fee",
+ "payment",
+ "refund",
+ "note"
+ ]);
+ });
+});
diff --git a/src/utils/order-ledger.js b/src/utils/order-ledger.js
new file mode 100644
index 00000000..e21c3c11
--- /dev/null
+++ b/src/utils/order-ledger.js
@@ -0,0 +1,118 @@
+/**
+ * Copyright 2026 OpenStack Foundation
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * */
+
+// Pure derivation of an order's ledger (rows + running balance) from a raw
+// PurchaseV2 payload. No i18n, no date/currency formatting, no react-pdf or
+// MUI imports — those belong to the presentational mapper in each consumer
+// (order-invoice-pdf/helpers.js, mui/SponsorOrderGrid/index.js). Keeping the
+// rules below (sign conventions, ordering, filtering, row keys) in one place
+// is what keeps the invoice PDF and the sponsor order grid from silently
+// drifting apart on the numbers a sponsor sees.
+
+/**
+ * @param {object} order - Raw PurchaseV2 payload (or an object that spreads
+ * it, e.g. sponsor-services' normalizeOrder output). Only raw fields are
+ * read here — never props a consumer added on top (e.g. a pre-formatted
+ * `discount` string).
+ * @returns {Array