diff --git a/package.json b/package.json index 90e38915..4c4d582f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openstack-uicore-foundation", - "version": "5.0.53", + "version": "5.0.54-beta.1", "description": "ui reactjs components for openstack marketing site", "main": "lib/openstack-uicore-foundation.js", "scripts": { diff --git a/src/components/order-invoice-pdf/__tests__/fixtures/purchase-v2.json b/src/components/order-invoice-pdf/__tests__/fixtures/purchase-v2.json index b64c1a08..ba743206 100644 --- a/src/components/order-invoice-pdf/__tests__/fixtures/purchase-v2.json +++ b/src/components/order-invoice-pdf/__tests__/fixtures/purchase-v2.json @@ -86,6 +86,51 @@ "canceled_by_email": "admin@example.com", "canceled_by_full_name": "Admin User", "cancel_reason": "Sponsor downgraded package", + "canceled_quantity": 1, + "cancellations": [ + { + "id": 501, + "quantity": 1, + "amount": 15000, + "reason": "Sponsor downgraded package", + "canceled_by_id": 7, + "canceled_by_email": "admin@example.com", + "canceled_by_full_name": "Admin User", + "created": 1699900000 + } + ], + "meta_fields": [] + }, + { + "line_id": 9003, + "position": 3, + "code": "ITEM-C", + "title": "Extra Badges", + "type": { "id": 213, "code": "ITEM-C", "name": "Extra Badges" }, + "notes": null, + "quantity": 5, + "current_rate_type": "Early", + "current_rate": 10000, + "custom_rate": null, + "amount": 50000, + "canceled_at": null, + "canceled_by_id": null, + "canceled_by_email": null, + "canceled_by_full_name": null, + "cancel_reason": null, + "canceled_quantity": 2, + "cancellations": [ + { + "id": 502, + "quantity": 2, + "amount": 20000, + "reason": "Partial downgrade", + "canceled_by_id": 7, + "canceled_by_email": "admin@example.com", + "canceled_by_full_name": "Admin User", + "created": 1699950000 + } + ], "meta_fields": [] } ] diff --git a/src/components/order-invoice-pdf/__tests__/order-invoice-pdf.test.js b/src/components/order-invoice-pdf/__tests__/order-invoice-pdf.test.js index 3337829c..d6c2e4c7 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 @@ -42,7 +42,8 @@ const TRANSLATIONS = { "sponsor_order_grid.refunded": "Refunded", "sponsor_order_grid.retained": "Retained as cancellation fee", "sponsor_order_grid.credited": "Credited to Payment Method", - "sponsor_order_grid.cancelled_by": "Cancelled {date} by {user}", + "sponsor_order_grid.cancelled_by": "Cancelled ({x} of {y}) - {date} - {user}", + "sponsor_order_grid.cancelled_items": "Cancelled items:", "mui_table.payment": "Payment", "mui_table.discount": "Discount", "mui_table.refund": "Refund", @@ -111,7 +112,8 @@ const MOCK_SUMMIT = { time_zone_id: "UTC" }; const baseForm = purchaseV2Fixture.forms[0]; const baseItem = baseForm.items[0]; // not cancelled -const baseCancelledItem = baseForm.items[1]; // cancelled +const baseCancelledItem = baseForm.items[1]; // fully cancelled +const basePartiallyCancelledItem = baseForm.items[2]; // partially cancelled const baseFee = purchaseV2Fixture.fees[0]; const basePayment = purchaseV2Fixture.payments[0]; const baseRefund = purchaseV2Fixture.refunds[0]; @@ -123,6 +125,10 @@ const makeCancelledItem = (overrides = {}) => ({ ...baseCancelledItem, ...overrides }); +const makePartiallyCancelledItem = (overrides = {}) => ({ + ...basePartiallyCancelledItem, + ...overrides +}); const makeFee = (overrides = {}) => ({ ...baseFee, ...overrides }); const makePayment = (overrides = {}) => ({ ...basePayment, ...overrides }); const makeRefund = (overrides = {}) => ({ ...baseRefund, ...overrides }); @@ -224,34 +230,51 @@ describe("buildRows — item rows", () => { // ─── Cancelled items (per-item, not per-form) ───────────────────────────────── describe("buildRows — cancelled items", () => { - it("sets cancelled: true and populates cancelledBy when item.canceled_by_id is set", () => { + it("sets cancelled: true and populates cancellations when canceled_quantity equals quantity", () => { const rows = buildRows( { forms: [makeForm({ items: [makeCancelledItem()] })] }, MOCK_SUMMIT ); expect(rows[0].cancelled).toBe(true); - expect(rows[0].cancelledBy).toMatch(/Admin User/); + expect(rows[0].partiallyCancelled).toBe(false); + expect(rows[0].cancellations).toHaveLength(1); + expect(rows[0].cancellations[0].label).toMatch(/Admin User/); + expect(rows[0].qty).toBe("0"); // quantity(1) - canceled_quantity(1) }); - it("sets cancelled: false and empty cancelledBy when canceled_by_id is absent or null", () => { - const withNull = makeForm({ + it("sets cancelled: false, partiallyCancelled: false and no cancellations when canceled_quantity is absent or 0", () => { + const withZero = makeForm({ id: 1, discount_in_cents: 0, - items: [makeItem({ canceled_by_id: null })] + items: [makeItem({ canceled_quantity: 0 })] }); const withAbsent = makeForm({ id: 2, discount_in_cents: 0, items: [makeItem()] }); - const rows = buildRows({ forms: [withNull, withAbsent] }, MOCK_SUMMIT); + const rows = buildRows({ forms: [withZero, withAbsent] }, MOCK_SUMMIT); rows.forEach((r) => { expect(r.cancelled).toBe(false); - expect(r.cancelledBy).toBe(""); + expect(r.partiallyCancelled).toBe(false); + expect(r.cancellations).toEqual([]); }); }); - it("cancelled items still accumulate into the running balance", () => { + it("sets partiallyCancelled: true (and cancelled: false) when canceled_quantity is between 0 and quantity", () => { + const rows = buildRows( + { forms: [makeForm({ items: [makePartiallyCancelledItem()] })] }, + MOCK_SUMMIT + ); + expect(rows[0].cancelled).toBe(false); + expect(rows[0].partiallyCancelled).toBe(true); + // quantity(5) - canceled_quantity(2) = 3 remaining + expect(rows[0].qty).toBe("3"); + expect(rows[0].cancellations).toHaveLength(1); + expect(rows[0].cancellations[0].label).toMatch(/Admin User/); + }); + + it("fully cancelled items still accumulate their full amount into the running balance", () => { const normalItem = makeItem({ amount: 8000 }); const cancelledItem = makeCancelledItem({ amount: 10000 }); const rows = buildRows( @@ -269,6 +292,15 @@ describe("buildRows — cancelled items", () => { expect(cancelled.balanceCents).toBe(18000); // 8000 + 10000 }); + it("partially cancelled items still accumulate their full amount into the running balance (matches SponsorOrderGrid; cancellation only nets out via reconciliation)", () => { + const partialItem = makePartiallyCancelledItem({ amount: 50000 }); + const rows = buildRows( + { forms: [makeForm({ discount_in_cents: 0, items: [partialItem] })] }, + MOCK_SUMMIT + ); + expect(rows[0].balanceCents).toBe(50000); + }); + it("a form-level canceled_by_id does not mark items as cancelled", () => { const form = makeForm({ canceled_by_id: 99, items: [makeItem()] }); const rows = buildRows({ forms: [form] }, MOCK_SUMMIT); @@ -607,6 +639,61 @@ describe("OrderPdf — reconciliation block", () => { }); }); +// ─── Partial cancellation (render-level) ────────────────────────────────── + +describe("OrderPdf — partial cancellation", () => { + it("shows a quantity split for a partially-cancelled line instead of a full strikethrough row", () => { + const { container } = render( + + ); + const text = container.textContent; + // basePartiallyCancelledItem: quantity 5, canceled_quantity 2 -> 3 remaining + expect(text).toContain("Extra Badges - Total: 3"); + // Cancellation event line uses the (x of y) translation tokens + expect(text).toContain("Cancelled (2 of 5) - "); + expect(text).toContain("Admin User"); + expect(text).toContain( + formatDate( + basePartiallyCancelledItem.cancellations[0].created, + "LOC", + "M/D/YY [@] h:mm A" + ) + ); + }); +}); + +// ─── Cancelled items summary (render-level) ─────────────────────────────── +// +// Mirrors SponsorOrderGrid's CancelledItems: an at-a-glance list of every +// (partially or fully) cancelled line, so a reader doesn't have to scan the +// whole table to find them. + +describe("OrderPdf — cancelled items summary", () => { + it("lists every cancelled line as 'formCode - itemCode (x/y)'", () => { + const { container } = render( + + ); + const text = container.textContent; + expect(text).toContain("Cancelled items:"); + // baseCancelledItem: form GOLD-1, ITEM-B, fully cancelled (1/1) + expect(text).toContain("GOLD-1 - ITEM-B (1/1)"); + // basePartiallyCancelledItem: form GOLD-1, ITEM-C, partially cancelled (2/5) + expect(text).toContain("GOLD-1 - ITEM-C (2/5)"); + }); + + it("omits the summary entirely when no line is cancelled", () => { + const { container } = render( + + ); + expect(container.textContent).not.toContain("Cancelled items:"); + }); +}); + // ─── generateInvoicePDF ──────────────────────────────────────────────────── describe("generateInvoicePDF", () => { diff --git a/src/components/order-invoice-pdf/components/cancelled-items-summary.js b/src/components/order-invoice-pdf/components/cancelled-items-summary.js new file mode 100644 index 00000000..cab0aecd --- /dev/null +++ b/src/components/order-invoice-pdf/components/cancelled-items-summary.js @@ -0,0 +1,45 @@ +/** + * 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 T from "i18n-react/dist/i18n-react"; +import { View, Text } from "@react-pdf/renderer"; +import { PdfIcon } from "./pdf-icon"; + +// Mirrors SponsorOrderGrid's CancelledItems: an at-a-glance list of every +// (partially or fully) cancelled line, so a reader doesn't have to scan the +// whole table to find them. `items` are order-invoice-pdf rows (from +// buildRows), not raw order items. +export const CancelledItemsSummary = ({ items, styles }) => { + if (!items || items.length === 0) return null; + + return ( + + + + {T.translate("sponsor_order_grid.cancelled_items")} + + {items.map((item) => ( + + {item.code} - {item.itemCode} ({item.canceledQuantity}/{item.quantity}) + + ))} + + ); +}; diff --git a/src/components/order-invoice-pdf/components/pdf-icon.js b/src/components/order-invoice-pdf/components/pdf-icon.js index 23033842..64780092 100644 --- a/src/components/order-invoice-pdf/components/pdf-icon.js +++ b/src/components/order-invoice-pdf/components/pdf-icon.js @@ -21,7 +21,9 @@ const MUI_ICON_PATHS = { 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" + "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", + SubdirectoryArrowRight: + "m19 15-6 6-1.42-1.42L15.17 16H4V4h2v10h9.17l-3.59-3.58L13 9z" }; const PDF_ICON_SIZE = 8; diff --git a/src/components/order-invoice-pdf/components/pdf-table-row.js b/src/components/order-invoice-pdf/components/pdf-table-row.js index 173197e6..86ecec8e 100644 --- a/src/components/order-invoice-pdf/components/pdf-table-row.js +++ b/src/components/order-invoice-pdf/components/pdf-table-row.js @@ -52,9 +52,19 @@ export const PdfTableRow = ({ row, styles, rowStyles }) => { ? `${row.description} - ${T.translate("mui_table.total")}: ${row.qty}` : row.description} - {row.cancelledBy && ( - {row.cancelledBy} - )} + {(row.cancellations || []).map((cancellation) => ( + + {cancellation.label} + {cancellation.reason && ( + + + + {cancellation.reason} + + + )} + + ))} {row.subDescription && ( {row.subDescription} )} diff --git a/src/components/order-invoice-pdf/helpers.js b/src/components/order-invoice-pdf/helpers.js index 36e0ee62..63d043b6 100644 --- a/src/components/order-invoice-pdf/helpers.js +++ b/src/components/order-invoice-pdf/helpers.js @@ -77,16 +77,19 @@ export const buildRows = (order) => { (form.items || []) .filter((item) => (item.quantity ?? 1) > 0) .forEach((item) => { - // Cancelled is per-item - const cancelled = !!item.canceled_by_id; - const cancelledBy = cancelled - ? T.translate("sponsor_order_grid.cancelled_by", { - user: item.canceled_by_full_name, - date: formatDate(item.canceled_at, "LOC", "YYYY/MM/DD HH:mm") - }) - : ""; - - // Cancelled items still accumulate + // Cancellation is per-item and quantity-scoped: canceled_quantity may + // be anywhere from 0 (not cancelled) up to quantity (fully cancelled), + // with the individual cancellation events (and their frozen per-event + // amounts) listed in cancellations. Mirrors SponsorOrderGrid's contract. + const quantity = item.quantity ?? 1; + const canceledQuantity = item.canceled_quantity ?? 0; + const cancellations = item.cancellations ?? []; + const cancelled = canceledQuantity > 0 && canceledQuantity === quantity; + const partiallyCancelled = canceledQuantity > 0 && canceledQuantity < quantity; + + // Matches SponsorOrderGrid: a charge stays in the ledger in full + // whether it's cancelled or not, partially or fully -- cancellation + // only nets out via the reconciliation block below. balanceCents += item.amount; rows.push({ @@ -94,13 +97,29 @@ export const buildRows = (order) => { type: "item", // Table shows form.code per item row (columnKey: "formCode", value: form.code) code: String(form.code || ""), + // Distinct from `code` (the form's code) -- this is the item's own + // code, used by the "Cancelled items:" summary to mirror + // SponsorOrderGrid's "formCode - itemCode (x/y)" links. + itemCode: String(item.type?.code || item.code || ""), description: String(item.type?.name || item.title || ""), addon: String(form.add_on?.name || ""), - qty: String(item.quantity ?? 1), + qty: String(quantity - canceledQuantity), + quantity, + canceledQuantity, price: currencyAmountFromCents(item.amount), balanceCents, cancelled, - cancelledBy + partiallyCancelled, + cancellations: cancellations.map((c) => ({ + id: c.id, + label: T.translate("sponsor_order_grid.cancelled_by", { + x: c.quantity, + y: quantity, + user: c.canceled_by_full_name, + date: formatDate(c.created, "LOC", "M/D/YY [@] h:mm A") + }), + reason: c.reason ? String(c.reason) : "" + })) }); }); diff --git a/src/components/order-invoice-pdf/index.js b/src/components/order-invoice-pdf/index.js index 32996024..4bca0a0b 100644 --- a/src/components/order-invoice-pdf/index.js +++ b/src/components/order-invoice-pdf/index.js @@ -26,6 +26,7 @@ import { import { FieldRow } from "./components/field-row"; import { PdfTableRow } from "./components/pdf-table-row"; import { ReconciliationBlock } from "./components/reconciliation-block"; +import { CancelledItemsSummary } from "./components/cancelled-items-summary"; import { formatBalance, getOrderTotal } from "../../utils/money"; export { buildRows }; @@ -45,6 +46,9 @@ export const OrderPdf = ({ order, summit, logoSrc, theme }) => { const styles = createStyles(fontFamily); const rowStyles = createRowStyles(styles); const rows = buildRows(order, summit); + const cancelledItems = rows.filter( + (row) => row.type === "item" && row.cancellations?.length > 0 + ); const mainLocation = summit.main_locations?.[0] ?? summit.locations?.find((location) => location.is_main); @@ -147,6 +151,8 @@ export const OrderPdf = ({ order, summit, logoSrc, theme }) => { + + {/* Table */} diff --git a/src/components/order-invoice-pdf/styles.js b/src/components/order-invoice-pdf/styles.js index fcff0ba3..5681a500 100644 --- a/src/components/order-invoice-pdf/styles.js +++ b/src/components/order-invoice-pdf/styles.js @@ -86,6 +86,27 @@ export const createStyles = (fontFamily) => StyleSheet.create({ fieldValue: { flex: 9 }, + cancelledItemsWrapper: { + flexDirection: "row", + flexWrap: "wrap", + alignItems: "center", + justifyContent: "flex-end", + marginTop: 8 + }, + cancelledItemsLabel: { + fontFamily, + fontSize: 8, + color: "#212529", + marginRight: 6 + }, + cancelledItemLink: { + fontSize: 8, + color: "#6C757D", + marginRight: 8 + }, + cancelledItemLinkBold: { + fontWeight: "bold" + }, tableWrapper: { marginTop: 8, borderRadius: 8, @@ -212,6 +233,12 @@ export const createStyles = (fontFamily) => StyleSheet.create({ }, paymentDesc: { fontFamily, fontSize: 8, fontWeight: "bold" }, muted: { color: "#6C757D", fontSize: 8 }, + cancellationReasonRow: { + flexDirection: "row", + alignItems: "center", + marginLeft: 8 + }, + cancellationReason: { color: "#6C757D", fontSize: 8, fontStyle: "italic" }, typeCell: { width: "15%", flexDirection: "row", alignItems: "center" }, typeBadgeLabel: { fontSize: 8 }, cancelledText: { color: "#9ca3af", textDecoration: "line-through" },