From ba787000b5f82c889a5435d629383da4496a35eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Fri, 3 Jul 2026 17:44:53 -0300 Subject: [PATCH 01/14] feat: add order invoice component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- package.json | 4 +- src/components/__tests__/order-pdf-.test.js | 401 ++++++++++ src/components/index.js | 1 + src/components/order-pdf/index.js | 806 ++++++++++++++++++++ webpack.common.js | 1 + 5 files changed, 1211 insertions(+), 2 deletions(-) create mode 100644 src/components/__tests__/order-pdf-.test.js create mode 100644 src/components/order-pdf/index.js diff --git a/package.json b/package.json index 2fc807d0..f9ae464e 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "@mui/icons-material": "^6.4.3", "@mui/material": "^6.4.3", "@mui/x-date-pickers": "^7.26.0", - "@react-pdf/renderer": "^3.1.11", + "@react-pdf/renderer": "^4.4.1", "@sentry/react": "^8.54.0", "@sentry/webpack-plugin": "^3.1.2", "@stripe/react-stripe-js": "^5.4.1", @@ -126,7 +126,7 @@ "@mui/icons-material": "^6.4.3", "@mui/material": "^6.4.3", "@mui/x-date-pickers": "^7.26.0", - "@react-pdf/renderer": "^3.1.11", + "@react-pdf/renderer": "^3.1.11 || ^4.0.0", "@sentry/react": "^8.54.0", "@stripe/react-stripe-js": "^5.4.1", "@stripe/stripe-js": "^8.5.3", diff --git a/src/components/__tests__/order-pdf-.test.js b/src/components/__tests__/order-pdf-.test.js new file mode 100644 index 00000000..17178a0c --- /dev/null +++ b/src/components/__tests__/order-pdf-.test.js @@ -0,0 +1,401 @@ +/** + * 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 React from "react"; +import { render } from "@testing-library/react"; +import { buildOrderPdfRows, OrderPdf } from "../index"; + +jest.mock("@react-pdf/renderer", () => { + const React = require("react"); + const Span = ({ children }) => + React.createElement("span", null, children ?? null); + return { + Document: Span, + Page: Span, + Text: Span, + View: Span, + Image: () => null, + Svg: () => null, + Path: () => null, + StyleSheet: { create: (s) => s }, + Font: { register: () => {} }, + pdf: () => ({ toBlob: async () => ({}) }) + }; +}); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +const MOCK_SUMMIT = { time_zone_id: "UTC" }; + +const makeForm = (overrides = {}) => ({ + id: 1, + code: "FORM-1", + name: "Gold Package", + discount_in_cents: 0, + discount: null, + add_on: null, + items: [], + ...overrides +}); + +const makeItem = (overrides = {}) => ({ + line_id: 10, + code: "ITEM-A", + title: "Logo Placement", + type: null, + quantity: 2, + amount: 5000, + ...overrides +}); + +// ─── Empty / missing collections ───────────────────────────────────────────── + +describe("buildRows — empty / missing collections", () => { + it("returns [] without throwing for any empty input", () => { + expect(buildRows({}, MOCK_SUMMIT)).toEqual([]); + expect( + buildRows({ forms: [], fees: [], payments: [], refunds: [] }, MOCK_SUMMIT) + ).toEqual([]); + }); +}); + +// ─── Item rows ──────────────────────────────────────────────────────────────── + +describe("buildRows — item rows", () => { + it("emits item rows directly with no group row", () => { + const rows = buildRows( + { forms: [makeForm({ items: [makeItem()] })] }, + MOCK_SUMMIT + ); + expect(rows[0].type).toBe("item"); + expect(rows.every((r) => r.type !== "group")).toBe(true); + }); + + it("formats price, stringifies qty, and uses form.code as the row code", () => { + const form = makeForm({ + code: "ABC-1", + items: [makeItem({ amount: 5000, quantity: 3 })] + }); + const itemRow = buildRows({ forms: [form] }, MOCK_SUMMIT).find( + (r) => r.type === "item" + ); + expect(itemRow.price).toBe("$50.00"); + expect(itemRow.qty).toBe("3"); + expect(itemRow.code).toBe("ABC-1"); + }); + + it("prefers item.type.name over item.title for description", () => { + const withType = makeItem({ + type: { name: "Platinum Sponsor" }, + title: "fallback" + }); + const withoutType = makeItem({ type: null, title: "Logo Placement" }); + const rows = buildRows( + { + forms: [ + makeForm({ id: 1, items: [withType] }), + makeForm({ id: 2, items: [withoutType] }) + ] + }, + MOCK_SUMMIT + ); + 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({ items: [makeItem({ quantity: 0 })] })] }, + MOCK_SUMMIT + ); + expect(rows).toHaveLength(0); + }); +}); + +// ─── Cancelled items (per-item, not per-form) ───────────────────────────────── + +describe("buildRows — cancelled items", () => { + const cancelledItem = makeItem({ + line_id: 20, + amount: 10000, + canceled_by_id: 99, + canceled_by_full_name: "Admin User", + canceled_at: 1700000000 + }); + + it("sets cancelled: true and populates cancelledBy when item.canceled_by_id is set", () => { + const rows = buildRows( + { forms: [makeForm({ items: [cancelledItem] })] }, + MOCK_SUMMIT + ); + expect(rows[0].cancelled).toBe(true); + expect(rows[0].cancelledBy).toMatch(/Admin User/); + }); + + it("sets cancelled: false and empty cancelledBy when canceled_by_id is absent or null", () => { + const withNull = makeForm({ + id: 1, + items: [makeItem({ canceled_by_id: null })] + }); + const withAbsent = makeForm({ id: 2, items: [makeItem()] }); + const rows = buildRows({ forms: [withNull, withAbsent] }, MOCK_SUMMIT); + rows.forEach((r) => { + expect(r.cancelled).toBe(false); + expect(r.cancelledBy).toBe(""); + }); + }); + + it("cancelled items still accumulate into the running balance", () => { + const normalItem = makeItem({ line_id: 10, amount: 8000 }); + const rows = buildRows( + { + forms: [ + makeForm({ id: 1, items: [normalItem] }), + makeForm({ id: 2, 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: [{ id: 1, title: "Processing Fee", amount: 200 }] }, + MOCK_SUMMIT + ).find((r) => r.type === "fee"); + expect(feeRow.code).toBe("PAYFEE"); + expect(feeRow.price).toBe("$2.00"); + }); +}); + +// ─── 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", () => { + const form = makeForm({ + discount_in_cents: 1500, + discount: "15%", + code: "DISC-1" + }); + const discountRows = buildRows({ forms: [form] }, MOCK_SUMMIT).filter( + (r) => r.type === "discount" + ); + expect(discountRows).toHaveLength(1); + expect(discountRows[0].rowKey).toBe(`discount-${form.id}`); + expect(discountRows[0].code).toBe("DIS"); + expect(discountRows[0].price).toBe("$15.00"); + }); +}); + +// ─── Payment rows ───────────────────────────────────────────────────────────── + +describe("buildRows — payment rows", () => { + it("sets description to 'Paid via ' and defaults method to card", () => { + const withMethod = buildRows( + { payments: [{ id: 1, amount: 2500, method: "wire" }] }, + MOCK_SUMMIT + ).find((r) => r.type === "payment"); + expect(withMethod.price).toBe("$25.00"); + expect(withMethod.description).toBe("Paid via wire"); + + const withoutMethod = buildRows( + { payments: [{ id: 2, amount: 10000 }] }, + MOCK_SUMMIT + ).find((r) => r.type === "payment"); + expect(withoutMethod.description).toBe("Paid via card"); + }); +}); + +// ─── Refund rows ────────────────────────────────────────────────────────────── + +describe("buildRows — refund rows", () => { + it("maps reason to description and status to subDescription, with defaults when absent", () => { + const withFields = buildRows( + { + refunds: [ + { + id: 1, + reason: "duplicate charge", + status: "approved", + amount: 3000 + } + ] + }, + MOCK_SUMMIT + ).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: [{ id: 2, amount: 1000 }] }, + MOCK_SUMMIT + ).find((r) => r.type === "refund"); + expect(withDefaults.description).toBe("Refund"); + expect(withDefaults.subDescription).toBe(""); + }); +}); + +// ─── Note rows ──────────────────────────────────────────────────────────────── + +describe("buildRows — note rows", () => { + it("emits type 'note' with content, defaulting to empty string when absent", () => { + const withContent = buildRows( + { notes: [{ id: 1, content: "Call client to confirm" }] }, + MOCK_SUMMIT + ); + expect(withContent[0].type).toBe("note"); + expect(withContent[0].content).toBe("Call client to confirm"); + + const withoutContent = buildRows({ notes: [{ id: 2 }] }, MOCK_SUMMIT); + 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: [{ id: 1, amount: 10000, created: 2 }], + refunds: [{ id: 2, 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 + }); +}); + +// ─── OrderPdf render-level ──────────────────────────────────────────────────── + +const makeRenderOrder = (overrides = {}) => ({ + number: "ORD-2026-001", + total: 0, + purchased_date: "2026-01-15T10:00:00Z", + purchased_by_full_name: "Jane Doe", + client: { contact_name: "Jane Doe", company_name: "Acme Corp" }, + address: null, + forms: [], + fees: [], + payments: [], + refunds: [], + cancelled_total: 0, + refunds_total: 0, + retained: 0, + credited_to_payment_method: 0, + ...overrides +}); + +const makeRenderSummit = (overrides = {}) => ({ + name: "OpenStack Summit 2026", + time_zone_id: "America/Los_Angeles", + main_locations: [ + { + short_name: "Main Hall", + name: "Convention Center", + address_1: "123 Expo Blvd", + city: "Vancouver", + state: "BC", + postal_code: "V6B 1A1", + country: "Canada" + } + ], + ...overrides +}); + +describe("OrderPdf — render", () => { + it("renders header fields from order and summit, venue from main_locations", () => { + const { container } = render( + + ); + const text = container.textContent; + expect(text).toContain("ORD-2026-001"); + expect(text).toContain("Acme Corp"); + expect(text).toContain("Jane Doe"); + expect(text).toContain("OpenStack Summit 2026"); + expect(text).toContain("Main Hall"); + expect(text).toContain("123 Expo Blvd"); + }); +}); + +// ─── Reconciliation block ───────────────────────────────────────────────────── + +describe("OrderPdf — reconciliation block", () => { + it("renders cancelled and refunded totals from order-level fields", () => { + const order = makeRenderOrder({ + cancelled_total: 75000, + refunds_total: 20000, + retained: 75000, + credited_to_payment_method: 0 + }); + const { container } = render( + + ); + const text = container.textContent; + expect(text).toContain("Reconciliation"); + expect(text).toContain("Cancelled"); + expect(text).toContain("Refunded"); + expect(text).toContain("$750.00"); + expect(text).toContain("$200.00"); + }); + + it("switches label between 'Retained as cancellation fee' and 'Credited to Payment Method'", () => { + const { container: retainedContainer } = render( + + ); + expect(retainedContainer.textContent).toContain( + "Retained as cancellation fee" + ); + + const { container: creditedContainer } = render( + + ); + expect(creditedContainer.textContent).toContain( + "Credited to Payment Method" + ); + }); +}); diff --git a/src/components/index.js b/src/components/index.js index f074b1c0..e0742e55 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -26,6 +26,7 @@ export {default as Input} from './inputs/text-input' export {default as Panel} from './sections/panel' export {default as SimpleLinkList} from './simple-link-list' export {default as SummitDropdown} from './summit-dropdown' +export {OrderPdf, buildRows as buildOrderPdfRows, generateInvoicePDF, previewPDF} from './order-pdf' export {default as Table} from './table/Table' export {default as SortableTable} from './table-sortable/SortableTable' export {default as EditableTable} from './table-editable/EditableTable' diff --git a/src/components/order-pdf/index.js b/src/components/order-pdf/index.js new file mode 100644 index 00000000..850601c8 --- /dev/null +++ b/src/components/order-pdf/index.js @@ -0,0 +1,806 @@ +/** + * 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 React from "react"; +import PropTypes from "prop-types"; +import { + Document, + Page, + Text, + View, + Image, + Svg, + Path, + StyleSheet, + pdf +} from "@react-pdf/renderer"; +import { currencyAmountFromCents } from "../../utils/money"; +import { epochToMomentTimeZone } from "../../utils/methods"; + +const DEFAULT_FONT_FAMILY = "Helvetica"; + +const formatDate = (epoch, timeZoneId, format) => + epochToMomentTimeZone(epoch, timeZoneId).format(format); + +const formatAddress = (address) => { + if (!address) return ""; + return [ + address.address_1, + address.address_2, + address.city, + address.state, + address.zip_code, + address.country + ] + .filter(Boolean) + .join(", "); +}; + +const formatVenueName = (location) => { + if (!location) return ""; + return `${location?.short_name ?? ""}${location?.name ? ` (${location?.name})` : ""}`; +}; + +const MUI_ICON_PATHS = { + ArrowUpward: "M4 12l1.41 1.41L11 7.83V20h2V7.83l5.58 5.59L20 12l-8-8-8 8z", + ArrowDownward: + "M20 12l-1.41-1.41L13 16.17V4h-2v12.17l-5.58-5.59L4 12l8 8 8-8z", + Refresh: + "M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z", + DoNotDisturb: + "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8 0-1.85.63-3.55 1.69-4.9L16.9 18.31C15.55 19.37 13.85 20 12 20zm6.31-3.1L7.1 5.69C8.45 4.63 10.15 4 12 4c4.42 0 8 3.58 8 8 0 1.85-.63 3.55-1.69 4.9z" +}; + +const PDF_ICON_SIZE = 8; + +const PdfIcon = ({ name, color, size = PDF_ICON_SIZE }) => ( + + + +); + +export const buildRows = (order, summit) => { + const rows = []; + let balanceCents = 0; + + (order.forms || []).forEach((form) => { + (form.items || []) + .filter((item) => item.quantity) + .forEach((item) => { + // Cancelled is per-item + const cancelled = !!item.canceled_by_id; + const cancelledBy = cancelled + ? `Cancelled by ${item.canceled_by_full_name} on ${formatDate(item.canceled_at, summit.time_zone_id, "YYYY/MM/DD HH:mm")}` + : ""; + + // Cancelled items still accumulate + balanceCents += item.amount; + + rows.push({ + rowKey: `item-${item.line_id ?? item.id}`, + 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), + balanceCents, + cancelled, + cancelledBy + }); + }); + + const discountCents = form.discount_in_cents ?? 0; + if (discountCents) { + balanceCents -= discountCents; + rows.push({ + rowKey: `discount-${form.id}`, + type: "discount", + code: "DIS", + description: String(form.discount || ""), + addon: "", + qty: "", + price: currencyAmountFromCents(discountCents), + balanceCents + }); + } + }); + + (order.fees || []).forEach((fee) => { + balanceCents += fee.amount; + rows.push({ + rowKey: `fee-${fee.id}`, + type: "fee", + code: "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: "PAY", + description: `Paid via ${item.method || "card"}`, + subDescription: formatDate( + item.created, + summit.time_zone_id, + "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: "REF", + description: String(item.reason || "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; +}; + +const fmtBalance = (cents) => { + if (cents == null) return ""; + const abs = currencyAmountFromCents(Math.abs(cents)); + return cents < 0 ? `-${abs}` : abs; +}; + +// Parameterized by fontFamily so a consumer's custom typeface (registered via +// Font.register in their own app) applies to every text style explicitly, +// not just the ones a consumer happens to remember to override. +const createStyles = (fontFamily) => StyleSheet.create({ + page: { + backgroundColor: "#F7F7F8", + padding: 64, + fontFamily, + fontSize: 8, + color: "#212529" + }, + headerRow: { + flexDirection: "row", + marginBottom: 20, + gap: 20 + }, + cellLeft: { + flex: 1, + backgroundColor: "#ffffff", + borderRadius: 10, + borderWidth: 1, + borderColor: "#DEE2E6", + paddingHorizontal: 18, + paddingVertical: 10, + justifyContent: "center" + }, + logo: { + width: "100%", + height: 84, + objectFit: "contain", + marginBottom: 0, + paddingBottom: 0 + }, + cellRight: { + flex: 1, + backgroundColor: "#ffffff", + borderRadius: 10, + borderWidth: 1, + borderColor: "#DEE2E6", + paddingHorizontal: 18, + paddingVertical: 10 + }, + invoiceTitle: { + fontFamily, + fontWeight: "bold", + fontSize: 14, + lineHeight: 1.5, + letterSpacing: 0, + color: "rgba(17, 19, 21, 1)", + marginBottom: 8 + }, + fieldRow: { + fontSize: 8, + lineHeight: 1.8, + flexDirection: "row", + color: "rgba(0, 0, 0, 0.87)", + paddingBottom: 3, + paddingTop: 6, + borderBottom: "1px solid rgba(224, 224, 224, 1)", + alignContent: "flex-start" + }, + fieldRowNoBorder: { + borderBottomWidth: 0 + }, + fieldLabel: { + flex: 4, + fontWeight: "bold", + color: "rgba(0, 0, 0, 0.87)" + }, + fieldValue: { + flex: 9 + }, + tableWrapper: { + marginTop: 8, + borderRadius: 8, + borderWidth: 1, + borderStyle: "solid", + borderColor: "#eee", + backgroundColor: "#ffffff" + }, + table: { + fontFamily, + borderRadius: 8, + overflow: "hidden" + }, + thRow: { + flexDirection: "row", + backgroundColor: "#f0f0f0", + paddingVertical: 12, + paddingHorizontal: 12, + alignItems: "center", + borderBottomWidth: 1, + borderBottomStyle: "solid", + borderBottomColor: "#eee" + }, + thText: { + fontFamily, + fontSize: 7, + fontWeight: 600, + color: "#333333" + }, + tdRow: { + backgroundColor: "#FFFFFF", + flexDirection: "row", + paddingHorizontal: 12, + paddingVertical: 14, + alignItems: "center", + borderBottomWidth: 1, + borderBottomStyle: "solid", + borderBottomColor: "#eee" + }, + tdPayment: { + flexDirection: "row", + paddingHorizontal: 12, + paddingVertical: 14, + alignItems: "flex-start", + backgroundColor: "#f0fdf4", + borderBottomWidth: 1, + borderBottomStyle: "solid", + borderBottomColor: "#eee" + }, + tdRefund: { + flexDirection: "row", + paddingHorizontal: 12, + paddingVertical: 14, + alignItems: "flex-start", + backgroundColor: "#fff7ed", + borderBottomWidth: 1, + borderBottomStyle: "solid", + borderBottomColor: "#eee" + }, + tdNote: { + flexDirection: "row", + paddingHorizontal: 12, + paddingVertical: 10, + alignItems: "flex-start", + backgroundColor: "#FFFFFF", + borderBottomWidth: 1, + borderBottomStyle: "solid", + borderBottomColor: "#eee" + }, + tdAmountDue: { + flexDirection: "row", + paddingHorizontal: 12, + paddingVertical: 14, + alignItems: "center", + backgroundColor: "#fafafa", + borderTopWidth: 1, + borderTopColor: "#000000" + }, + colCode: { width: "12%", fontSize: 8 }, + colCodePayment: { width: "12%", fontSize: 8, fontWeight: "bold" }, + colType: { width: "15%", fontSize: 8 }, + colDesc: { width: "39%", fontSize: 8 }, + colDescMultiLine: { + width: "39%", + flexDirection: "column", + justifyContent: "flex-start" + }, + colPrice: { width: "18%", textAlign: "right", fontSize: 8, color: "#333333" }, + colPriceRefund: { + fontFamily, + width: "18%", + textAlign: "right", + fontSize: 8, + fontWeight: "bold", + color: "#e65100" + }, + colPricePayment: { + fontFamily, + width: "18%", + textAlign: "right", + fontSize: 8, + fontWeight: "bold", + color: "#1b5e20" + }, + colBalance: { + width: "16%", + textAlign: "right", + fontSize: 8, + color: "#666666" + }, + colBalanceNegative: { color: "#dc2626" }, + tdAmountDueLabel: { + fontFamily, + width: "66%", + fontSize: 8, + fontWeight: 700 + }, + tdAmountDueValue: { + fontFamily, + width: "16%", + textAlign: "right", + fontSize: 9, + fontWeight: 700 + }, + paymentDesc: { fontFamily, fontSize: 8, fontWeight: "bold" }, + muted: { color: "#6C757D", fontSize: 8 }, + typeCell: { width: "15%", flexDirection: "row", alignItems: "center" }, + typeBadgeLabel: { fontSize: 8 }, + cancelledText: { color: "#9ca3af", textDecoration: "line-through" }, + reconciliationWrapper: { + flexDirection: "row", + paddingHorizontal: 12, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomStyle: "solid", + borderBottomColor: "#eee" + }, + reconciliationBox: { + width: "33%" + }, + reconciliationTitle: { + fontFamily, + fontSize: 8, + fontWeight: 600, + color: "#333333", + marginBottom: 6 + }, + reconciliationRow: { + flexDirection: "row", + justifyContent: "space-between", + paddingVertical: 2 + }, + reconciliationLabel: { + fontSize: 7, + color: "#6C757D" + }, + reconciliationValue: { + fontSize: 7, + color: "#333333", + textAlign: "right" + }, + reconciliationDivider: { + borderBottomWidth: 1, + borderBottomColor: "#dee2e6", + marginVertical: 4 + } +}); + +const createRowStyles = (styles) => ({ + fee: { + row: styles.tdRow, + code: styles.colCode, + descText: null, + price: styles.colPrice, + typeBadge: { + icon: "ArrowUpward", + iconColor: "#ff9800", + label: "Charge", + labelColor: "#333333" + } + }, + item: { + row: styles.tdRow, + code: styles.colCode, + descText: null, + price: styles.colPrice, + typeBadge: { + icon: "ArrowUpward", + iconColor: "#ff9800", + label: "Charge", + labelColor: "#333333" + } + }, + discount: { + row: styles.tdPayment, + code: styles.colCodePayment, + descText: styles.paymentDesc, + price: styles.colPricePayment, + typeBadge: { + icon: "ArrowDownward", + iconColor: "#4caf50", + label: "Discount", + labelColor: "#1b5e20" + } + }, + payment: { + row: styles.tdPayment, + code: styles.colCodePayment, + descText: styles.paymentDesc, + price: styles.colPricePayment, + typeBadge: { + icon: "ArrowDownward", + iconColor: "#4caf50", + label: "Payment", + labelColor: "#1b5e20" + } + }, + refund: { + row: styles.tdRefund, + code: styles.colCodePayment, + descText: styles.paymentDesc, + price: styles.colPriceRefund, + typeBadge: { + icon: "Refresh", + iconColor: "#ea580c", + label: "Refund", + labelColor: "#e65100" + } + }, + cancelled: { + row: styles.tdRow, + code: [styles.colCode, styles.cancelledText], + descText: styles.cancelledText, + price: [styles.colPrice, styles.cancelledText], + balance: [styles.colBalance, styles.cancelledText], + typeBadge: { + icon: "DoNotDisturb", + iconColor: "#9ca3af", + label: "Cancelled", + labelColor: "#9ca3af" + } + } +}); + +const PdfTableRow = ({ row, styles, rowStyles }) => { + // Notes span all columns, matching NotesRow in the table + if (row.type === "note") { + return ( + + NOTE + {row.content} + + ); + } + + const s = rowStyles[row.cancelled ? "cancelled" : row.type]; + return ( + + {row.code} + {s.typeBadge ? ( + + + + {s.typeBadge.label} + + + ) : ( + + )} + + + {row.type === "item" + ? `${row.description} - Total: ${row.qty}` + : row.description} + + {row.cancelledBy ? ( + {row.cancelledBy} + ) : null} + {row.subDescription ? ( + {row.subDescription} + ) : null} + + {row.price} + {row.balanceCents != null ? ( + + {fmtBalance(row.balanceCents)} + + ) : ( + + )} + + ); +}; + +const FieldRow = ({ styles, label, value, noBorder = false }) => ( + + {label ?? ""} + {value ?? ""} + +); + +// Uses order-level totals matching the table's ReconciliationBox props: +// cancelledTotal ← order.cancelled_total +// refundsTotal ← order.refunds_total +// retained ← order.retained +// credited ← order.credited_to_payment_method +const ReconciliationBlock = ({ + styles, + cancelledTotal, + refundsTotal, + retained, + credited +}) => { + const totalColor = retained > 0 ? "#c62828" : "#1b5e20"; + const totalLabel = + retained > 0 + ? "Retained as cancellation fee" + : credited > 0 + ? "Credited to Payment Method" + : "Balance"; + const totalValue = + retained > 0 ? retained : credited > 0 ? credited : retained; + + return ( + + + Reconciliation + + Cancelled + + {currencyAmountFromCents(cancelledTotal ?? 0)} + + + + Refunded + + {currencyAmountFromCents(refundsTotal ?? 0)} + + + + + {totalLabel} + + {currencyAmountFromCents(totalValue ?? 0)} + + + + + ); +}; + +// logoSrc/fontFamily are left to the consumer on purpose: this component is +// shared across apps with different branding/typefaces, so no logo image or +// font file ships with uicore. fontFamily defaults to a react-pdf built-in +// (no Font.register needed); pass a family already registered via +// Font.register in the consuming app to use a custom typeface. +export const OrderPdf = ({ + order, + summit, + logoSrc, + fontFamily = DEFAULT_FONT_FAMILY +}) => { + const styles = createStyles(fontFamily); + const rowStyles = createRowStyles(styles); + const rows = buildRows(order, summit); + + const { + amount_due: total = 0, + cancelled_total: cancelledTotal = 0, + refunds_total: refundsTotal = 0, + retained = 0, + credited_to_payment_method: credited = 0 + } = order || {}; + + const clientName = + order.client?.contact_name || order.purchased_by_full_name || ""; + const clientCompany = order.client?.company_name || ""; + const clientAddress = formatAddress(order.address) || "N/A"; + + return ( + + + {/* Row 1: Logo | Invoice title + Order / Date */} + + + {logoSrc ? : null} + + + Invoice + + + + + + {/* Row 2: Client + Address | Event + Venue + Address */} + + + + + + + + + + + + + {/* Table */} + + + + Code + Type + Details + Amount + Balance + + + {rows.map((row) => ( + + ))} + + + + + AMOUNT DUE + + 0 ? "#e65100" : "#1b5e20" + } + ]} + > + {fmtBalance(total ?? 0)} + + + + + + + ); +}; + +OrderPdf.propTypes = { + order: PropTypes.object.isRequired, + summit: PropTypes.object.isRequired, + logoSrc: PropTypes.string, + fontFamily: PropTypes.string +}; + +export const generateInvoicePDF = async ( + order, + summit, + { logoSrc, fontFamily } = {} +) => { + try { + const blob = await pdf( + + ).toBlob(); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `invoice-${order.number}.pdf` + .toLowerCase() + .replace(/\s+/g, "-"); + document.body.appendChild(link); + link.click(); + URL.revokeObjectURL(url); + document.body.removeChild(link); + } catch (error) { + console.error("Error generating invoice PDF:", error); + throw error; + } +}; + +export const previewPDF = async (order, summit, { logoSrc, fontFamily } = {}) => { + const blob = await pdf( + + ).toBlob(); + const url = URL.createObjectURL(blob); + window.open(url, "_blank"); +}; diff --git a/webpack.common.js b/webpack.common.js index 162da07a..ef4f11be 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -25,6 +25,7 @@ module.exports = { 'components/sections/panel': './src/components/sections/panel.js', 'components/simple-link-list': './src/components/simple-link-list/index.js', 'components/summit-dropdown': './src/components/summit-dropdown/index.js', + 'components/order-pdf': './src/components/order-pdf/index.js', 'components/table': './src/components/table/Table.js', 'components/table-editable': './src/components/table-editable/EditableTable.js', 'components/table-selectable': './src/components/table-selectable/SelectableTable.js', From 6b3dcda54441f5516508c19cecd3875269d1eae1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Fri, 3 Jul 2026 18:30:48 -0300 Subject: [PATCH 02/14] fix: adjust tests, fix bugs, change names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/components/index.js | 2 +- .../__tests__/order-invoice-pdf.test.js} | 2 +- .../{order-pdf => order-invoice-pdf}/index.js | 17 +++++++++++++++-- webpack.common.js | 2 +- 4 files changed, 18 insertions(+), 5 deletions(-) rename src/components/{__tests__/order-pdf-.test.js => order-invoice-pdf/__tests__/order-invoice-pdf.test.js} (99%) rename src/components/{order-pdf => order-invoice-pdf}/index.js (98%) diff --git a/src/components/index.js b/src/components/index.js index e0742e55..ec86ae89 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -26,7 +26,7 @@ export {default as Input} from './inputs/text-input' export {default as Panel} from './sections/panel' export {default as SimpleLinkList} from './simple-link-list' export {default as SummitDropdown} from './summit-dropdown' -export {OrderPdf, buildRows as buildOrderPdfRows, generateInvoicePDF, previewPDF} from './order-pdf' +export {OrderPdf, buildRows as buildOrderPdfRows, generateInvoicePDF, previewPDF} from './order-invoice-pdf' export {default as Table} from './table/Table' export {default as SortableTable} from './table-sortable/SortableTable' export {default as EditableTable} from './table-editable/EditableTable' diff --git a/src/components/__tests__/order-pdf-.test.js b/src/components/order-invoice-pdf/__tests__/order-invoice-pdf.test.js similarity index 99% rename from src/components/__tests__/order-pdf-.test.js rename to src/components/order-invoice-pdf/__tests__/order-invoice-pdf.test.js index 17178a0c..3cdea631 100644 --- a/src/components/__tests__/order-pdf-.test.js +++ b/src/components/order-invoice-pdf/__tests__/order-invoice-pdf.test.js @@ -13,7 +13,7 @@ import React from "react"; import { render } from "@testing-library/react"; -import { buildOrderPdfRows, OrderPdf } from "../index"; +import { buildRows, OrderPdf } from "../index"; jest.mock("@react-pdf/renderer", () => { const React = require("react"); diff --git a/src/components/order-pdf/index.js b/src/components/order-invoice-pdf/index.js similarity index 98% rename from src/components/order-pdf/index.js rename to src/components/order-invoice-pdf/index.js index 850601c8..a89a8322 100644 --- a/src/components/order-pdf/index.js +++ b/src/components/order-invoice-pdf/index.js @@ -13,6 +13,7 @@ import React from "react"; import PropTypes from "prop-types"; +import moment from "moment-timezone"; import { Document, Page, @@ -26,11 +27,23 @@ import { } from "@react-pdf/renderer"; import { currencyAmountFromCents } from "../../utils/money"; import { epochToMomentTimeZone } from "../../utils/methods"; +import { MILLISECONDS_IN_SECOND } from "../../utils/constants"; const DEFAULT_FONT_FAMILY = "Helvetica"; -const formatDate = (epoch, timeZoneId, format) => - epochToMomentTimeZone(epoch, timeZoneId).format(format); +export const formatDate = ( + date, + timeZone = "LOC", + format = "dddd Do h:mm a" +) => { + if (timeZone === "LOC") { + return moment(date * MILLISECONDS_IN_SECOND).format(format); + } + + return moment(date * MILLISECONDS_IN_SECOND) + .tz(timeZone) + .format(format); +}; const formatAddress = (address) => { if (!address) return ""; diff --git a/webpack.common.js b/webpack.common.js index ef4f11be..4dd4ae58 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -25,7 +25,7 @@ module.exports = { 'components/sections/panel': './src/components/sections/panel.js', 'components/simple-link-list': './src/components/simple-link-list/index.js', 'components/summit-dropdown': './src/components/summit-dropdown/index.js', - 'components/order-pdf': './src/components/order-pdf/index.js', + 'components/order-invoice-pdf': './src/components/order-invoice-pdf/index.js', 'components/table': './src/components/table/Table.js', 'components/table-editable': './src/components/table-editable/EditableTable.js', 'components/table-selectable': './src/components/table-selectable/SelectableTable.js', From fcf8c622150f8db73d92d4a73ddec0327a98676d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Fri, 3 Jul 2026 18:39:07 -0300 Subject: [PATCH 03/14] fix: clear unused import, add catch for missing date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/components/order-invoice-pdf/index.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/order-invoice-pdf/index.js b/src/components/order-invoice-pdf/index.js index a89a8322..104446ba 100644 --- a/src/components/order-invoice-pdf/index.js +++ b/src/components/order-invoice-pdf/index.js @@ -26,7 +26,6 @@ import { pdf } from "@react-pdf/renderer"; import { currencyAmountFromCents } from "../../utils/money"; -import { epochToMomentTimeZone } from "../../utils/methods"; import { MILLISECONDS_IN_SECOND } from "../../utils/constants"; const DEFAULT_FONT_FAMILY = "Helvetica"; @@ -36,6 +35,8 @@ export const formatDate = ( timeZone = "LOC", format = "dddd Do h:mm a" ) => { + if (!date) return ""; + if (timeZone === "LOC") { return moment(date * MILLISECONDS_IN_SECOND).format(format); } From 526d13e921c7922c8701c8c11803fcaa9c55015d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Tue, 7 Jul 2026 13:50:05 -0300 Subject: [PATCH 04/14] fix: split file for better readability, adjust test and functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- package.json | 2 +- src/components/index.js | 1 - .../__tests__/order-invoice-pdf.test.js | 3 +- .../order-invoice-pdf/components.js | 142 ++++ src/components/order-invoice-pdf/helpers.js | 195 +++++ src/components/order-invoice-pdf/index.js | 668 +----------------- src/components/order-invoice-pdf/styles.js | 330 +++++++++ 7 files changed, 696 insertions(+), 645 deletions(-) create mode 100644 src/components/order-invoice-pdf/components.js create mode 100644 src/components/order-invoice-pdf/helpers.js create mode 100644 src/components/order-invoice-pdf/styles.js diff --git a/package.json b/package.json index f9ae464e..02aea57a 100644 --- a/package.json +++ b/package.json @@ -126,7 +126,7 @@ "@mui/icons-material": "^6.4.3", "@mui/material": "^6.4.3", "@mui/x-date-pickers": "^7.26.0", - "@react-pdf/renderer": "^3.1.11 || ^4.0.0", + "@react-pdf/renderer": "^4.4.1", "@sentry/react": "^8.54.0", "@stripe/react-stripe-js": "^5.4.1", "@stripe/stripe-js": "^8.5.3", diff --git a/src/components/index.js b/src/components/index.js index ec86ae89..9970c329 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -134,7 +134,6 @@ export {MuiBaseCustomTheme} from './mui/MuiBaseCustomTheme' // export {default as ExtraQuestionsForm } from './extra-questions/index.js'; // export {default as GMap} from './google-map'; // export {default as TextEditorV2} from './inputs/editor-input-v2' -// export {default as TextEditorV3} from './inputs/editor-input-v3' // export {default as CompanyInputV2} from './inputs/company-input-v2.js' // export {default as MuiDndList} from './mui/dnd-list' // react-beautiful-dnd // export {default as MuiSortableTable} from './mui/sortable-table/mui-table-sortable' // react-beautiful-dnd 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 3cdea631..389f289c 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 @@ -309,7 +309,7 @@ describe("buildRows — balance accumulation", () => { const makeRenderOrder = (overrides = {}) => ({ number: "ORD-2026-001", total: 0, - purchased_date: "2026-01-15T10:00:00Z", + purchased_date: 1700000000, purchased_by_full_name: "Jane Doe", client: { contact_name: "Jane Doe", company_name: "Acme Corp" }, address: null, @@ -353,6 +353,7 @@ describe("OrderPdf — render", () => { expect(text).toContain("OpenStack Summit 2026"); expect(text).toContain("Main Hall"); expect(text).toContain("123 Expo Blvd"); + expect(text).toContain("V6B 1A1"); }); }); diff --git a/src/components/order-invoice-pdf/components.js b/src/components/order-invoice-pdf/components.js new file mode 100644 index 00000000..bb59d4cd --- /dev/null +++ b/src/components/order-invoice-pdf/components.js @@ -0,0 +1,142 @@ +/** + * 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 React from "react"; +import { View, Text, Svg, Path } from "@react-pdf/renderer"; +import { currencyAmountFromCents } from "../../utils/money"; +import { MUI_ICON_PATHS, PDF_ICON_SIZE, fmtBalance } from "./helpers"; + +export const PdfIcon = ({ name, color, size = PDF_ICON_SIZE }) => ( + + + +); + +export const FieldRow = ({ styles, label, value, noBorder = false }) => ( + + {label ?? ""} + {value ?? ""} + +); + +export const PdfTableRow = ({ row, styles, rowStyles }) => { + // Notes span all columns, matching NotesRow in the table + if (row.type === "note") { + return ( + + NOTE + {row.content} + + ); + } + + const s = rowStyles[row.cancelled ? "cancelled" : row.type]; + return ( + + {row.code} + {s.typeBadge ? ( + + + + {s.typeBadge.label} + + + ) : ( + + )} + + + {row.type === "item" + ? `${row.description} - Total: ${row.qty}` + : row.description} + + {row.cancelledBy ? ( + {row.cancelledBy} + ) : null} + {row.subDescription ? ( + {row.subDescription} + ) : null} + + {row.price} + {row.balanceCents != null ? ( + + {fmtBalance(row.balanceCents)} + + ) : ( + + )} + + ); +}; + +// Uses order-level totals matching the table's ReconciliationBox props: +// cancelledTotal ← order.cancelled_total +// refundsTotal ← order.refunds_total +// retained ← order.retained +// credited ← order.credited_to_payment_method +export const ReconciliationBlock = ({ + styles, + cancelledTotal, + refundsTotal, + retained, + credited +}) => { + const totalColor = retained > 0 ? "#c62828" : "#1b5e20"; + const totalLabel = + retained > 0 + ? "Retained as cancellation fee" + : credited > 0 + ? "Credited to Payment Method" + : "Balance"; + const totalValue = + retained > 0 ? retained : credited > 0 ? credited : retained; + + return ( + + + Reconciliation + + Cancelled + + {currencyAmountFromCents(cancelledTotal ?? 0)} + + + + Refunded + + {currencyAmountFromCents(refundsTotal ?? 0)} + + + + + {totalLabel} + + {currencyAmountFromCents(totalValue ?? 0)} + + + + + ); +}; diff --git a/src/components/order-invoice-pdf/helpers.js b/src/components/order-invoice-pdf/helpers.js new file mode 100644 index 00000000..a64cc791 --- /dev/null +++ b/src/components/order-invoice-pdf/helpers.js @@ -0,0 +1,195 @@ +/** + * 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 moment from "moment-timezone"; +import { currencyAmountFromCents } from "../../utils/money"; +import { MILLISECONDS_IN_SECOND } from "../../utils/constants"; + +export const DEFAULT_FONT_FAMILY = "Helvetica"; + +export const MUI_ICON_PATHS = { + ArrowUpward: "M4 12l1.41 1.41L11 7.83V20h2V7.83l5.58 5.59L20 12l-8-8-8 8z", + ArrowDownward: + "M20 12l-1.41-1.41L13 16.17V4h-2v12.17l-5.58-5.59L4 12l8 8 8-8z", + Refresh: + "M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z", + DoNotDisturb: + "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8 0-1.85.63-3.55 1.69-4.9L16.9 18.31C15.55 19.37 13.85 20 12 20zm6.31-3.1L7.1 5.69C8.45 4.63 10.15 4 12 4c4.42 0 8 3.58 8 8 0 1.85-.63 3.55-1.69 4.9z" +}; + +export const PDF_ICON_SIZE = 8; + +export const formatDate = ( + date, + timeZone = "LOC", + format = "dddd Do h:mm a" +) => { + if (!date) return ""; + + if (timeZone === "LOC") { + return moment(date * MILLISECONDS_IN_SECOND).format(format); + } + + return moment(date * MILLISECONDS_IN_SECOND) + .tz(timeZone) + .format(format); +}; + +export const formatAddress = (address) => { + if (!address) return ""; + return [ + address.address_1, + address.address_2, + address.city, + address.state, + address.zip_code ?? address.postal_code, + address.country + ] + .filter(Boolean) + .join(", "); +}; + +export const formatVenueName = (location) => { + if (!location) return ""; + return `${location?.short_name ?? ""}${location?.name ? ` (${location?.name})` : ""}`; +}; + +// Reads fontFamily off the consumer's MUI theme so it stays in sync with the +// rest of their app instead of being duplicated as a separate literal. Only +// the first name in the CSS font stack is usable here: react-pdf renders +// with fonts registered via Font.register, so this name must match a family +// already registered that way in the consuming app. +export const getThemeFontFamily = (theme) => { + const fontFamily = theme?.typography?.fontFamily; + if (!fontFamily) return DEFAULT_FONT_FAMILY; + return fontFamily.split(",")[0].trim().replace(/^['"]|['"]$/g, ""); +}; + +export const fmtBalance = (cents) => { + if (cents == null) return ""; + const abs = currencyAmountFromCents(Math.abs(cents)); + return cents < 0 ? `-${abs}` : abs; +}; + +export const buildRows = (order, summit) => { + const rows = []; + let balanceCents = 0; + + (order.forms || []).forEach((form) => { + (form.items || []) + .filter((item) => item.quantity) + .forEach((item) => { + // Cancelled is per-item + const cancelled = !!item.canceled_by_id; + const cancelledBy = cancelled + ? `Cancelled by ${item.canceled_by_full_name} on ${formatDate(item.canceled_at, summit.time_zone_id, "YYYY/MM/DD HH:mm")}` + : ""; + + // Cancelled items still accumulate + balanceCents += item.amount; + + rows.push({ + rowKey: `item-${item.line_id ?? item.id}`, + 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), + balanceCents, + cancelled, + cancelledBy + }); + }); + + const discountCents = form.discount_in_cents ?? 0; + if (discountCents) { + balanceCents -= discountCents; + rows.push({ + rowKey: `discount-${form.id}`, + type: "discount", + code: "DIS", + description: String(form.discount || ""), + addon: "", + qty: "", + price: currencyAmountFromCents(discountCents), + balanceCents + }); + } + }); + + (order.fees || []).forEach((fee) => { + balanceCents += fee.amount; + rows.push({ + rowKey: `fee-${fee.id}`, + type: "fee", + code: "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: "PAY", + description: `Paid via ${item.method || "card"}`, + subDescription: formatDate( + item.created, + summit.time_zone_id, + "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: "REF", + description: String(item.reason || "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; +}; diff --git a/src/components/order-invoice-pdf/index.js b/src/components/order-invoice-pdf/index.js index 104446ba..14628371 100644 --- a/src/components/order-invoice-pdf/index.js +++ b/src/components/order-invoice-pdf/index.js @@ -13,641 +13,28 @@ import React from "react"; import PropTypes from "prop-types"; -import moment from "moment-timezone"; +import { Document, Page, Text, View, Image, pdf } from "@react-pdf/renderer"; +import { createStyles, createRowStyles } from "./styles"; +import { FieldRow, PdfTableRow, ReconciliationBlock } from "./components"; import { - Document, - Page, - Text, - View, - Image, - Svg, - Path, - StyleSheet, - pdf -} from "@react-pdf/renderer"; -import { currencyAmountFromCents } from "../../utils/money"; -import { MILLISECONDS_IN_SECOND } from "../../utils/constants"; + buildRows, + formatDate, + formatAddress, + formatVenueName, + getThemeFontFamily, + fmtBalance +} from "./helpers"; -const DEFAULT_FONT_FAMILY = "Helvetica"; +export { buildRows, formatDate }; -export const formatDate = ( - date, - timeZone = "LOC", - format = "dddd Do h:mm a" -) => { - if (!date) return ""; - - if (timeZone === "LOC") { - return moment(date * MILLISECONDS_IN_SECOND).format(format); - } - - return moment(date * MILLISECONDS_IN_SECOND) - .tz(timeZone) - .format(format); -}; - -const formatAddress = (address) => { - if (!address) return ""; - return [ - address.address_1, - address.address_2, - address.city, - address.state, - address.zip_code, - address.country - ] - .filter(Boolean) - .join(", "); -}; - -const formatVenueName = (location) => { - if (!location) return ""; - return `${location?.short_name ?? ""}${location?.name ? ` (${location?.name})` : ""}`; -}; - -const MUI_ICON_PATHS = { - ArrowUpward: "M4 12l1.41 1.41L11 7.83V20h2V7.83l5.58 5.59L20 12l-8-8-8 8z", - ArrowDownward: - "M20 12l-1.41-1.41L13 16.17V4h-2v12.17l-5.58-5.59L4 12l8 8 8-8z", - Refresh: - "M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z", - DoNotDisturb: - "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8 0-1.85.63-3.55 1.69-4.9L16.9 18.31C15.55 19.37 13.85 20 12 20zm6.31-3.1L7.1 5.69C8.45 4.63 10.15 4 12 4c4.42 0 8 3.58 8 8 0 1.85-.63 3.55-1.69 4.9z" -}; - -const PDF_ICON_SIZE = 8; - -const PdfIcon = ({ name, color, size = PDF_ICON_SIZE }) => ( - - - -); - -export const buildRows = (order, summit) => { - const rows = []; - let balanceCents = 0; - - (order.forms || []).forEach((form) => { - (form.items || []) - .filter((item) => item.quantity) - .forEach((item) => { - // Cancelled is per-item - const cancelled = !!item.canceled_by_id; - const cancelledBy = cancelled - ? `Cancelled by ${item.canceled_by_full_name} on ${formatDate(item.canceled_at, summit.time_zone_id, "YYYY/MM/DD HH:mm")}` - : ""; - - // Cancelled items still accumulate - balanceCents += item.amount; - - rows.push({ - rowKey: `item-${item.line_id ?? item.id}`, - 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), - balanceCents, - cancelled, - cancelledBy - }); - }); - - const discountCents = form.discount_in_cents ?? 0; - if (discountCents) { - balanceCents -= discountCents; - rows.push({ - rowKey: `discount-${form.id}`, - type: "discount", - code: "DIS", - description: String(form.discount || ""), - addon: "", - qty: "", - price: currencyAmountFromCents(discountCents), - balanceCents - }); - } - }); - - (order.fees || []).forEach((fee) => { - balanceCents += fee.amount; - rows.push({ - rowKey: `fee-${fee.id}`, - type: "fee", - code: "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: "PAY", - description: `Paid via ${item.method || "card"}`, - subDescription: formatDate( - item.created, - summit.time_zone_id, - "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: "REF", - description: String(item.reason || "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; -}; - -const fmtBalance = (cents) => { - if (cents == null) return ""; - const abs = currencyAmountFromCents(Math.abs(cents)); - return cents < 0 ? `-${abs}` : abs; -}; - -// Parameterized by fontFamily so a consumer's custom typeface (registered via -// Font.register in their own app) applies to every text style explicitly, -// not just the ones a consumer happens to remember to override. -const createStyles = (fontFamily) => StyleSheet.create({ - page: { - backgroundColor: "#F7F7F8", - padding: 64, - fontFamily, - fontSize: 8, - color: "#212529" - }, - headerRow: { - flexDirection: "row", - marginBottom: 20, - gap: 20 - }, - cellLeft: { - flex: 1, - backgroundColor: "#ffffff", - borderRadius: 10, - borderWidth: 1, - borderColor: "#DEE2E6", - paddingHorizontal: 18, - paddingVertical: 10, - justifyContent: "center" - }, - logo: { - width: "100%", - height: 84, - objectFit: "contain", - marginBottom: 0, - paddingBottom: 0 - }, - cellRight: { - flex: 1, - backgroundColor: "#ffffff", - borderRadius: 10, - borderWidth: 1, - borderColor: "#DEE2E6", - paddingHorizontal: 18, - paddingVertical: 10 - }, - invoiceTitle: { - fontFamily, - fontWeight: "bold", - fontSize: 14, - lineHeight: 1.5, - letterSpacing: 0, - color: "rgba(17, 19, 21, 1)", - marginBottom: 8 - }, - fieldRow: { - fontSize: 8, - lineHeight: 1.8, - flexDirection: "row", - color: "rgba(0, 0, 0, 0.87)", - paddingBottom: 3, - paddingTop: 6, - borderBottom: "1px solid rgba(224, 224, 224, 1)", - alignContent: "flex-start" - }, - fieldRowNoBorder: { - borderBottomWidth: 0 - }, - fieldLabel: { - flex: 4, - fontWeight: "bold", - color: "rgba(0, 0, 0, 0.87)" - }, - fieldValue: { - flex: 9 - }, - tableWrapper: { - marginTop: 8, - borderRadius: 8, - borderWidth: 1, - borderStyle: "solid", - borderColor: "#eee", - backgroundColor: "#ffffff" - }, - table: { - fontFamily, - borderRadius: 8, - overflow: "hidden" - }, - thRow: { - flexDirection: "row", - backgroundColor: "#f0f0f0", - paddingVertical: 12, - paddingHorizontal: 12, - alignItems: "center", - borderBottomWidth: 1, - borderBottomStyle: "solid", - borderBottomColor: "#eee" - }, - thText: { - fontFamily, - fontSize: 7, - fontWeight: 600, - color: "#333333" - }, - tdRow: { - backgroundColor: "#FFFFFF", - flexDirection: "row", - paddingHorizontal: 12, - paddingVertical: 14, - alignItems: "center", - borderBottomWidth: 1, - borderBottomStyle: "solid", - borderBottomColor: "#eee" - }, - tdPayment: { - flexDirection: "row", - paddingHorizontal: 12, - paddingVertical: 14, - alignItems: "flex-start", - backgroundColor: "#f0fdf4", - borderBottomWidth: 1, - borderBottomStyle: "solid", - borderBottomColor: "#eee" - }, - tdRefund: { - flexDirection: "row", - paddingHorizontal: 12, - paddingVertical: 14, - alignItems: "flex-start", - backgroundColor: "#fff7ed", - borderBottomWidth: 1, - borderBottomStyle: "solid", - borderBottomColor: "#eee" - }, - tdNote: { - flexDirection: "row", - paddingHorizontal: 12, - paddingVertical: 10, - alignItems: "flex-start", - backgroundColor: "#FFFFFF", - borderBottomWidth: 1, - borderBottomStyle: "solid", - borderBottomColor: "#eee" - }, - tdAmountDue: { - flexDirection: "row", - paddingHorizontal: 12, - paddingVertical: 14, - alignItems: "center", - backgroundColor: "#fafafa", - borderTopWidth: 1, - borderTopColor: "#000000" - }, - colCode: { width: "12%", fontSize: 8 }, - colCodePayment: { width: "12%", fontSize: 8, fontWeight: "bold" }, - colType: { width: "15%", fontSize: 8 }, - colDesc: { width: "39%", fontSize: 8 }, - colDescMultiLine: { - width: "39%", - flexDirection: "column", - justifyContent: "flex-start" - }, - colPrice: { width: "18%", textAlign: "right", fontSize: 8, color: "#333333" }, - colPriceRefund: { - fontFamily, - width: "18%", - textAlign: "right", - fontSize: 8, - fontWeight: "bold", - color: "#e65100" - }, - colPricePayment: { - fontFamily, - width: "18%", - textAlign: "right", - fontSize: 8, - fontWeight: "bold", - color: "#1b5e20" - }, - colBalance: { - width: "16%", - textAlign: "right", - fontSize: 8, - color: "#666666" - }, - colBalanceNegative: { color: "#dc2626" }, - tdAmountDueLabel: { - fontFamily, - width: "66%", - fontSize: 8, - fontWeight: 700 - }, - tdAmountDueValue: { - fontFamily, - width: "16%", - textAlign: "right", - fontSize: 9, - fontWeight: 700 - }, - paymentDesc: { fontFamily, fontSize: 8, fontWeight: "bold" }, - muted: { color: "#6C757D", fontSize: 8 }, - typeCell: { width: "15%", flexDirection: "row", alignItems: "center" }, - typeBadgeLabel: { fontSize: 8 }, - cancelledText: { color: "#9ca3af", textDecoration: "line-through" }, - reconciliationWrapper: { - flexDirection: "row", - paddingHorizontal: 12, - paddingVertical: 12, - borderBottomWidth: 1, - borderBottomStyle: "solid", - borderBottomColor: "#eee" - }, - reconciliationBox: { - width: "33%" - }, - reconciliationTitle: { - fontFamily, - fontSize: 8, - fontWeight: 600, - color: "#333333", - marginBottom: 6 - }, - reconciliationRow: { - flexDirection: "row", - justifyContent: "space-between", - paddingVertical: 2 - }, - reconciliationLabel: { - fontSize: 7, - color: "#6C757D" - }, - reconciliationValue: { - fontSize: 7, - color: "#333333", - textAlign: "right" - }, - reconciliationDivider: { - borderBottomWidth: 1, - borderBottomColor: "#dee2e6", - marginVertical: 4 - } -}); - -const createRowStyles = (styles) => ({ - fee: { - row: styles.tdRow, - code: styles.colCode, - descText: null, - price: styles.colPrice, - typeBadge: { - icon: "ArrowUpward", - iconColor: "#ff9800", - label: "Charge", - labelColor: "#333333" - } - }, - item: { - row: styles.tdRow, - code: styles.colCode, - descText: null, - price: styles.colPrice, - typeBadge: { - icon: "ArrowUpward", - iconColor: "#ff9800", - label: "Charge", - labelColor: "#333333" - } - }, - discount: { - row: styles.tdPayment, - code: styles.colCodePayment, - descText: styles.paymentDesc, - price: styles.colPricePayment, - typeBadge: { - icon: "ArrowDownward", - iconColor: "#4caf50", - label: "Discount", - labelColor: "#1b5e20" - } - }, - payment: { - row: styles.tdPayment, - code: styles.colCodePayment, - descText: styles.paymentDesc, - price: styles.colPricePayment, - typeBadge: { - icon: "ArrowDownward", - iconColor: "#4caf50", - label: "Payment", - labelColor: "#1b5e20" - } - }, - refund: { - row: styles.tdRefund, - code: styles.colCodePayment, - descText: styles.paymentDesc, - price: styles.colPriceRefund, - typeBadge: { - icon: "Refresh", - iconColor: "#ea580c", - label: "Refund", - labelColor: "#e65100" - } - }, - cancelled: { - row: styles.tdRow, - code: [styles.colCode, styles.cancelledText], - descText: styles.cancelledText, - price: [styles.colPrice, styles.cancelledText], - balance: [styles.colBalance, styles.cancelledText], - typeBadge: { - icon: "DoNotDisturb", - iconColor: "#9ca3af", - label: "Cancelled", - labelColor: "#9ca3af" - } - } -}); - -const PdfTableRow = ({ row, styles, rowStyles }) => { - // Notes span all columns, matching NotesRow in the table - if (row.type === "note") { - return ( - - NOTE - {row.content} - - ); - } - - const s = rowStyles[row.cancelled ? "cancelled" : row.type]; - return ( - - {row.code} - {s.typeBadge ? ( - - - - {s.typeBadge.label} - - - ) : ( - - )} - - - {row.type === "item" - ? `${row.description} - Total: ${row.qty}` - : row.description} - - {row.cancelledBy ? ( - {row.cancelledBy} - ) : null} - {row.subDescription ? ( - {row.subDescription} - ) : null} - - {row.price} - {row.balanceCents != null ? ( - - {fmtBalance(row.balanceCents)} - - ) : ( - - )} - - ); -}; - -const FieldRow = ({ styles, label, value, noBorder = false }) => ( - - {label ?? ""} - {value ?? ""} - -); - -// Uses order-level totals matching the table's ReconciliationBox props: -// cancelledTotal ← order.cancelled_total -// refundsTotal ← order.refunds_total -// retained ← order.retained -// credited ← order.credited_to_payment_method -const ReconciliationBlock = ({ - styles, - cancelledTotal, - refundsTotal, - retained, - credited -}) => { - const totalColor = retained > 0 ? "#c62828" : "#1b5e20"; - const totalLabel = - retained > 0 - ? "Retained as cancellation fee" - : credited > 0 - ? "Credited to Payment Method" - : "Balance"; - const totalValue = - retained > 0 ? retained : credited > 0 ? credited : retained; - - return ( - - - Reconciliation - - Cancelled - - {currencyAmountFromCents(cancelledTotal ?? 0)} - - - - Refunded - - {currencyAmountFromCents(refundsTotal ?? 0)} - - - - - {totalLabel} - - {currencyAmountFromCents(totalValue ?? 0)} - - - - - ); -}; - -// logoSrc/fontFamily are left to the consumer on purpose: this component is +// logoSrc/theme are left to the consumer on purpose: this component is // shared across apps with different branding/typefaces, so no logo image or -// font file ships with uicore. fontFamily defaults to a react-pdf built-in -// (no Font.register needed); pass a family already registered via -// Font.register in the consuming app to use a custom typeface. -export const OrderPdf = ({ - order, - summit, - logoSrc, - fontFamily = DEFAULT_FONT_FAMILY -}) => { +// font file ships with uicore. fontFamily is read off theme.typography and +// falls back to a react-pdf built-in (no Font.register needed) when absent; +// the theme's fontFamily must already be registered via Font.register in +// the consuming app to render as a custom typeface instead of falling back. +export const OrderPdf = ({ order, summit, logoSrc, theme }) => { + const fontFamily = getThemeFontFamily(theme); const styles = createStyles(fontFamily); const rowStyles = createRowStyles(styles); const rows = buildRows(order, summit); @@ -773,13 +160,13 @@ OrderPdf.propTypes = { order: PropTypes.object.isRequired, summit: PropTypes.object.isRequired, logoSrc: PropTypes.string, - fontFamily: PropTypes.string + theme: PropTypes.object }; export const generateInvoicePDF = async ( order, summit, - { logoSrc, fontFamily } = {} + { logoSrc, theme } = {} ) => { try { const blob = await pdf( @@ -787,7 +174,7 @@ export const generateInvoicePDF = async ( order={order} summit={summit} logoSrc={logoSrc} - fontFamily={fontFamily} + theme={theme} /> ).toBlob(); const url = URL.createObjectURL(blob); @@ -806,15 +193,12 @@ export const generateInvoicePDF = async ( } }; -export const previewPDF = async (order, summit, { logoSrc, fontFamily } = {}) => { +export const previewPDF = async (order, summit, { logoSrc, theme } = {}) => { const blob = await pdf( - + ).toBlob(); const url = URL.createObjectURL(blob); - window.open(url, "_blank"); + window.open(url, "_blank", "noopener,noreferrer"); + // Revoke after the new tab has had a chance to load the PDF. + setTimeout(() => URL.revokeObjectURL(url), 60_000); }; diff --git a/src/components/order-invoice-pdf/styles.js b/src/components/order-invoice-pdf/styles.js new file mode 100644 index 00000000..6a6d110a --- /dev/null +++ b/src/components/order-invoice-pdf/styles.js @@ -0,0 +1,330 @@ +/** + * 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 { StyleSheet } from "@react-pdf/renderer"; + +// Parameterized by fontFamily so a consumer's custom typeface (registered via +// Font.register in their own app) applies to every text style explicitly, +// not just the ones a consumer happens to remember to override. +export const createStyles = (fontFamily) => StyleSheet.create({ + page: { + backgroundColor: "#F7F7F8", + padding: 64, + fontFamily, + fontSize: 8, + color: "#212529" + }, + headerRow: { + flexDirection: "row", + marginBottom: 20, + gap: 20 + }, + cellLeft: { + flex: 1, + backgroundColor: "#ffffff", + borderRadius: 10, + borderWidth: 1, + borderColor: "#DEE2E6", + paddingHorizontal: 18, + paddingVertical: 10, + justifyContent: "center" + }, + logo: { + width: "100%", + height: 84, + objectFit: "contain", + marginBottom: 0, + paddingBottom: 0 + }, + cellRight: { + flex: 1, + backgroundColor: "#ffffff", + borderRadius: 10, + borderWidth: 1, + borderColor: "#DEE2E6", + paddingHorizontal: 18, + paddingVertical: 10 + }, + invoiceTitle: { + fontFamily, + fontWeight: "bold", + fontSize: 14, + lineHeight: 1.5, + letterSpacing: 0, + color: "rgba(17, 19, 21, 1)", + marginBottom: 8 + }, + fieldRow: { + fontSize: 8, + lineHeight: 1.8, + flexDirection: "row", + color: "rgba(0, 0, 0, 0.87)", + paddingBottom: 3, + paddingTop: 6, + borderBottom: "1px solid rgba(224, 224, 224, 1)", + alignContent: "flex-start" + }, + fieldRowNoBorder: { + borderBottomWidth: 0 + }, + fieldLabel: { + flex: 4, + fontWeight: "bold", + color: "rgba(0, 0, 0, 0.87)" + }, + fieldValue: { + flex: 9 + }, + tableWrapper: { + marginTop: 8, + borderRadius: 8, + borderWidth: 1, + borderStyle: "solid", + borderColor: "#eee", + backgroundColor: "#ffffff" + }, + table: { + fontFamily, + borderRadius: 8, + overflow: "hidden" + }, + thRow: { + flexDirection: "row", + backgroundColor: "#f0f0f0", + paddingVertical: 12, + paddingHorizontal: 12, + alignItems: "center", + borderBottomWidth: 1, + borderBottomStyle: "solid", + borderBottomColor: "#eee" + }, + thText: { + fontFamily, + fontSize: 7, + fontWeight: 600, + color: "#333333" + }, + tdRow: { + backgroundColor: "#FFFFFF", + flexDirection: "row", + paddingHorizontal: 12, + paddingVertical: 14, + alignItems: "center", + borderBottomWidth: 1, + borderBottomStyle: "solid", + borderBottomColor: "#eee" + }, + tdPayment: { + flexDirection: "row", + paddingHorizontal: 12, + paddingVertical: 14, + alignItems: "flex-start", + backgroundColor: "#f0fdf4", + borderBottomWidth: 1, + borderBottomStyle: "solid", + borderBottomColor: "#eee" + }, + tdRefund: { + flexDirection: "row", + paddingHorizontal: 12, + paddingVertical: 14, + alignItems: "flex-start", + backgroundColor: "#fff7ed", + borderBottomWidth: 1, + borderBottomStyle: "solid", + borderBottomColor: "#eee" + }, + tdNote: { + flexDirection: "row", + paddingHorizontal: 12, + paddingVertical: 10, + alignItems: "flex-start", + backgroundColor: "#FFFFFF", + borderBottomWidth: 1, + borderBottomStyle: "solid", + borderBottomColor: "#eee" + }, + tdAmountDue: { + flexDirection: "row", + paddingHorizontal: 12, + paddingVertical: 14, + alignItems: "center", + backgroundColor: "#fafafa", + borderTopWidth: 1, + borderTopColor: "#000000" + }, + colCode: { width: "12%", fontSize: 8 }, + colCodePayment: { width: "12%", fontSize: 8, fontWeight: "bold" }, + colType: { width: "15%", fontSize: 8 }, + colDesc: { width: "39%", fontSize: 8 }, + colDescMultiLine: { + width: "39%", + flexDirection: "column", + justifyContent: "flex-start" + }, + colPrice: { width: "18%", textAlign: "right", fontSize: 8, color: "#333333" }, + colPriceRefund: { + fontFamily, + width: "18%", + textAlign: "right", + fontSize: 8, + fontWeight: "bold", + color: "#e65100" + }, + colPricePayment: { + fontFamily, + width: "18%", + textAlign: "right", + fontSize: 8, + fontWeight: "bold", + color: "#1b5e20" + }, + colBalance: { + width: "16%", + textAlign: "right", + fontSize: 8, + color: "#666666" + }, + colBalanceNegative: { color: "#dc2626" }, + tdAmountDueLabel: { + fontFamily, + width: "66%", + fontSize: 8, + fontWeight: 700 + }, + tdAmountDueValue: { + fontFamily, + width: "16%", + textAlign: "right", + fontSize: 9, + fontWeight: 700 + }, + paymentDesc: { fontFamily, fontSize: 8, fontWeight: "bold" }, + muted: { color: "#6C757D", fontSize: 8 }, + typeCell: { width: "15%", flexDirection: "row", alignItems: "center" }, + typeBadgeLabel: { fontSize: 8 }, + cancelledText: { color: "#9ca3af", textDecoration: "line-through" }, + reconciliationWrapper: { + flexDirection: "row", + paddingHorizontal: 12, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomStyle: "solid", + borderBottomColor: "#eee" + }, + reconciliationBox: { + width: "33%" + }, + reconciliationTitle: { + fontFamily, + fontSize: 8, + fontWeight: 600, + color: "#333333", + marginBottom: 6 + }, + reconciliationRow: { + flexDirection: "row", + justifyContent: "space-between", + paddingVertical: 2 + }, + reconciliationLabel: { + fontSize: 7, + color: "#6C757D" + }, + reconciliationValue: { + fontSize: 7, + color: "#333333", + textAlign: "right" + }, + reconciliationDivider: { + borderBottomWidth: 1, + borderBottomColor: "#dee2e6", + marginVertical: 4 + } +}); + +export const createRowStyles = (styles) => ({ + fee: { + row: styles.tdRow, + code: styles.colCode, + descText: null, + price: styles.colPrice, + typeBadge: { + icon: "ArrowUpward", + iconColor: "#ff9800", + label: "Charge", + labelColor: "#333333" + } + }, + item: { + row: styles.tdRow, + code: styles.colCode, + descText: null, + price: styles.colPrice, + typeBadge: { + icon: "ArrowUpward", + iconColor: "#ff9800", + label: "Charge", + labelColor: "#333333" + } + }, + discount: { + row: styles.tdPayment, + code: styles.colCodePayment, + descText: styles.paymentDesc, + price: styles.colPricePayment, + typeBadge: { + icon: "ArrowDownward", + iconColor: "#4caf50", + label: "Discount", + labelColor: "#1b5e20" + } + }, + payment: { + row: styles.tdPayment, + code: styles.colCodePayment, + descText: styles.paymentDesc, + price: styles.colPricePayment, + typeBadge: { + icon: "ArrowDownward", + iconColor: "#4caf50", + label: "Payment", + labelColor: "#1b5e20" + } + }, + refund: { + row: styles.tdRefund, + code: styles.colCodePayment, + descText: styles.paymentDesc, + price: styles.colPriceRefund, + typeBadge: { + icon: "Refresh", + iconColor: "#ea580c", + label: "Refund", + labelColor: "#e65100" + } + }, + cancelled: { + row: styles.tdRow, + code: [styles.colCode, styles.cancelledText], + descText: styles.cancelledText, + price: [styles.colPrice, styles.cancelledText], + balance: [styles.colBalance, styles.cancelledText], + typeBadge: { + icon: "DoNotDisturb", + iconColor: "#9ca3af", + label: "Cancelled", + labelColor: "#9ca3af" + } + } +}); From c173983a46a7591dd314575819eb6d16eb050eed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Tue, 7 Jul 2026 14:00:58 -0300 Subject: [PATCH 05/14] fix: adjust export on component index file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/components/index.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/index.js b/src/components/index.js index 9970c329..b77daffd 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -26,7 +26,6 @@ export {default as Input} from './inputs/text-input' export {default as Panel} from './sections/panel' export {default as SimpleLinkList} from './simple-link-list' export {default as SummitDropdown} from './summit-dropdown' -export {OrderPdf, buildRows as buildOrderPdfRows, generateInvoicePDF, previewPDF} from './order-invoice-pdf' export {default as Table} from './table/Table' export {default as SortableTable} from './table-sortable/SortableTable' export {default as EditableTable} from './table-editable/EditableTable' @@ -134,6 +133,7 @@ export {MuiBaseCustomTheme} from './mui/MuiBaseCustomTheme' // export {default as ExtraQuestionsForm } from './extra-questions/index.js'; // export {default as GMap} from './google-map'; // export {default as TextEditorV2} from './inputs/editor-input-v2' +// export {default as TextEditorV3} from './inputs/editor-input-v3' // export {default as CompanyInputV2} from './inputs/company-input-v2.js' // export {default as MuiDndList} from './mui/dnd-list' // react-beautiful-dnd // export {default as MuiSortableTable} from './mui/sortable-table/mui-table-sortable' // react-beautiful-dnd @@ -141,3 +141,4 @@ export {MuiBaseCustomTheme} from './mui/MuiBaseCustomTheme' // export {default as MuiAdditionalInput} from './mui/formik-inputs/additional-input/additional-input' // react-beautiful-dnd (via dnd-list) // export {default as MuiAdditionalInputList} from './mui/formik-inputs/additional-input/additional-input-list' // react-beautiful-dnd (via dnd-list) // export {default as MuiDragNDropList} from './mui/DragNDropList' // @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities +// export {OrderPdf, buildRows as buildOrderPdfRows, generateInvoicePDF, previewPDF} from './order-invoice-pdf' // @react-pdf/renderer From a79ce705c379217302477aa2227bfc32dfdb0633 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Tue, 7 Jul 2026 14:19:37 -0300 Subject: [PATCH 06/14] fix: throw error if order or summit is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- .../__tests__/order-invoice-pdf.test.js | 9 +++++++++ src/components/order-invoice-pdf/index.js | 5 ++++- 2 files changed, 13 insertions(+), 1 deletion(-) 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 389f289c..1a254b1e 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 @@ -355,6 +355,15 @@ describe("OrderPdf — render", () => { expect(text).toContain("123 Expo Blvd"); expect(text).toContain("V6B 1A1"); }); + + it("throws a clear error when order or summit are omitted", () => { + expect(() => render()).toThrow( + "OrderPdf: order is required" + ); + expect(() => render()).toThrow( + "OrderPdf: summit is required" + ); + }); }); // ─── Reconciliation block ───────────────────────────────────────────────────── diff --git a/src/components/order-invoice-pdf/index.js b/src/components/order-invoice-pdf/index.js index 14628371..fb907c2d 100644 --- a/src/components/order-invoice-pdf/index.js +++ b/src/components/order-invoice-pdf/index.js @@ -34,6 +34,9 @@ export { buildRows, formatDate }; // the theme's fontFamily must already be registered via Font.register in // the consuming app to render as a custom typeface instead of falling back. export const OrderPdf = ({ order, summit, logoSrc, theme }) => { + if (!order) throw new Error("OrderPdf: order is required"); + if (!summit) throw new Error("OrderPdf: summit is required"); + const fontFamily = getThemeFontFamily(theme); const styles = createStyles(fontFamily); const rowStyles = createRowStyles(styles); @@ -45,7 +48,7 @@ export const OrderPdf = ({ order, summit, logoSrc, theme }) => { refunds_total: refundsTotal = 0, retained = 0, credited_to_payment_method: credited = 0 - } = order || {}; + } = order; const clientName = order.client?.contact_name || order.purchased_by_full_name || ""; From 51d2b0854e3477b93b02812e44010f3a784379a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Tue, 14 Jul 2026 04:00:26 -0300 Subject: [PATCH 07/14] fix: update yarn.lock, new components files, adjust code for functions, add tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- .../__tests__/order-invoice-pdf.test.js | 157 ++++++++- .../order-invoice-pdf/components.js | 142 -------- .../order-invoice-pdf/components/field-row.js | 22 ++ .../order-invoice-pdf/components/pdf-icon.js | 38 +++ .../components/pdf-table-row.js | 74 ++++ .../components/reconciliation-block.js | 66 ++++ src/components/order-invoice-pdf/helpers.js | 12 - src/components/order-invoice-pdf/index.js | 43 ++- yarn.lock | 318 ++++++++++-------- 9 files changed, 569 insertions(+), 303 deletions(-) delete mode 100644 src/components/order-invoice-pdf/components.js create mode 100644 src/components/order-invoice-pdf/components/field-row.js create mode 100644 src/components/order-invoice-pdf/components/pdf-icon.js create mode 100644 src/components/order-invoice-pdf/components/pdf-table-row.js create mode 100644 src/components/order-invoice-pdf/components/reconciliation-block.js 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 1a254b1e..9c4ac122 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 @@ -13,7 +13,8 @@ import React from "react"; import { render } from "@testing-library/react"; -import { buildRows, OrderPdf } from "../index"; +import { pdf } from "@react-pdf/renderer"; +import { buildRows, OrderPdf, generateInvoicePDF, previewPDF } from "../index"; jest.mock("@react-pdf/renderer", () => { const React = require("react"); @@ -29,7 +30,7 @@ jest.mock("@react-pdf/renderer", () => { Path: () => null, StyleSheet: { create: (s) => s }, Font: { register: () => {} }, - pdf: () => ({ toBlob: async () => ({}) }) + pdf: jest.fn(() => ({ toBlob: async () => ({}) })) }; }); @@ -409,3 +410,155 @@ describe("OrderPdf — reconciliation block", () => { ); }); }); + +// ─── generateInvoicePDF ──────────────────────────────────────────────────── + +describe("generateInvoicePDF", () => { + let callOrder; + let clickSpy; + let appendChildSpy; + let removeChildSpy; + + beforeEach(() => { + jest.useFakeTimers(); + callOrder = []; + URL.createObjectURL = jest.fn(() => { + callOrder.push("createObjectURL"); + return "blob:mock-url"; + }); + URL.revokeObjectURL = jest.fn(() => callOrder.push("revokeObjectURL")); + clickSpy = jest + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(() => callOrder.push("click")); + appendChildSpy = jest.spyOn(document.body, "appendChild"); + removeChildSpy = jest.spyOn(document.body, "removeChild"); + }); + + afterEach(() => { + jest.useRealTimers(); + delete URL.createObjectURL; + delete URL.revokeObjectURL; + jest.restoreAllMocks(); + }); + + it("downloads the blob through a temporary link, then revokes the URL only after a delay", async () => { + await generateInvoicePDF( + makeRenderOrder({ number: "ORD 2026 001" }), + makeRenderSummit() + ); + + const link = appendChildSpy.mock.calls[0][0]; + expect(link.tagName).toBe("A"); + expect(link.href).toContain("blob:mock-url"); + expect(link.download).toBe("invoice-ord-2026-001.pdf"); + + expect(clickSpy).toHaveBeenCalledTimes(1); + expect(removeChildSpy).toHaveBeenCalledWith(link); + // revoking right after click() has been reported to produce empty/failed + // downloads on Safari and some Firefox versions, so it must be deferred + expect(callOrder).toEqual(["createObjectURL", "click"]); + expect(URL.revokeObjectURL).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(60_000); + expect(callOrder).toEqual(["createObjectURL", "click", "revokeObjectURL"]); + }); + + it("logs and rethrows when PDF generation fails, without touching the DOM", async () => { + const consoleErrorSpy = jest + .spyOn(console, "error") + .mockImplementation(() => {}); + pdf.mockReturnValueOnce({ + toBlob: () => Promise.reject(new Error("boom")) + }); + + await expect( + generateInvoicePDF(makeRenderOrder(), makeRenderSummit()) + ).rejects.toThrow("boom"); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + "Error generating invoice PDF:", + expect.any(Error) + ); + expect(appendChildSpy).not.toHaveBeenCalled(); + consoleErrorSpy.mockRestore(); + }); +}); + +// ─── previewPDF ───────────────────────────────────────────────────────────── + +describe("previewPDF", () => { + let originalOpen; + let fakeTab; + + beforeEach(() => { + jest.useFakeTimers(); + originalOpen = window.open; + fakeTab = { location: { href: "" } }; + URL.createObjectURL = jest.fn(() => "blob:mock-preview-url"); + URL.revokeObjectURL = jest.fn(); + window.open = jest.fn(() => fakeTab); + }); + + afterEach(() => { + jest.useRealTimers(); + delete URL.createObjectURL; + delete URL.revokeObjectURL; + window.open = originalOpen; + }); + + it("opens a blank tab synchronously (before the blob is ready) so Safari's popup blocker doesn't kill it", async () => { + const promise = previewPDF(makeRenderOrder(), makeRenderSummit()); + + // asserted before awaiting, i.e. before the pending PDF promise resolves + expect(window.open).toHaveBeenCalledWith("", "_blank", "noreferrer"); + + await promise; + }); + + it("navigates the pre-opened tab to the blob URL once it's ready", async () => { + await previewPDF(makeRenderOrder(), makeRenderSummit()); + + expect(fakeTab.location.href).toBe("blob:mock-preview-url"); + }); + + it("does not throw when the popup blocker returns null for the pre-opened tab", async () => { + window.open = jest.fn(() => null); + + await expect( + previewPDF(makeRenderOrder(), makeRenderSummit()) + ).resolves.toBeUndefined(); + }); + + it("logs, closes the pre-opened tab, and rethrows when PDF generation fails", async () => { + const consoleErrorSpy = jest + .spyOn(console, "error") + .mockImplementation(() => {}); + fakeTab.close = jest.fn(); + pdf.mockReturnValueOnce({ + toBlob: () => Promise.reject(new Error("boom")) + }); + + await expect( + previewPDF(makeRenderOrder(), makeRenderSummit()) + ).rejects.toThrow("boom"); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + "Error generating invoice PDF preview:", + expect.any(Error) + ); + expect(fakeTab.close).toHaveBeenCalledTimes(1); + consoleErrorSpy.mockRestore(); + }); + + it("keeps the URL alive until the new tab has had time to load it", async () => { + await previewPDF(makeRenderOrder(), makeRenderSummit()); + + expect(URL.revokeObjectURL).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(59_999); + expect(URL.revokeObjectURL).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(1); + expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:mock-preview-url"); + }); +}); diff --git a/src/components/order-invoice-pdf/components.js b/src/components/order-invoice-pdf/components.js deleted file mode 100644 index bb59d4cd..00000000 --- a/src/components/order-invoice-pdf/components.js +++ /dev/null @@ -1,142 +0,0 @@ -/** - * 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 React from "react"; -import { View, Text, Svg, Path } from "@react-pdf/renderer"; -import { currencyAmountFromCents } from "../../utils/money"; -import { MUI_ICON_PATHS, PDF_ICON_SIZE, fmtBalance } from "./helpers"; - -export const PdfIcon = ({ name, color, size = PDF_ICON_SIZE }) => ( - - - -); - -export const FieldRow = ({ styles, label, value, noBorder = false }) => ( - - {label ?? ""} - {value ?? ""} - -); - -export const PdfTableRow = ({ row, styles, rowStyles }) => { - // Notes span all columns, matching NotesRow in the table - if (row.type === "note") { - return ( - - NOTE - {row.content} - - ); - } - - const s = rowStyles[row.cancelled ? "cancelled" : row.type]; - return ( - - {row.code} - {s.typeBadge ? ( - - - - {s.typeBadge.label} - - - ) : ( - - )} - - - {row.type === "item" - ? `${row.description} - Total: ${row.qty}` - : row.description} - - {row.cancelledBy ? ( - {row.cancelledBy} - ) : null} - {row.subDescription ? ( - {row.subDescription} - ) : null} - - {row.price} - {row.balanceCents != null ? ( - - {fmtBalance(row.balanceCents)} - - ) : ( - - )} - - ); -}; - -// Uses order-level totals matching the table's ReconciliationBox props: -// cancelledTotal ← order.cancelled_total -// refundsTotal ← order.refunds_total -// retained ← order.retained -// credited ← order.credited_to_payment_method -export const ReconciliationBlock = ({ - styles, - cancelledTotal, - refundsTotal, - retained, - credited -}) => { - const totalColor = retained > 0 ? "#c62828" : "#1b5e20"; - const totalLabel = - retained > 0 - ? "Retained as cancellation fee" - : credited > 0 - ? "Credited to Payment Method" - : "Balance"; - const totalValue = - retained > 0 ? retained : credited > 0 ? credited : retained; - - return ( - - - Reconciliation - - Cancelled - - {currencyAmountFromCents(cancelledTotal ?? 0)} - - - - Refunded - - {currencyAmountFromCents(refundsTotal ?? 0)} - - - - - {totalLabel} - - {currencyAmountFromCents(totalValue ?? 0)} - - - - - ); -}; diff --git a/src/components/order-invoice-pdf/components/field-row.js b/src/components/order-invoice-pdf/components/field-row.js new file mode 100644 index 00000000..6f2271ff --- /dev/null +++ b/src/components/order-invoice-pdf/components/field-row.js @@ -0,0 +1,22 @@ +/** + * 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 React from "react"; +import { View, Text } from "@react-pdf/renderer"; + +export const FieldRow = ({ styles, label, value, noBorder = false }) => ( + + {label ?? ""} + {value ?? ""} + +); \ No newline at end of file diff --git a/src/components/order-invoice-pdf/components/pdf-icon.js b/src/components/order-invoice-pdf/components/pdf-icon.js new file mode 100644 index 00000000..23033842 --- /dev/null +++ b/src/components/order-invoice-pdf/components/pdf-icon.js @@ -0,0 +1,38 @@ +/** + * 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 React from "react"; +import { Svg, Path } from "@react-pdf/renderer"; + +const MUI_ICON_PATHS = { + ArrowUpward: "M4 12l1.41 1.41L11 7.83V20h2V7.83l5.58 5.59L20 12l-8-8-8 8z", + ArrowDownward: + "M20 12l-1.41-1.41L13 16.17V4h-2v12.17l-5.58-5.59L4 12l8 8 8-8z", + Refresh: + "M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z", + DoNotDisturb: + "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8 0-1.85.63-3.55 1.69-4.9L16.9 18.31C15.55 19.37 13.85 20 12 20zm6.31-3.1L7.1 5.69C8.45 4.63 10.15 4 12 4c4.42 0 8 3.58 8 8 0 1.85-.63 3.55-1.69 4.9z" +}; + +const PDF_ICON_SIZE = 8; + +export const PdfIcon = ({ name, color, size = PDF_ICON_SIZE }) => ( + + + +); \ No newline at end of file diff --git a/src/components/order-invoice-pdf/components/pdf-table-row.js b/src/components/order-invoice-pdf/components/pdf-table-row.js new file mode 100644 index 00000000..833dddb2 --- /dev/null +++ b/src/components/order-invoice-pdf/components/pdf-table-row.js @@ -0,0 +1,74 @@ +/** + * 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 React from "react"; +import { View, Text } from "@react-pdf/renderer"; +import { fmtBalance } from "../helpers"; +import { PdfIcon } from "./pdf-icon"; + +export const PdfTableRow = ({ row, styles, rowStyles }) => { + // Notes span all columns, matching NotesRow in the table + if (row.type === "note") { + return ( + + NOTE + {row.content} + + ); + } + + const s = rowStyles[row.cancelled ? "cancelled" : row.type]; + return ( + + {row.code} + {s.typeBadge ? ( + + + + {s.typeBadge.label} + + + ) : ( + + )} + + + {row.type === "item" + ? `${row.description} - Total: ${row.qty}` + : row.description} + + {row.cancelledBy && ( + {row.cancelledBy} + )} + {row.subDescription && ( + {row.subDescription} + )} + + {row.price} + {row.balanceCents != null ? ( + + {fmtBalance(row.balanceCents)} + + ) : ( + + )} + + ); +}; \ No newline at end of file diff --git a/src/components/order-invoice-pdf/components/reconciliation-block.js b/src/components/order-invoice-pdf/components/reconciliation-block.js new file mode 100644 index 00000000..cf3ee451 --- /dev/null +++ b/src/components/order-invoice-pdf/components/reconciliation-block.js @@ -0,0 +1,66 @@ +/** + * 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 React from "react"; +import { View, Text } from "@react-pdf/renderer"; +import { currencyAmountFromCents } from "../../../utils/money"; + +// Uses order-level totals matching the table's ReconciliationBox props: +// cancelledTotal ← order.cancelled_total +// refundsTotal ← order.refunds_total +// retained ← order.retained +// credited ← order.credited_to_payment_method +export const ReconciliationBlock = ({ + styles, + cancelledTotal, + refundsTotal, + retained, + credited +}) => { + const totalColor = retained > 0 ? "#c62828" : "#1b5e20"; + const totalLabel = + retained > 0 + ? "Retained as cancellation fee" + : credited > 0 + ? "Credited to Payment Method" + : "Balance"; + const totalValue = + retained > 0 ? retained : credited > 0 ? credited : retained; + + return ( + + + Reconciliation + + Cancelled + + {currencyAmountFromCents(cancelledTotal ?? 0)} + + + + Refunded + + {currencyAmountFromCents(refundsTotal ?? 0)} + + + + + {totalLabel} + + {currencyAmountFromCents(totalValue ?? 0)} + + + + + ); +}; diff --git a/src/components/order-invoice-pdf/helpers.js b/src/components/order-invoice-pdf/helpers.js index a64cc791..8e90199e 100644 --- a/src/components/order-invoice-pdf/helpers.js +++ b/src/components/order-invoice-pdf/helpers.js @@ -17,18 +17,6 @@ import { MILLISECONDS_IN_SECOND } from "../../utils/constants"; export const DEFAULT_FONT_FAMILY = "Helvetica"; -export const MUI_ICON_PATHS = { - ArrowUpward: "M4 12l1.41 1.41L11 7.83V20h2V7.83l5.58 5.59L20 12l-8-8-8 8z", - ArrowDownward: - "M20 12l-1.41-1.41L13 16.17V4h-2v12.17l-5.58-5.59L4 12l8 8 8-8z", - Refresh: - "M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z", - DoNotDisturb: - "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8 0-1.85.63-3.55 1.69-4.9L16.9 18.31C15.55 19.37 13.85 20 12 20zm6.31-3.1L7.1 5.69C8.45 4.63 10.15 4 12 4c4.42 0 8 3.58 8 8 0 1.85-.63 3.55-1.69 4.9z" -}; - -export const PDF_ICON_SIZE = 8; - export const formatDate = ( date, timeZone = "LOC", diff --git a/src/components/order-invoice-pdf/index.js b/src/components/order-invoice-pdf/index.js index fb907c2d..e15a087c 100644 --- a/src/components/order-invoice-pdf/index.js +++ b/src/components/order-invoice-pdf/index.js @@ -15,7 +15,6 @@ import React from "react"; import PropTypes from "prop-types"; import { Document, Page, Text, View, Image, pdf } from "@react-pdf/renderer"; import { createStyles, createRowStyles } from "./styles"; -import { FieldRow, PdfTableRow, ReconciliationBlock } from "./components"; import { buildRows, formatDate, @@ -24,8 +23,11 @@ import { getThemeFontFamily, fmtBalance } from "./helpers"; +import { FieldRow } from "./components/field-row"; +import { PdfTableRow } from "./components/pdf-table-row"; +import { ReconciliationBlock } from "./components/reconciliation-block"; -export { buildRows, formatDate }; +export { buildRows }; // logoSrc/theme are left to the consumer on purpose: this component is // shared across apps with different branding/typefaces, so no logo image or @@ -166,6 +168,11 @@ OrderPdf.propTypes = { theme: PropTypes.object }; +// Revoking a blob URL immediately after use has been reported to produce +// empty/failed downloads on Safari and some Firefox versions, so both +// generateInvoicePDF and previewPDF defer the revoke by this long instead. +const REVOKE_DELAY_MS = 60_000; + export const generateInvoicePDF = async ( order, summit, @@ -188,8 +195,8 @@ export const generateInvoicePDF = async ( .replace(/\s+/g, "-"); document.body.appendChild(link); link.click(); - URL.revokeObjectURL(url); document.body.removeChild(link); + setTimeout(() => URL.revokeObjectURL(url), REVOKE_DELAY_MS); } catch (error) { console.error("Error generating invoice PDF:", error); throw error; @@ -197,11 +204,27 @@ export const generateInvoicePDF = async ( }; export const previewPDF = async (order, summit, { logoSrc, theme } = {}) => { - const blob = await pdf( - - ).toBlob(); - const url = URL.createObjectURL(blob); - window.open(url, "_blank", "noopener,noreferrer"); - // Revoke after the new tab has had a chance to load the PDF. - setTimeout(() => URL.revokeObjectURL(url), 60_000); + // Opened synchronously, before the await below, so the tab is still tied + // to the originating click — otherwise Safari's popup blocker kills it. + // Can't pass "noopener" here: that makes window.open return null, and we + // need the reference to set .location.href once the blob URL is ready. + const newTab = window.open("", "_blank", "noreferrer"); + + try { + const blob = await pdf( + + ).toBlob(); + const url = URL.createObjectURL(blob); + + if (newTab) { + newTab.location.href = url; + } + + // Revoke after the new tab has had a chance to load the PDF. + setTimeout(() => URL.revokeObjectURL(url), REVOKE_DELAY_MS); + } catch (error) { + console.error("Error generating invoice PDF preview:", error); + if (newTab) newTab.close(); + throw error; + } }; diff --git a/yarn.lock b/yarn.lock index a61a59e6..314afe32 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1722,6 +1722,16 @@ "@babel/runtime" "^7.25.7" "@mui/utils" "^5.16.6 || ^6.0.0 || ^7.0.0" +"@noble/ciphers@^1.0.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@noble/ciphers/-/ciphers-1.3.0.tgz#f64b8ff886c240e644e5573c097f86e5b43676dc" + integrity sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw== + +"@noble/hashes@^1.6.0": + version "1.8.0" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.8.0.tgz#cee43d801fcef9644b11b8194857695acd5f815a" + integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== + "@npmcli/fs@^1.0.0": version "1.1.1" resolved "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz" @@ -1758,143 +1768,146 @@ resolved "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz" integrity sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA== -"@react-pdf/fns@2.0.1": - version "2.0.1" - resolved "https://registry.npmjs.org/@react-pdf/fns/-/fns-2.0.1.tgz" - integrity sha512-/vgecczzFYBQFkgUupH+sxXhLWQtBwdwCgweyh25XOlR4NZuaMD/UVUDl4loFHhRQqDMQq37lkTcchh7zzW6ug== - dependencies: - "@babel/runtime" "^7.20.13" +"@react-pdf/fns@3.1.3": + version "3.1.3" + resolved "https://registry.yarnpkg.com/@react-pdf/fns/-/fns-3.1.3.tgz#e0437d60ac10746bfbdf080e6809ba6f20d01556" + integrity sha512-0I7pApDr1/RLAKbizuLy/IHTEa93LSPy/bEwYniboC3Xqnp6Od8xFJKbKEzGw2wh/5zKFFwl00g4t9RwgIMc3w== -"@react-pdf/font@^2.3.6": - version "2.3.6" - resolved "https://registry.npmjs.org/@react-pdf/font/-/font-2.3.6.tgz" - integrity sha512-JYV+KmVyG2tPdpCK0/iFiBy1V7VHz2fETttKCgTRsLAo+w8RpM0pUGSAYROSuRl7yqbhiKGw/A24PYWhBReiOQ== +"@react-pdf/font@^4.0.8": + version "4.0.8" + resolved "https://registry.yarnpkg.com/@react-pdf/font/-/font-4.0.8.tgz#2279fb487f8a532e8b82e11732703a904ad05bb3" + integrity sha512-deNd+emtZAJho1IlzKL9bRoLAGv/6oXOIKO2oZfs4RuXUrK1onLHbJO7e2YoVLPFP/sQxisRTnzdJFtd35iKwA== dependencies: - "@babel/runtime" "^7.20.13" - "@react-pdf/types" "^2.3.3" - cross-fetch "^3.1.5" + "@react-pdf/pdfkit" "^5.1.1" + "@react-pdf/types" "^2.11.1" fontkit "^2.0.2" is-url "^1.2.4" -"@react-pdf/image@^2.2.1": - version "2.2.1" - resolved "https://registry.npmjs.org/@react-pdf/image/-/image-2.2.1.tgz" - integrity sha512-f0+cEP6pSBmk8eS/wP2tMsJcv2c7xjzca6cr1kwcapr1nzkPrh6fMdEeFl6kR2/HlJK/JoHo+xxlzRiQ8V2lrw== - dependencies: - "@babel/runtime" "^7.20.13" - "@react-pdf/png-js" "^2.2.0" - cross-fetch "^3.1.5" - -"@react-pdf/layout@^3.6.2": - version "3.6.2" - resolved "https://registry.npmjs.org/@react-pdf/layout/-/layout-3.6.2.tgz" - integrity sha512-YD3/tDC6p5XPCXI04zH79bgX8LytjxEYfeCtsIzEFk0A2VvIHoRnRRDZ2OhZmO5g112ykyjY8vn9//ubTt+Ktg== - dependencies: - "@babel/runtime" "^7.20.13" - "@react-pdf/fns" "2.0.1" - "@react-pdf/image" "^2.2.1" - "@react-pdf/pdfkit" "^3.0.2" - "@react-pdf/primitives" "^3.0.0" - "@react-pdf/stylesheet" "^4.1.7" - "@react-pdf/textkit" "^4.2.0" - "@react-pdf/types" "^2.3.3" - "@react-pdf/yoga" "^4.1.2" - cross-fetch "^3.1.5" - emoji-regex "^10.2.1" +"@react-pdf/image@^3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@react-pdf/image/-/image-3.1.0.tgz#1ab51db9a6341ffde5049be3ef55d44c3dc777d8" + integrity sha512-ks7Ry8v711r8NvKWSELehj0BXBNPRihSnWsM09nDD8Ur175zbWBCK217LLwQMKDNYDVpkZaipdoJPom1LGaE9g== + dependencies: + "@react-pdf/svg" "^1.1.0" + jay-peg "^1.1.1" + png-js "^2.0.0" + +"@react-pdf/layout@^4.6.1": + version "4.6.1" + resolved "https://registry.yarnpkg.com/@react-pdf/layout/-/layout-4.6.1.tgz#6777fafa2a47996d4b42de37ddd324ea1aff4557" + integrity sha512-gN6PmWoEffvlIkifLfEhMsVucRywVMyH3rnxdyOVOhGy0nWJKKGpHyPc4plbDdpP6EfZ0r8prHXujDSkIG2nSA== + dependencies: + "@react-pdf/fns" "3.1.3" + "@react-pdf/image" "^3.1.0" + "@react-pdf/primitives" "^4.3.0" + "@react-pdf/stylesheet" "^6.2.1" + "@react-pdf/textkit" "^6.3.0" + "@react-pdf/types" "^2.11.1" + emoji-regex-xs "^1.0.0" queue "^6.0.1" + yoga-layout "^3.2.1" -"@react-pdf/pdfkit@^3.0.2": - version "3.0.2" - resolved "https://registry.npmjs.org/@react-pdf/pdfkit/-/pdfkit-3.0.2.tgz" - integrity sha512-+m5rwNCwyEH6lmnZWpsQJvdqb6YaCCR0nMWrc/KKDwznuPMrGmGWyNxqCja+bQPORcHZyl6Cd/iFL0glyB3QGw== +"@react-pdf/pdfkit@^5.1.1": + version "5.1.1" + resolved "https://registry.yarnpkg.com/@react-pdf/pdfkit/-/pdfkit-5.1.1.tgz#b3af968f94555a3d7c4cc1d5b81493bdbfbd76e0" + integrity sha512-wNcdSsNlNYyGHGAgIdt453egBF7fiF9UxpRlklUfVvu8OWCrUppG9xiUrPLVoKiqWet5tMi0w6LmuFUJuYqjEg== dependencies: "@babel/runtime" "^7.20.13" - "@react-pdf/png-js" "^2.2.0" + "@noble/ciphers" "^1.0.0" + "@noble/hashes" "^1.6.0" browserify-zlib "^0.2.0" - crypto-js "^4.0.0" fontkit "^2.0.2" + jay-peg "^1.1.1" + js-md5 "^0.8.3" + linebreak "^1.1.0" + png-js "^2.0.0" vite-compatible-readable-stream "^3.6.1" -"@react-pdf/png-js@^2.2.0": - version "2.2.0" - resolved "https://registry.npmjs.org/@react-pdf/png-js/-/png-js-2.2.0.tgz" - integrity sha512-csZU5lfNW73tq7s7zB/1rWXGro+Z9cQhxtsXwxS418TSszHUiM6PwddouiKJxdGhbVLjRIcuuFVa0aR5cDOC6w== - dependencies: - browserify-zlib "^0.2.0" +"@react-pdf/primitives@^4.3.0": + version "4.3.0" + resolved "https://registry.yarnpkg.com/@react-pdf/primitives/-/primitives-4.3.0.tgz#3bb5f74294bea923392499dd46bc5196d47b918c" + integrity sha512-nYXoZ36pvwNzbc54+DbL8RCn15jU7woJ9D/svnh5tpUXekJ+CbI4mZLo6boSv24CvJgychOu6h7gxX03B4ps0A== -"@react-pdf/primitives@^3.0.0": - version "3.0.1" - resolved "https://registry.npmjs.org/@react-pdf/primitives/-/primitives-3.0.1.tgz" - integrity sha512-0HGcknrLNwyhxe+SZCBL29JY4M85mXKdvTZE9uhjNbADGgTc8wVnkc5+e4S/lDvugbVISXyuIhZnYwtK9eDnyQ== +"@react-pdf/reconciler@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@react-pdf/reconciler/-/reconciler-2.0.0.tgz#d53ba53d5418c275c1fe1b150f0e9822243b799a" + integrity sha512-7zaPRujpbHSmCpIrZ+b9HSTJHthcVZzX0Wx7RzvQGsGBUbHP4p6s5itXrAIOuQuPvDepoHGNOvf6xUuMVvdoyw== + dependencies: + object-assign "^4.1.1" + scheduler "0.25.0-rc-603e6108-20241029" -"@react-pdf/render@^3.2.6": - version "3.2.6" - resolved "https://registry.npmjs.org/@react-pdf/render/-/render-3.2.6.tgz" - integrity sha512-nsd1sleWMzBdrYGv5BwChPgVwoTZilfdiadE5wQiblFqG1C7EYINadalnEl1tjldKAzofSPBLKJbnSGR5r2lIQ== +"@react-pdf/render@^4.5.1": + version "4.5.1" + resolved "https://registry.yarnpkg.com/@react-pdf/render/-/render-4.5.1.tgz#7d94bfb96f4abe0cac28f769e296db909c8c456f" + integrity sha512-IW/N4HWJWtioBXCf7n02IR24VJJ8gbdS3jGypf+vW/rSErEx3/URRzh9UK6Ma8Fpog9+T/W6GE2NHJ5AAKHhVA== dependencies: "@babel/runtime" "^7.20.13" - "@react-pdf/fns" "2.0.1" - "@react-pdf/primitives" "^3.0.0" - "@react-pdf/textkit" "^4.2.0" - "@react-pdf/types" "^2.3.3" + "@react-pdf/fns" "3.1.3" + "@react-pdf/primitives" "^4.3.0" + "@react-pdf/textkit" "^6.3.0" + "@react-pdf/types" "^2.11.1" abs-svg-path "^0.1.1" - color-string "^1.5.3" + color-string "^2.1.4" normalize-svg-path "^1.1.0" parse-svg-path "^0.1.2" svg-arc-to-cubic-bezier "^3.2.0" -"@react-pdf/renderer@^3.1.11": - version "3.1.12" - resolved "https://registry.npmjs.org/@react-pdf/renderer/-/renderer-3.1.12.tgz" - integrity sha512-y4H2ELH0okJP7+ig+uNjFAfuAanWiF3mxsKsE7ZBuhF4tabbMt9fX+pQ7qn4xrzEWX0vlso6s6ebkkeGdSGWzA== +"@react-pdf/renderer@^4.4.1": + version "4.5.1" + resolved "https://registry.yarnpkg.com/@react-pdf/renderer/-/renderer-4.5.1.tgz#3daa9caa572ea8c42beece01b6faa348b460d304" + integrity sha512-5r1VQrE6FRLXX5wWUxwZzM24E2BJMo6g8AQWuS8WyPs9ugu5yMnb2g8/RpPYka/Z6J+RUEWc32wty2NoUJF42Q== dependencies: "@babel/runtime" "^7.20.13" - "@react-pdf/font" "^2.3.6" - "@react-pdf/layout" "^3.6.2" - "@react-pdf/pdfkit" "^3.0.2" - "@react-pdf/primitives" "^3.0.0" - "@react-pdf/render" "^3.2.6" - "@react-pdf/types" "^2.3.3" + "@react-pdf/fns" "3.1.3" + "@react-pdf/font" "^4.0.8" + "@react-pdf/layout" "^4.6.1" + "@react-pdf/pdfkit" "^5.1.1" + "@react-pdf/primitives" "^4.3.0" + "@react-pdf/reconciler" "^2.0.0" + "@react-pdf/render" "^4.5.1" + "@react-pdf/types" "^2.11.1" events "^3.3.0" object-assign "^4.1.1" prop-types "^15.6.2" queue "^6.0.1" - scheduler "^0.17.0" -"@react-pdf/stylesheet@^4.1.7": - version "4.1.7" - resolved "https://registry.npmjs.org/@react-pdf/stylesheet/-/stylesheet-4.1.7.tgz" - integrity sha512-3n0Vg0XFszPyo0MpH75DkLRvsS4JOE0HzBH6XqFHDiquZDrC4mNgmMhZEbsOED+8xDGoCeVh8fLU3L6Tu0HWqg== +"@react-pdf/stylesheet@^6.2.1": + version "6.2.1" + resolved "https://registry.yarnpkg.com/@react-pdf/stylesheet/-/stylesheet-6.2.1.tgz#baa87869c0cc953fb334d20c63359672b872475c" + integrity sha512-2+UEk+7e+z8baaWi2l5kPLWmwtJeOI+T5wW9GGeN3iDH7vd3kbTqOpN1yt9mmfNVZFxQsnDHpznFb5v5UF983A== dependencies: - "@babel/runtime" "^7.20.13" - "@react-pdf/fns" "2.0.1" - "@react-pdf/types" "^2.3.3" - color-string "^1.5.3" + "@react-pdf/fns" "3.1.3" + "@react-pdf/types" "^2.11.1" + color-string "^2.1.4" hsl-to-hex "^1.0.0" media-engine "^1.0.3" postcss-value-parser "^4.1.0" -"@react-pdf/textkit@^4.2.0": - version "4.2.0" - resolved "https://registry.npmjs.org/@react-pdf/textkit/-/textkit-4.2.0.tgz" - integrity sha512-R90pEOW6NdhUx4p99iROvKmwB06IRYdXMhh0QcmUeoPOLe64ZdMfs3LZliNUWgI5fCmq71J+nv868i/EakFPDg== +"@react-pdf/svg@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@react-pdf/svg/-/svg-1.1.0.tgz#c3ba275d312f7e2862f2a7044944b46c44c35f41" + integrity sha512-cTIHXiz9x1HrbfqzfxfZP3FRdDwUXG77QWF6Fb5MP/lV3ONxR+g0Z3hwtBatCS9HeGBQCpxX/Lzb8wHE+co1PA== dependencies: - "@babel/runtime" "^7.20.13" - "@react-pdf/fns" "2.0.1" + "@react-pdf/primitives" "^4.3.0" + +"@react-pdf/textkit@^6.3.0": + version "6.3.0" + resolved "https://registry.yarnpkg.com/@react-pdf/textkit/-/textkit-6.3.0.tgz#fe685654f557ff861008e09308db4a2a57f6bc42" + integrity sha512-v6+V8nAcVwm7s2s1jIG2MD3Iw//x/k+XrH1foWOELBE4b32pyDgKyPXN/6KJE0dnX7+fVy27uctLNCLNMvzKzQ== + dependencies: + "@react-pdf/fns" "3.1.3" + bidi-js "^1.0.2" hyphen "^1.6.4" unicode-properties "^1.4.1" -"@react-pdf/types@^2.3.3": - version "2.3.3" - resolved "https://registry.npmjs.org/@react-pdf/types/-/types-2.3.3.tgz" - integrity sha512-I3BVu5vF0xxX6rvqZHt4gCjFAt6X+mak5bwYQyf6bm21IIMDXXBtgXqWEl1wosWizArox7fcN/XbEnysrf/8Dw== - -"@react-pdf/yoga@^4.1.2": - version "4.1.2" - resolved "https://registry.npmjs.org/@react-pdf/yoga/-/yoga-4.1.2.tgz" - integrity sha512-OlMZkFrJDj4GyKZ70thiObwwPVZ52B7mlPyfzwa+sgwsioqHXg9nMWOO+7SQFNUbbOGagMUu0bCuTv+iXYZuaQ== +"@react-pdf/types@^2.11.1": + version "2.11.1" + resolved "https://registry.yarnpkg.com/@react-pdf/types/-/types-2.11.1.tgz#ae37a12a883ae2d54a2b98b9b9ba32fbf4241478" + integrity sha512-i9xQgfaDU9QoeNnbp6rltXCWg1huEh195rpOuN8cE4BZ2FuLdQrsIcb2dhFF9aOxXf+XBA6LOSpIW051MDD/bw== dependencies: - "@babel/runtime" "^7.20.13" + "@react-pdf/font" "^4.0.8" + "@react-pdf/primitives" "^4.3.0" + "@react-pdf/stylesheet" "^6.2.1" "@sentry-internal/browser-utils@8.55.1": version "8.55.1" @@ -3356,6 +3369,11 @@ balanced-match@^1.0.0: resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== +base64-js@0.0.8: + version "0.0.8" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-0.0.8.tgz#1101e9544f4a76b1bc3b26d452ca96d7a35e7978" + integrity sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw== + base64-js@^1.1.2, base64-js@^1.3.0, base64-js@^1.3.1, base64-js@^1.5.1: version "1.5.1" resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz" @@ -3391,6 +3409,13 @@ bcrypt-pbkdf@^1.0.0: dependencies: tweetnacl "^0.14.3" +bidi-js@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/bidi-js/-/bidi-js-1.0.3.tgz#6f8bcf3c877c4d9220ddf49b9bb6930c88f877d2" + integrity sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw== + dependencies: + require-from-string "^2.0.2" + big.js@^5.2.2: version "5.2.2" resolved "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz" @@ -3899,18 +3924,22 @@ color-convert@^2.0.1: dependencies: color-name "~1.1.4" -color-name@^1.0.0, color-name@~1.1.4: +color-name@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-2.1.0.tgz#0b677385c1c4b4edfdeaf77e38fa338e3a40b693" + integrity sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg== + +color-name@~1.1.4: version "1.1.4" resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== -color-string@^1.5.3: - version "1.9.1" - resolved "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz" - integrity sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg== +color-string@^2.1.4: + version "2.1.4" + resolved "https://registry.yarnpkg.com/color-string/-/color-string-2.1.4.tgz#9dcf566ff976e23368c8bd673f5c35103ab41058" + integrity sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg== dependencies: - color-name "^1.0.0" - simple-swizzle "^0.2.2" + color-name "^2.0.0" color-support@^1.1.2, color-support@^1.1.3: version "1.1.3" @@ -4135,13 +4164,6 @@ cross-fetch@^3.0.4: dependencies: node-fetch "2.6.7" -cross-fetch@^3.1.5: - version "3.1.6" - resolved "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.6.tgz" - integrity sha512-riRvo06crlE8HiqOwIpQhxwdOk4fOeR7FVM/wXoxchFEqMNUjvbs3bfo4OTgMEMHzppd4DxFBDbyySj8Cv781g== - dependencies: - node-fetch "^2.6.11" - cross-spawn@^7.0.3: version "7.0.6" resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz" @@ -4151,7 +4173,7 @@ cross-spawn@^7.0.3: shebang-command "^2.0.0" which "^2.0.1" -crypto-js@^4.0.0, crypto-js@^4.1.1: +crypto-js@^4.1.1: version "4.1.1" resolved "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz" integrity sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw== @@ -4776,10 +4798,10 @@ emittery@^0.10.2: resolved "https://registry.npmjs.org/emittery/-/emittery-0.10.2.tgz" integrity sha512-aITqOwnLanpHLNXZJENbOgjUBeHocD+xsSJmNrjovKBW5HbSpW3d1pEls7GFQPUWXiwG9+0P4GtHfEqC/4M0Iw== -emoji-regex@^10.2.1: - version "10.2.1" - resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.2.1.tgz" - integrity sha512-97g6QgOk8zlDRdgq1WxwgTMgEWGVAQvB5Fdpgc1MkNy56la5SKP9GsMXKDOdqwn90/41a8yPwIGk1Y6WVbeMQA== +emoji-regex-xs@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz#e8af22e5d9dbd7f7f22d280af3d19d2aab5b0724" + integrity sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg== emoji-regex@^8.0.0: version "8.0.0" @@ -5226,6 +5248,11 @@ fbjs@^2.0.0: setimmediate "^1.0.5" ua-parser-js "^0.7.18" +fflate@^0.8.2: + version "0.8.3" + resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.8.3.tgz#bc27d8eb30343d4d512abb03480202ce65d825fc" + integrity sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA== + file-loader@^6.2.0: version "6.2.0" resolved "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz" @@ -6231,11 +6258,6 @@ is-arrayish@^0.2.1: resolved "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz" integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0= -is-arrayish@^0.3.1: - version "0.3.2" - resolved "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz" - integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ== - is-bigint@^1.0.1: version "1.0.4" resolved "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz" @@ -6653,6 +6675,13 @@ istanbul-reports@^3.1.3: html-escaper "^2.0.0" istanbul-lib-report "^3.0.0" +jay-peg@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/jay-peg/-/jay-peg-1.1.1.tgz#fdf410b89fa7a295bf74424ffe4c9083dbe7c363" + integrity sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww== + dependencies: + restructure "^3.0.0" + jest-changed-files@^28.0.2: version "28.0.2" resolved "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-28.0.2.tgz" @@ -7145,6 +7174,11 @@ js-cookie@^3.0.5: resolved "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz" integrity sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw== +js-md5@^0.8.3: + version "0.8.3" + resolved "https://registry.yarnpkg.com/js-md5/-/js-md5-0.8.3.tgz#921bab7efa95bfc9d62b87ee08a57f8fe4305b69" + integrity sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ== + "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz" @@ -7383,6 +7417,14 @@ lilconfig@^2.0.3: resolved "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.5.tgz" integrity sha512-xaYmXZtTHPAw5m+xLN8ab9C+3a8YmV3asNSPOATITbtwrfbwaLJj8h66H1WMIpALCkqsIzK3h7oQ+PdX+LQ9Eg== +linebreak@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/linebreak/-/linebreak-1.1.0.tgz#831cf378d98bced381d8ab118f852bd50d81e46b" + integrity sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ== + dependencies: + base64-js "0.0.8" + unicode-trie "^2.0.0" + lines-and-columns@^1.1.6: version "1.2.4" resolved "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz" @@ -7996,7 +8038,7 @@ node-fetch@^1.0.1: encoding "^0.1.11" is-stream "^1.0.1" -node-fetch@^2.6.11, node-fetch@^2.6.7: +node-fetch@^2.6.7: version "2.6.11" resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.11.tgz" integrity sha512-4I6pdBY1EthSqDmJkiNk3JIT8cswwR9nfeW/cPdUagJYEQG7R95WRH74wpz7ma8Gh/9dI9FP+OU+0E4FvtA55w== @@ -8537,6 +8579,13 @@ pkg-dir@^4.1.0, pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" +png-js@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/png-js/-/png-js-2.0.0.tgz#7bc521aea1d47f5e3bf42eeecdd77095cba98d5f" + integrity sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA== + dependencies: + fflate "^0.8.2" + posix-character-classes@^0.1.0: version "0.1.1" resolved "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz" @@ -9680,13 +9729,10 @@ saxes@^5.0.1: dependencies: xmlchars "^2.2.0" -scheduler@^0.17.0: - version "0.17.0" - resolved "https://registry.npmjs.org/scheduler/-/scheduler-0.17.0.tgz" - integrity sha512-7rro8Io3tnCPuY4la/NuI5F2yfESpnfZyT6TtkXnSWVkcu0BCDJ+8gk5ozUaFaxpIyNuWAPXrH0yFcSi28fnDA== - dependencies: - loose-envify "^1.1.0" - object-assign "^4.1.1" +scheduler@0.25.0-rc-603e6108-20241029: + version "0.25.0-rc-603e6108-20241029" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.25.0-rc-603e6108-20241029.tgz#684dd96647e104d23e0d29a37f18937daf82df19" + integrity sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA== scheduler@^0.20.2: version "0.20.2" @@ -9965,13 +10011,6 @@ signal-exit@^3.0.0, signal-exit@^3.0.3, signal-exit@^3.0.7: resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz" integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== -simple-swizzle@^0.2.2: - version "0.2.2" - resolved "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz" - integrity sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg== - dependencies: - is-arrayish "^0.3.1" - sisteransi@^1.0.5: version "1.0.5" resolved "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz" @@ -11360,3 +11399,8 @@ yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +yoga-layout@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/yoga-layout/-/yoga-layout-3.2.1.tgz#d2d1ba06f0e81c2eb650c3e5ad8b0b4adde1e843" + integrity sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ== From c58724b68648069883688c742f3102c9ff595513 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Tue, 14 Jul 2026 10:51:57 -0300 Subject: [PATCH 08/14] fix: adjust form item quantity filter condition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/components/order-invoice-pdf/helpers.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/order-invoice-pdf/helpers.js b/src/components/order-invoice-pdf/helpers.js index 8e90199e..99e89416 100644 --- a/src/components/order-invoice-pdf/helpers.js +++ b/src/components/order-invoice-pdf/helpers.js @@ -75,7 +75,7 @@ export const buildRows = (order, summit) => { (order.forms || []).forEach((form) => { (form.items || []) - .filter((item) => item.quantity) + .filter((item) => (item.quantity ?? 1) > 0) .forEach((item) => { // Cancelled is per-item const cancelled = !!item.canceled_by_id; From 2e705d7ce6ad75d99acdb9be3ff8006c014c4e82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Wed, 15 Jul 2026 10:57:48 -0300 Subject: [PATCH 09/14] fix: adjust window params for previewPDF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- .../order-invoice-pdf/__tests__/order-invoice-pdf.test.js | 2 +- src/components/order-invoice-pdf/index.js | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) 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 9c4ac122..4e532d57 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 @@ -510,7 +510,7 @@ describe("previewPDF", () => { const promise = previewPDF(makeRenderOrder(), makeRenderSummit()); // asserted before awaiting, i.e. before the pending PDF promise resolves - expect(window.open).toHaveBeenCalledWith("", "_blank", "noreferrer"); + expect(window.open).toHaveBeenCalledWith("", "_blank"); await promise; }); diff --git a/src/components/order-invoice-pdf/index.js b/src/components/order-invoice-pdf/index.js index e15a087c..bb09ad0f 100644 --- a/src/components/order-invoice-pdf/index.js +++ b/src/components/order-invoice-pdf/index.js @@ -206,9 +206,10 @@ export const generateInvoicePDF = async ( export const previewPDF = async (order, summit, { logoSrc, theme } = {}) => { // Opened synchronously, before the await below, so the tab is still tied // to the originating click — otherwise Safari's popup blocker kills it. - // Can't pass "noopener" here: that makes window.open return null, and we - // need the reference to set .location.href once the blob URL is ready. - const newTab = window.open("", "_blank", "noreferrer"); + // Can't pass "noopener" (or "noreferrer", which implies it) here: that + // makes window.open return null, and we need the reference to set + // .location.href once the blob URL is ready. + const newTab = window.open("", "_blank"); try { const blob = await pdf( From d269599d8871d4065d6a85d035eb4f48dcfffa9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Thu, 16 Jul 2026 10:45:08 -0300 Subject: [PATCH 10/14] fix: adjust catch for missing purchased_date and show as pending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- .../__tests__/order-invoice-pdf.test.js | 25 +++++++++++++++++++ src/components/order-invoice-pdf/index.js | 4 +-- 2 files changed, 27 insertions(+), 2 deletions(-) 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 4e532d57..9da333e1 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 @@ -15,6 +15,7 @@ import React from "react"; import { render } from "@testing-library/react"; import { pdf } from "@react-pdf/renderer"; import { buildRows, OrderPdf, generateInvoicePDF, previewPDF } from "../index"; +import { formatDate } from "../helpers"; jest.mock("@react-pdf/renderer", () => { const React = require("react"); @@ -365,6 +366,30 @@ describe("OrderPdf — render", () => { "OrderPdf: summit is required" ); }); + + it("renders 'Pending' for the Date field when purchased_date is null", () => { + const { container } = render( + + ); + expect(container.textContent).toContain("Pending"); + }); + + it("renders the formatted date when purchased_date is present", () => { + const { container } = render( + + ); + expect(container.textContent).not.toContain("Pending"); + expect(container.textContent).toContain( + formatDate( + makeRenderOrder().purchased_date, + makeRenderSummit().time_zone_id, + "YYYY/MM/DD hh:mm a" + ) + ); + }); }); // ─── Reconciliation block ───────────────────────────────────────────────────── diff --git a/src/components/order-invoice-pdf/index.js b/src/components/order-invoice-pdf/index.js index bb09ad0f..316b3a76 100644 --- a/src/components/order-invoice-pdf/index.js +++ b/src/components/order-invoice-pdf/index.js @@ -71,11 +71,11 @@ export const OrderPdf = ({ order, summit, logoSrc, theme }) => { From 81e96525fdfd18f3f59ab1a6c9eb789d0f8f3064 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Thu, 23 Jul 2026 11:34:18 -0300 Subject: [PATCH 11/14] fix: adjust order total from net_amount or amount_due values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/components/order-invoice-pdf/index.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/order-invoice-pdf/index.js b/src/components/order-invoice-pdf/index.js index 316b3a76..51d34a98 100644 --- a/src/components/order-invoice-pdf/index.js +++ b/src/components/order-invoice-pdf/index.js @@ -45,13 +45,16 @@ export const OrderPdf = ({ order, summit, logoSrc, theme }) => { const rows = buildRows(order, summit); const { - amount_due: total = 0, + net_amount, + amount_due, cancelled_total: cancelledTotal = 0, refunds_total: refundsTotal = 0, retained = 0, credited_to_payment_method: credited = 0 } = order; + const total = net_amount || amount_due || 0; + const clientName = order.client?.contact_name || order.purchased_by_full_name || ""; const clientCompany = order.client?.company_name || ""; From 5f1974a460cfec93b7697857bd87449acd630305 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Fri, 24 Jul 2026 16:37:26 -0300 Subject: [PATCH 12/14] fix: adjust main_location object based on summit data, fallback value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- .../__tests__/order-invoice-pdf.test.js | 30 +++++++++++++++++-- src/components/order-invoice-pdf/index.js | 7 +++-- 2 files changed, 33 insertions(+), 4 deletions(-) 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 9da333e1..cc822e0b 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 @@ -329,8 +329,9 @@ const makeRenderOrder = (overrides = {}) => ({ const makeRenderSummit = (overrides = {}) => ({ name: "OpenStack Summit 2026", time_zone_id: "America/Los_Angeles", - main_locations: [ + locations: [ { + is_main: true, short_name: "Main Hall", name: "Convention Center", address_1: "123 Expo Blvd", @@ -344,7 +345,7 @@ const makeRenderSummit = (overrides = {}) => ({ }); describe("OrderPdf — render", () => { - it("renders header fields from order and summit, venue from main_locations", () => { + it("renders header fields from order and summit, venue from locations marked is_main", () => { const { container } = render( ); @@ -358,6 +359,31 @@ describe("OrderPdf — render", () => { expect(text).toContain("V6B 1A1"); }); + it("prefers main_locations over locations.is_main when both are present", () => { + const { container } = render( + + ); + const text = container.textContent; + expect(text).toContain("Legacy Hall"); + expect(text).toContain("1 Legacy Way"); + expect(text).not.toContain("Main Hall"); + }); + it("throws a clear error when order or summit are omitted", () => { expect(() => render()).toThrow( "OrderPdf: order is required" diff --git a/src/components/order-invoice-pdf/index.js b/src/components/order-invoice-pdf/index.js index 51d34a98..5906d0f4 100644 --- a/src/components/order-invoice-pdf/index.js +++ b/src/components/order-invoice-pdf/index.js @@ -43,6 +43,9 @@ export const OrderPdf = ({ order, summit, logoSrc, theme }) => { const styles = createStyles(fontFamily); const rowStyles = createRowStyles(styles); const rows = buildRows(order, summit); + const mainLocation = + summit.main_locations?.[0] ?? + summit.locations?.find((location) => location.is_main); const { net_amount, @@ -104,12 +107,12 @@ export const OrderPdf = ({ order, summit, logoSrc, theme }) => { From f1635f0a3111f208965f891a386cd49350502e9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Fri, 24 Jul 2026 17:38:42 -0300 Subject: [PATCH 13/14] fix: v:5.0.46-beta.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 02aea57a..8b8cd2da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openstack-uicore-foundation", - "version": "5.0.43", + "version": "5.0.46-beta.2", "description": "ui reactjs components for openstack marketing site", "main": "lib/openstack-uicore-foundation.js", "scripts": { From 1aaf805483fefefe268062ea29a5328e861182e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Tue, 28 Jul 2026 10:32:46 -0300 Subject: [PATCH 14/14] fix: use functions for total and discounts instead normalize data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- .../__tests__/order-invoice-pdf.test.js | 22 +++++++++++++++---- src/components/order-invoice-pdf/helpers.js | 17 ++++++++++++-- src/components/order-invoice-pdf/index.js | 7 +++--- 3 files changed, 36 insertions(+), 10 deletions(-) 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 cc822e0b..e2ff3203 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 @@ -44,7 +44,8 @@ const makeForm = (overrides = {}) => ({ code: "FORM-1", name: "Gold Package", discount_in_cents: 0, - discount: null, + discount_amount: null, + discount_type: null, add_on: null, items: [], ...overrides @@ -206,11 +207,11 @@ describe("buildRows — discount rows", () => { expect(rows.filter((r) => r.type === "discount")).toHaveLength(0); }); - it("emits one discount row with code DIS and formatted amount", () => { + it("emits one discount row with code DIS and formatted amount, describing a Rate discount from raw discount_amount/discount_type", () => { const form = makeForm({ discount_in_cents: 1500, - discount: "15%", - code: "DISC-1" + discount_amount: 1500, + discount_type: "Rate" }); const discountRows = buildRows({ forms: [form] }, MOCK_SUMMIT).filter( (r) => r.type === "discount" @@ -219,6 +220,19 @@ describe("buildRows — discount rows", () => { expect(discountRows[0].rowKey).toBe(`discount-${form.id}`); expect(discountRows[0].code).toBe("DIS"); expect(discountRows[0].price).toBe("$15.00"); + expect(discountRows[0].description).toBe("15%"); + }); + + it("describes an Amount discount from raw discount_amount/discount_type", () => { + const form = makeForm({ + discount_in_cents: 500, + discount_amount: 500, + discount_type: "Amount" + }); + const discountRows = buildRows({ forms: [form] }, MOCK_SUMMIT).filter( + (r) => r.type === "discount" + ); + expect(discountRows[0].description).toBe("$5.00"); }); }); diff --git a/src/components/order-invoice-pdf/helpers.js b/src/components/order-invoice-pdf/helpers.js index 99e89416..7c78fee2 100644 --- a/src/components/order-invoice-pdf/helpers.js +++ b/src/components/order-invoice-pdf/helpers.js @@ -12,7 +12,7 @@ * */ import moment from "moment-timezone"; -import { currencyAmountFromCents } from "../../utils/money"; +import { currencyAmountFromCents, formatDiscount } from "../../utils/money"; import { MILLISECONDS_IN_SECOND } from "../../utils/constants"; export const DEFAULT_FONT_FAMILY = "Helvetica"; @@ -33,6 +33,19 @@ export const formatDate = ( .format(format); }; +/** + * Derives an order's amount due directly from its raw net_amount/amount_due + * fields (both in cents). This is the single source for that fallback rule, + * shared by consumers that normalize a raw order (e.g. into order.total) and + * by consumers that render straight from the raw order (e.g. the invoice PDF). + * @param {object} order - Raw order object with net_amount/amount_due in cents. + * @returns {number} - The amount due, in cents. + */ +export const getOrderTotal = (order) => { + if (!order) return 0; + return order.net_amount || order.amount_due || 0; +}; + export const formatAddress = (address) => { if (!address) return ""; return [ @@ -108,7 +121,7 @@ export const buildRows = (order, summit) => { rowKey: `discount-${form.id}`, type: "discount", code: "DIS", - description: String(form.discount || ""), + description: formatDiscount(form.discount_amount, form.discount_type), addon: "", qty: "", price: currencyAmountFromCents(discountCents), diff --git a/src/components/order-invoice-pdf/index.js b/src/components/order-invoice-pdf/index.js index 5906d0f4..e5ea084f 100644 --- a/src/components/order-invoice-pdf/index.js +++ b/src/components/order-invoice-pdf/index.js @@ -21,7 +21,8 @@ import { formatAddress, formatVenueName, getThemeFontFamily, - fmtBalance + fmtBalance, + getOrderTotal } from "./helpers"; import { FieldRow } from "./components/field-row"; import { PdfTableRow } from "./components/pdf-table-row"; @@ -48,15 +49,13 @@ export const OrderPdf = ({ order, summit, logoSrc, theme }) => { summit.locations?.find((location) => location.is_main); const { - net_amount, - amount_due, cancelled_total: cancelledTotal = 0, refunds_total: refundsTotal = 0, retained = 0, credited_to_payment_method: credited = 0 } = order; - const total = net_amount || amount_due || 0; + const total = getOrderTotal(order); const clientName = order.client?.contact_name || order.purchased_by_full_name || "";