From b40eefbab17da4ad96ca02e23748662446f4c265 Mon Sep 17 00:00:00 2001 From: Usman Abeeb <136495186+therealbibson@users.noreply.github.com> Date: Sun, 27 Sep 2026 04:41:34 +0100 Subject: [PATCH] feat(components): add PDF export option to PaymentExport Closes #656 --- src/__tests__/paymentExportPdf.test.tsx | 95 ++++++++++++++++ src/components/PaymentExport.tsx | 60 ++++++++-- src/lib/paymentExportPdf.ts | 143 ++++++++++++++++++++++++ 3 files changed, 289 insertions(+), 9 deletions(-) create mode 100644 src/__tests__/paymentExportPdf.test.tsx create mode 100644 src/lib/paymentExportPdf.ts diff --git a/src/__tests__/paymentExportPdf.test.tsx b/src/__tests__/paymentExportPdf.test.tsx new file mode 100644 index 00000000..aa7ed60c --- /dev/null +++ b/src/__tests__/paymentExportPdf.test.tsx @@ -0,0 +1,95 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import type { Payment } from "@stellar-split/sdk"; +import PaymentExport from "@/components/PaymentExport"; +import { + buildPaymentSummaryHtml, + generatePaymentSummaryFilename, +} from "@/lib/paymentExportPdf"; +import { darkTheme, lightTheme } from "@/lib/pdfTheme"; + +const INVOICE_ID = "INV-1"; + +type PaymentWithMeta = Payment & { timestamp?: number; txHash?: string }; + +const payments = [ + { + payer: "GPAYERADDRESS000000000000000000000000000000000000000000", + amount: 25_000_000n, + timestamp: 1_700_000_000, + txHash: "abcdef1234567890", + }, +] as unknown as PaymentWithMeta[]; + +describe("buildPaymentSummaryHtml", () => { + it("includes the invoice number, addresses, amounts and transaction hashes", () => { + const html = buildPaymentSummaryHtml(INVOICE_ID, payments); + + expect(html).toContain(`Invoice #${INVOICE_ID}`); + expect(html).toContain("GPAYERADDRESS000000000000000000000000000000000000000000"); + expect(html).toContain("abcdef1234567890"); + expect(html).toContain("USDC"); + }); + + it("uses colour tokens from pdfTheme", () => { + expect(buildPaymentSummaryHtml(INVOICE_ID, payments)).toContain( + lightTheme.accent + ); + expect(buildPaymentSummaryHtml(INVOICE_ID, payments, true)).toContain( + darkTheme.accent + ); + }); + + it("renders an empty state when there are no payments", () => { + expect(buildPaymentSummaryHtml(INVOICE_ID, [])).toContain( + "No payments to export." + ); + }); + + it("escapes untrusted values instead of injecting them", () => { + const html = buildPaymentSummaryHtml(INVOICE_ID, [ + { payer: "", amount: 1n }, + ] as unknown as PaymentWithMeta[]); + expect(html).not.toContain(""); + expect(html).toContain("<script>"); + }); +}); + +describe("generatePaymentSummaryFilename", () => { + it("produces a dated .pdf filename for the invoice", () => { + expect(generatePaymentSummaryFilename(INVOICE_ID)).toMatch( + /^payments-invoice-INV-1-\d{4}-\d{2}-\d{2}\.pdf$/ + ); + }); +}); + +describe("PaymentExport PDF option", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("shows a PDF option in the export format selector", () => { + render( + + ); + expect(screen.getByRole("button", { name: "PDF" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "CSV" })).toBeInTheDocument(); + }); + + it("opens the printable payment summary when PDF export is triggered", () => { + const openSpy = vi.spyOn(window, "open").mockReturnValue(null); + vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:mock-url"); + vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); + + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "PDF" })); + fireEvent.click( + screen.getByRole("button", { name: /export payments pdf/i }) + ); + + expect(openSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/components/PaymentExport.tsx b/src/components/PaymentExport.tsx index 8767eab9..d6a8df16 100644 --- a/src/components/PaymentExport.tsx +++ b/src/components/PaymentExport.tsx @@ -11,17 +11,26 @@ import { PAYMENT_EXPORT_COLUMN_LABELS, type PaymentExportColumn, } from '@/lib/csvExport'; +import { + buildPaymentSummaryHtml, + downloadPaymentSummaryPDF, +} from '@/lib/paymentExportPdf'; interface Props { invoiceId: string; payments: Payment[]; } +type ExportFormat = 'csv' | 'pdf'; + +const EXPORT_FORMATS: ExportFormat[] = ['csv', 'pdf']; + export default function PaymentExport({ invoiceId, payments }: Props) { const [showFilters, setShowFilters] = useState(false); const [startDate, setStartDate] = useState(''); const [endDate, setEndDate] = useState(''); const [exporting, setExporting] = useState(false); + const [exportFormat, setExportFormat] = useState('csv'); const [selectedColumns, setSelectedColumns] = useState([ ...PAYMENT_EXPORT_COLUMNS, ]); @@ -47,12 +56,17 @@ export default function PaymentExport({ invoiceId, payments }: Props) { endDate || null ); - // Convert to CSV - const csv = paymentsToCSV(filteredPayments, selectedColumns); + if (exportFormat === 'pdf') { + const html = buildPaymentSummaryHtml(invoiceId, filteredPayments); + downloadPaymentSummaryPDF(html); + } else { + // Convert to CSV + const csv = paymentsToCSV(filteredPayments, selectedColumns); - // Download - const filename = generatePaymentExportFilename(invoiceId); - downloadCSV(csv, filename); + // Download + const filename = generatePaymentExportFilename(invoiceId); + downloadCSV(csv, filename); + } } catch (error) { console.error('Export failed:', error); alert('Failed to export payments. Please try again.'); @@ -78,7 +92,7 @@ export default function PaymentExport({ invoiceId, payments }: Props) {

Export Payment History

-
+
+ + {/* Export format selector */} +
+ {EXPORT_FORMATS.map((format) => ( + + ))} +
+
@@ -151,7 +191,7 @@ export default function PaymentExport({ invoiceId, payments }: Props) { )} {/* Column Selection */} - {payments.length > 0 && ( + {payments.length > 0 && exportFormat === 'csv' && (

Columns to include

@@ -183,7 +223,9 @@ export default function PaymentExport({ invoiceId, payments }: Props) { {/* Export Info */} {payments.length > 0 && !showFilters && (

- CSV will include payer address, amount (USDC), timestamp, and transaction hash for all payments. + {exportFormat === 'pdf' + ? 'PDF will include the invoice number, payer addresses, amounts, timestamps, and transaction hashes.' + : 'CSV will include payer address, amount (USDC), timestamp, and transaction hash for all payments.'}

)}
diff --git a/src/lib/paymentExportPdf.ts b/src/lib/paymentExportPdf.ts new file mode 100644 index 00000000..5a81def9 --- /dev/null +++ b/src/lib/paymentExportPdf.ts @@ -0,0 +1,143 @@ +/** + * paymentExportPdf.ts — Renders a printable PDF payment summary. + * + * Follows the same "print to PDF" flow used by `ReceiptPDF` / `InvoicePDF`: + * the summary is rendered as a self-contained HTML document in a new window + * and the browser's print dialog produces the PDF. Colour tokens come from + * `src/lib/pdfTheme.ts` so the export stays consistent with the rest of the + * PDF pipeline. + */ + +import type { Payment } from "@stellar-split/sdk"; +import { formatAmount } from "@stellar-split/sdk"; +import { getPdfTheme } from "@/lib/pdfTheme"; + +type PaymentWithMeta = Payment & { timestamp?: number; txHash?: string }; + +/** Escape untrusted text before interpolating it into the HTML document. */ +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** Format a payment timestamp (seconds or milliseconds) as an ISO-8601 date. */ +function formatTimestamp(timestamp: number | undefined): string { + if (!timestamp) return ""; + const ms = timestamp < 10000000000 ? timestamp * 1000 : timestamp; + return new Date(ms).toISOString(); +} + +/** Sum payment amounts when the SDK exposes them as bigints. */ +function sumAmounts(payments: PaymentWithMeta[]): bigint { + return payments.reduce((sum, payment) => { + return sum + (typeof payment.amount === "bigint" ? payment.amount : 0n); + }, 0n); +} + +/** + * Build a printable payment-summary HTML document for an invoice. + * Includes the invoice number, per-payment amounts, payer addresses and + * transaction hashes, plus a totals row. + */ +export function buildPaymentSummaryHtml( + invoiceId: string, + payments: PaymentWithMeta[], + preferDark = false, +): string { + const theme = getPdfTheme(preferDark); + const total = sumAmounts(payments); + + const rows = payments + .map((payment) => { + const hash = payment.txHash ? escapeHtml(payment.txHash) : "—"; + return ` + ${escapeHtml(payment.payer)} + ${escapeHtml(formatAmount(payment.amount))} USDC + ${escapeHtml(formatTimestamp(payment.timestamp)) || "—"} + ${hash} + `; + }) + .join("\n"); + + const totalRow = + total > 0n + ? ` + Total (${payments.length} payment${payments.length === 1 ? "" : "s"}) + ${escapeHtml(formatAmount(total))} USDC + ` + : ""; + + const emptyRow = ` No payments to export.`; + + return ` + + + + StellarSplit Payment Summary — Invoice ${escapeHtml(invoiceId)} + + + +

✦ StellarSplit

+

Payment Summary — Invoice #${escapeHtml(invoiceId)}

+ + + + + + + + + + +${payments.length > 0 ? rows : emptyRow} +${totalRow} + +
Recipient AddressAmountTimestampTransaction Hash
+ +