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} Ordered ledger entries, each carrying its raw + * source object(s), amounts in cents, and a precomputed running + * balanceCents (except `note` entries, which have no monetary effect). + */ +export const buildOrderLedger = (order) => { + const entries = []; + let balanceCents = 0; + + (order?.forms || []).forEach((form) => { + (form.items || []) + .filter((item) => (item.quantity ?? 1) > 0) + .forEach((item, idx) => { + // 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. A form-level canceled_by_id never + // marks an item as cancelled -- only canceled_quantity does. + const quantity = item.quantity ?? 1; + const canceledQuantity = item.canceled_quantity ?? 0; + + // A charge stays in the ledger in full whether it's cancelled or + // not, partially or fully -- cancellation only nets out via the + // reconciliation total, not the running balance. + balanceCents += item.amount; + entries.push({ + type: "item", + // Scoped by form.id: line_id/id are expected to be unique per the + // real purchases-api v2 shape, but a payload missing both (or a + // stray collision) must not collide across DIFFERENT forms — that + // silently corrupted rendering in SponsorOrderGrid, which looks + // up row data by rowKey in a Map keyed across the whole order. + rowKey: `item-${form.id}-${item.line_id ?? item.id ?? idx}`, + form, + item, + quantity, + canceledQuantity, + cancellations: item.cancellations ?? [], + amountCents: item.amount, + cancelled: canceledQuantity > 0 && canceledQuantity === quantity, + partiallyCancelled: canceledQuantity > 0 && canceledQuantity < quantity, + balanceCents + }); + }); + + const discountCents = form.discount_in_cents ?? 0; + if (discountCents) { + balanceCents -= discountCents; + entries.push({ + type: "discount", + rowKey: `discount-${form.id}`, + form, + amountCents: discountCents, + balanceCents + }); + } + }); + + (order?.fees || []).forEach((fee) => { + balanceCents += fee.amount; + entries.push({ + type: "fee", + rowKey: `fee-${fee.line_id ?? fee.id}`, + fee, + amountCents: fee.amount, + balanceCents + }); + }); + + // Payments and refunds interleaved and sorted by created: + const paymentsAndRefunds = [ + ...(order?.payments || []).map((payment) => ({ kind: "payment", payment, created: payment.created })), + ...(order?.refunds || []).map((refund) => ({ kind: "refund", refund, created: refund.created })) + ].sort((a, b) => a.created - b.created); + + paymentsAndRefunds.forEach(({ kind, payment, refund }) => { + if (kind === "payment") { + balanceCents -= payment.amount; + entries.push({ + type: "payment", + rowKey: `payment-${payment.id}`, + payment, + amountCents: payment.amount, + balanceCents + }); + } else { + balanceCents += refund.amount; + entries.push({ + type: "refund", + rowKey: `refund-${refund.id}`, + refund, + amountCents: refund.amount, + balanceCents + }); + } + }); + + (order?.notes || []).forEach((note) => { + entries.push({ + type: "note", + rowKey: `note-${note.id}`, + note + }); + }); + + return entries; +};