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" },