diff --git a/src/components/mui/SponsorOrderGrid/__tests__/SponsorOrderGrid.test.js b/src/components/mui/SponsorOrderGrid/__tests__/SponsorOrderGrid.test.js
index 5804ea5b..2b9d3b30 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", () => ({
@@ -458,3 +460,141 @@ 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();
+ });
+
+ test("falls back to mui_table.card for the payment row when payment.method is absent (matches the invoice PDF)", () => {
+ const order = {
+ forms: [],
+ payments: [{ id: 1, amount: 10000, created: 1, method: null }],
+ total: 0
+ };
+ render();
+ expect(screen.getByText(/mui_table\.paid_via mui_table\.card/)).toBeInTheDocument();
+ });
+
+ test("does not override payment.method when present", () => {
+ const order = {
+ forms: [],
+ payments: [{ id: 1, amount: 10000, created: 1, method: "wire" }],
+ total: 0
+ };
+ render();
+ expect(screen.getByText(/mui_table\.paid_via wire/)).toBeInTheDocument();
+ });
+
+ test("falls back to mui_table.refund for the refund row when refund.reason is absent (matches the invoice PDF)", () => {
+ const order = {
+ forms: [],
+ refunds: [{ id: 1, amount: 3000, created: 1, reason: null }],
+ total: 0
+ };
+ render();
+ // "mui_table.refund" also labels the row's type badge, so a second
+ // occurrence (the reason cell falling back to the same translation) is
+ // exactly what this asserts.
+ expect(screen.getAllByText("mui_table.refund")).toHaveLength(2);
+ });
+
+ test("does not override refund.reason when present", () => {
+ const order = {
+ forms: [],
+ refunds: [{ id: 1, amount: 3000, created: 1, reason: "duplicate charge" }],
+ total: 0
+ };
+ render();
+ expect(screen.getByText("duplicate charge")).toBeInTheDocument();
+ // Only the type badge renders "mui_table.refund" -- the reason cell
+ // must not have been overridden with the fallback.
+ expect(screen.getAllByText("mui_table.refund")).toHaveLength(1);
+ });
+});
diff --git a/src/components/mui/SponsorOrderGrid/index.js b/src/components/mui/SponsorOrderGrid/index.js
index 61ac77bb..5de06b86 100644
--- a/src/components/mui/SponsorOrderGrid/index.js
+++ b/src/components/mui/SponsorOrderGrid/index.js
@@ -28,7 +28,8 @@ import RuleIcon from "@mui/icons-material/Rule";
import {DiscountRow, FeeRow, NotesRow, PaymentRow, RefundRow, TotalRow} from "../tables/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";
@@ -37,35 +38,28 @@ import CancelledItems from "./components/CancelledItems";
import BalanceValue from "./components/BalanceValue";
import ChangeQuantityModal from "./components/ChangeQuantityModal";
-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, canceledQuantity, cancellations, 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.line_id ?? `${form.id}-${i}`;
- const canceledQuantity = it.canceled_quantity ?? 0;
- const cancelled = canceledQuantity > 0 && canceledQuantity === it.quantity;
- 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,
- canceled_quantity: canceledQuantity,
- type,
- amount,
- amountValue: it.amount,
- cancelled,
- cancellations: it.cancellations ?? []
- };
- })
- }));
+ return {
+ id: item.line_id ?? `${form.id}-${idx}`,
+ formCode: form.code,
+ itemName: item.type?.name || item.title,
+ itemCode: item.type?.code,
+ quantity,
+ canceled_quantity: canceledQuantity,
+ type: cancelled ? SPONSOR_ORDER_GRID_ITEM_TYPES.CANCELLED : SPONSOR_ORDER_GRID_ITEM_TYPES.CHARGE,
+ amount: currencyAmountFromCents(item.amount || 0),
+ amountValue: item.amount,
+ cancelled,
+ cancellations
+ };
};
const SponsorOrderGrid = ({
@@ -78,28 +72,25 @@ 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.canceled_quantity > 0));
+ 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.canceled_quantity > 0);
const canCancel = onCancelForm && onUndoCancelForm;
const trailingCols = canCancel ? 1 : 0;
const [changeQuantityRow, setChangeQuantityRow] = React.useState(null);
- let balance = 0;
-
- const calculateBalance = (rowAmount, op = 1) => {
- balance = balance + (rowAmount * op);
- return balance;
- }
const columns = [
{
@@ -149,11 +140,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 (
@@ -199,117 +185,129 @@ 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(
-
-
- setChangeQuantityRow(row)}>
-
-
-
-
- )
- }
+ // ACTION COLUMN
+ if (canCancel) {
+ cols.push(
+
+
+ setChangeQuantityRow(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..8e3a46ab
--- /dev/null
+++ b/src/components/order-invoice-pdf/__tests__/ledger-consistency.test.js
@@ -0,0 +1,125 @@
+/**
+ * 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));
+ });
+ });
+
+ it("buildRows (PDF) and SponsorOrderGrid render the same discount description when form.discount is a pre-formatted string", () => {
+ const orderWithNormalizedDiscount = {
+ ...purchaseV2Fixture,
+ forms: [{ ...purchaseV2Fixture.forms[0], discount: "10% off" }]
+ };
+
+ const pdfDiscountRow = buildRows(orderWithNormalizedDiscount).find(
+ (row) => row.type === "discount"
+ );
+ const { container } = render(
+
+ );
+
+ expect(pdfDiscountRow.description).toBe("10% off");
+ expect(container.textContent).toContain("10% off");
+ });
+
+ it("PDF's cancelled-items filter and the grid's cancelled-items header agree on which items are cancelled", () => {
+ const pdfCancelledRatios = buildRows(purchaseV2Fixture)
+ .filter((row) => row.type === "item" && row.canceledQuantity > 0)
+ .map((row) => `(${row.canceledQuantity}/${row.quantity})`);
+
+ // Sanity check the fixture actually exercises both a fully and a
+ // partially cancelled item -- otherwise this test would pass vacuously.
+ expect(pdfCancelledRatios.length).toBeGreaterThanOrEqual(2);
+
+ const { container } = render(
+
+ );
+
+ // Matched on the "(canceled/total)" ratio the CancelledItems header
+ // renders per item -- itemCode is deliberately not part of this check,
+ // since real purchases-api-v2 items always carry a populated `type`.
+ pdfCancelledRatios.forEach((ratio) => {
+ expect(container.textContent).toContain(ratio);
+ });
+ });
+});
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 d6c2e4c7..b28df217 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
@@ -108,8 +108,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]; // fully cancelled
@@ -161,13 +159,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([]);
});
});
@@ -176,10 +180,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);
});
@@ -189,9 +190,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");
@@ -200,41 +199,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 cancellations when canceled_quantity equals quantity", () => {
- 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].partiallyCancelled).toBe(false);
expect(rows[0].cancellations).toHaveLength(1);
@@ -253,7 +234,7 @@ describe("buildRows — cancelled items", () => {
discount_in_cents: 0,
items: [makeItem()]
});
- const rows = buildRows({ forms: [withZero, withAbsent] }, MOCK_SUMMIT);
+ const rows = buildRows({ forms: [withZero, withAbsent] });
rows.forEach((r) => {
expect(r.cancelled).toBe(false);
expect(r.partiallyCancelled).toBe(false);
@@ -262,10 +243,7 @@ describe("buildRows — cancelled items", () => {
});
it("sets partiallyCancelled: true (and cancelled: false) when canceled_quantity is between 0 and quantity", () => {
- const rows = buildRows(
- { forms: [makeForm({ items: [makePartiallyCancelledItem()] })] },
- MOCK_SUMMIT
- );
+ const rows = buildRows({ forms: [makeForm({ items: [makePartiallyCancelledItem()] })] });
expect(rows[0].cancelled).toBe(false);
expect(rows[0].partiallyCancelled).toBe(true);
// quantity(5) - canceled_quantity(2) = 3 remaining
@@ -277,15 +255,12 @@ describe("buildRows — cancelled items", () => {
it("fully cancelled items still accumulate their full amount 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 rows = buildRows({
+ forms: [
+ makeForm({ id: 1, discount_in_cents: 0, items: [normalItem] }),
+ makeForm({ id: 2, discount_in_cents: 0, items: [cancelledItem] })
+ ]
+ });
const normal = rows.find((r) => !r.cancelled);
const cancelled = rows.find((r) => r.cancelled);
expect(normal.balanceCents).toBe(8000);
@@ -294,16 +269,13 @@ describe("buildRows — cancelled items", () => {
it("partially cancelled items still accumulate their full amount into the running balance (matches SponsorOrderGrid; cancellation only nets out via reconciliation)", () => {
const partialItem = makePartiallyCancelledItem({ amount: 50000 });
- const rows = buildRows(
- { forms: [makeForm({ discount_in_cents: 0, items: [partialItem] })] },
- MOCK_SUMMIT
- );
+ const rows = buildRows({ forms: [makeForm({ discount_in_cents: 0, items: [partialItem] })] });
expect(rows[0].balanceCents).toBe(50000);
});
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);
+ const rows = buildRows({ forms: [form] });
expect(rows[0].cancelled).toBe(false);
});
});
@@ -312,43 +284,22 @@ describe("buildRows — cancelled items", () => {
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);
@@ -364,28 +315,38 @@ 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");
});
+
+ it("prefers a pre-formatted form.discount string over discount_amount/discount_type when present (matches SponsorOrderGrid)", () => {
+ const form = makeForm({
+ discount: "10% off",
+ discount_amount: 1000,
+ discount_type: "Rate"
+ });
+ const discountRows = buildRows({ forms: [form] }).filter(
+ (r) => r.type === "discount"
+ );
+ expect(discountRows[0].description).toBe("10% off");
+ });
});
// ─── Payment 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");
});
});
@@ -394,22 +355,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("");
});
@@ -419,37 +376,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 63d043b6..dac94194 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,31 +70,28 @@ 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) => {
- // Cancellation is per-item and quantity-scoped: canceled_quantity may
- // be anywhere from 0 (not cancelled) up to quantity (fully cancelled),
- // with the individual cancellation events (and their frozen per-event
- // amounts) listed in cancellations. Mirrors SponsorOrderGrid's contract.
- const quantity = item.quantity ?? 1;
- const canceledQuantity = item.canceled_quantity ?? 0;
- const cancellations = item.cancellations ?? [];
- const cancelled = canceledQuantity > 0 && canceledQuantity === quantity;
- const partiallyCancelled = canceledQuantity > 0 && canceledQuantity < quantity;
-
- // Matches SponsorOrderGrid: a charge stays in the ledger in full
- // whether it's cancelled or not, partially or fully -- cancellation
- // only nets out via the reconciliation block below.
- balanceCents += item.amount;
-
- rows.push({
- rowKey: `item-${item.line_id ?? item.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,
+ canceledQuantity,
+ cancellations,
+ cancelled,
+ partiallyCancelled,
+ amountCents,
+ balanceCents
+ } = entry;
+
+ return {
+ rowKey: entry.rowKey,
type: "item",
// Table shows form.code per item row (columnKey: "formCode", value: form.code)
code: String(form.code || ""),
@@ -106,7 +104,7 @@ export const buildRows = (order) => {
qty: String(quantity - canceledQuantity),
quantity,
canceledQuantity,
- price: currencyAmountFromCents(item.amount),
+ price: currencyAmountFromCents(amountCents),
balanceCents,
cancelled,
partiallyCancelled,
@@ -120,82 +118,78 @@ export const buildRows = (order) => {
}),
reason: c.reason ? String(c.reason) : ""
}))
- });
- });
-
- 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
- });
+ };
+ }
+
+ case "discount": {
+ const { form, amountCents, balanceCents } = entry;
+ return {
+ rowKey: entry.rowKey,
+ type: "discount",
+ code: T.translate("mui_table.dis"),
+ // Prefers a consumer-normalized form.discount when present, same
+ // as SponsorOrderGrid -- otherwise the two can show different
+ // discount text for the same order.
+ description: form.discount ?? 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.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
- });
- }
- });
-
- (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 4bca0a0b..23619b79 100644
--- a/src/components/order-invoice-pdf/index.js
+++ b/src/components/order-invoice-pdf/index.js
@@ -45,9 +45,9 @@ 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 cancelledItems = rows.filter(
- (row) => row.type === "item" && row.cancellations?.length > 0
+ (row) => row.type === "item" && row.canceledQuantity > 0
);
const mainLocation =
summit.main_locations?.[0] ??
diff --git a/src/utils/__tests__/order-ledger.test.js b/src/utils/__tests__/order-ledger.test.js
new file mode 100644
index 00000000..0e1cb13e
--- /dev/null
+++ b/src/utils/__tests__/order-ledger.test.js
@@ -0,0 +1,262 @@
+/**
+ * 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]; // fully cancelled
+const basePartiallyCancelledItem = baseForm.items[2]; // partially 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 makePartiallyCancelledItem = (overrides = {}) => ({
+ ...basePartiallyCancelledItem,
+ ...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 canceled_quantity equals quantity, 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[0].partiallyCancelled).toBe(false);
+ expect(entries[1].cancelled).toBe(false);
+ expect(entries[1].partiallyCancelled).toBe(false);
+ });
+
+ it("sets partiallyCancelled: true (and cancelled: false) when canceled_quantity is between 0 and quantity, exposing canceledQuantity and cancellations", () => {
+ const entries = buildOrderLedger({
+ forms: [makeForm({ discount_in_cents: 0, items: [makePartiallyCancelledItem()] })]
+ });
+ expect(entries[0].cancelled).toBe(false);
+ expect(entries[0].partiallyCancelled).toBe(true);
+ expect(entries[0].canceledQuantity).toBe(basePartiallyCancelledItem.canceled_quantity);
+ expect(entries[0].cancellations).toBe(basePartiallyCancelledItem.cancellations);
+ });
+
+ 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",
+ "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..70f4de2f
--- /dev/null
+++ b/src/utils/order-ledger.js
@@ -0,0 +1,132 @@
+/**
+ * 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