Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": []
}
]
Expand Down
107 changes: 97 additions & 10 deletions src/components/order-invoice-pdf/__tests__/order-invoice-pdf.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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];
Expand All @@ -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 });
Expand Down Expand Up @@ -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(
Expand All @@ -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);
Expand Down Expand Up @@ -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(
<OrderPdf order={makeRenderOrder()} summit={makeRenderSummit()} />
);
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(
<OrderPdf order={makeRenderOrder()} summit={makeRenderSummit()} />
);
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(
<OrderPdf
order={makeRenderOrder({
forms: [makeForm({ discount_in_cents: 0, items: [makeItem()] })]
})}
summit={makeRenderSummit()}
/>
);
expect(container.textContent).not.toContain("Cancelled items:");
});
});

// ─── generateInvoicePDF ────────────────────────────────────────────────────

describe("generateInvoicePDF", () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<View style={styles.cancelledItemsWrapper} wrap={false}>
<PdfIcon name="DoNotDisturb" color="#212529" />
<Text style={styles.cancelledItemsLabel}>
{T.translate("sponsor_order_grid.cancelled_items")}
</Text>
{items.map((item) => (
<Text
key={item.rowKey}
style={[
styles.cancelledItemLink,
item.cancelled && styles.cancelledItemLinkBold
]}
>
{item.code} - {item.itemCode} ({item.canceledQuantity}/{item.quantity})
</Text>
))}
</View>
);
};
4 changes: 3 additions & 1 deletion src/components/order-invoice-pdf/components/pdf-icon.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
16 changes: 13 additions & 3 deletions src/components/order-invoice-pdf/components/pdf-table-row.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,19 @@ export const PdfTableRow = ({ row, styles, rowStyles }) => {
? `${row.description} - ${T.translate("mui_table.total")}: ${row.qty}`
: row.description}
</Text>
{row.cancelledBy && (
<Text style={styles.muted}>{row.cancelledBy}</Text>
)}
{(row.cancellations || []).map((cancellation) => (
<React.Fragment key={cancellation.id}>
<Text style={styles.muted}>{cancellation.label}</Text>
{cancellation.reason && (
<View style={styles.cancellationReasonRow}>
<PdfIcon name="SubdirectoryArrowRight" color="#6C757D" />
<Text style={styles.cancellationReason}>
{cancellation.reason}
</Text>
</View>
)}
</React.Fragment>
))}
{row.subDescription && (
<Text style={styles.muted}>{row.subDescription}</Text>
)}
Expand Down
43 changes: 31 additions & 12 deletions src/components/order-invoice-pdf/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,30 +77,49 @@ 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({
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 || ""),
// 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) => ({
Comment thread
tomrndom marked this conversation as resolved.
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) : ""
}))
});
});

Expand Down
6 changes: 6 additions & 0 deletions src/components/order-invoice-pdf/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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);
Expand Down Expand Up @@ -147,6 +151,8 @@ export const OrderPdf = ({ order, summit, logoSrc, theme }) => {
</View>
</View>

<CancelledItemsSummary items={cancelledItems} styles={styles} />

{/* Table */}
<View style={styles.tableWrapper}>
<View style={styles.table}>
Expand Down
Loading
Loading