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