Skip to content
Merged
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
95 changes: 95 additions & 0 deletions src/__tests__/paymentExportPdf.test.tsx
Original file line number Diff line number Diff line change
@@ -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: "<script>alert(1)</script>", amount: 1n },
] as unknown as PaymentWithMeta[]);
expect(html).not.toContain("<script>alert(1)</script>");
expect(html).toContain("&lt;script&gt;");
});
});

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(
<PaymentExport invoiceId={INVOICE_ID} payments={payments as Payment[]} />
);
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(
<PaymentExport invoiceId={INVOICE_ID} payments={payments as Payment[]} />
);

fireEvent.click(screen.getByRole("button", { name: "PDF" }));
fireEvent.click(
screen.getByRole("button", { name: /export payments pdf/i })
);

expect(openSpy).toHaveBeenCalled();
});
});
60 changes: 51 additions & 9 deletions src/components/PaymentExport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>('');
const [endDate, setEndDate] = useState<string>('');
const [exporting, setExporting] = useState(false);
const [exportFormat, setExportFormat] = useState<ExportFormat>('csv');
const [selectedColumns, setSelectedColumns] = useState<PaymentExportColumn[]>([
...PAYMENT_EXPORT_COLUMNS,
]);
Expand All @@ -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.');
Expand All @@ -78,21 +92,47 @@ export default function PaymentExport({ invoiceId, payments }: Props) {
<div className="bg-gray-900 rounded-lg p-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4">
<h3 className="text-sm font-medium text-gray-300">Export Payment History</h3>
<div className="flex gap-2 flex-wrap">
<div className="flex gap-2 flex-wrap items-center">
<button
type="button"
onClick={() => setShowFilters(!showFilters)}
className="px-3 py-1.5 rounded-lg bg-gray-800 hover:bg-gray-700 text-sm transition-colors border border-gray-700"
>
{showFilters ? 'Hide Filters' : 'Show Filters'}
</button>

{/* Export format selector */}
<div
role="group"
aria-label="Export format"
className="inline-flex rounded-lg border border-gray-700 overflow-hidden"
>
{EXPORT_FORMATS.map((format) => (
<button
key={format}
type="button"
onClick={() => setExportFormat(format)}
aria-pressed={exportFormat === format}
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
exportFormat === format
? 'bg-indigo-600 text-white'
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'
}`}
>
{format.toUpperCase()}
</button>
))}
</div>

<button
type="button"
onClick={handleExport}
disabled={exporting || payments.length === 0}
className="px-4 py-1.5 rounded-lg bg-indigo-600 hover:bg-indigo-500 disabled:bg-gray-700 disabled:text-gray-500 text-white text-sm font-semibold transition-colors"
>
{exporting ? 'Exporting...' : 'Export Payments CSV'}
{exporting
? 'Exporting...'
: `Export Payments ${exportFormat.toUpperCase()}`}
</button>
</div>
</div>
Expand Down Expand Up @@ -151,7 +191,7 @@ export default function PaymentExport({ invoiceId, payments }: Props) {
)}

{/* Column Selection */}
{payments.length > 0 && (
{payments.length > 0 && exportFormat === 'csv' && (
<div className="border-t border-gray-800 pt-4 mt-4">
<p className="text-xs text-gray-400 mb-2">Columns to include</p>
<div className="flex flex-wrap gap-3">
Expand Down Expand Up @@ -183,7 +223,9 @@ export default function PaymentExport({ invoiceId, payments }: Props) {
{/* Export Info */}
{payments.length > 0 && !showFilters && (
<p className="text-xs text-gray-500 mt-2">
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.'}
</p>
)}
</div>
Expand Down
143 changes: 143 additions & 0 deletions src/lib/paymentExportPdf.ts
Original file line number Diff line number Diff line change
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}

/** 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<bigint>((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 ` <tr>
<td>${escapeHtml(payment.payer)}</td>
<td class="amount">${escapeHtml(formatAmount(payment.amount))} USDC</td>
<td>${escapeHtml(formatTimestamp(payment.timestamp)) || "—"}</td>
<td class="hash">${hash}</td>
</tr>`;
})
.join("\n");

const totalRow =
total > 0n
? ` <tr class="total-row">
<td colspan="3">Total (${payments.length} payment${payments.length === 1 ? "" : "s"})</td>
<td class="amount">${escapeHtml(formatAmount(total))} USDC</td>
</tr>`
: "";

const emptyRow = ` <tr><td colspan="4" class="empty">No payments to export.</td></tr>`;

return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>StellarSplit Payment Summary — Invoice ${escapeHtml(invoiceId)}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; max-width: 820px; margin: 40px auto; color: ${theme.text}; background: ${theme.background}; font-size: 13px; line-height: 1.6; }
h1 { font-size: 22px; margin-bottom: 4px; color: ${theme.text}; }
.subtitle { color: ${theme.mutedText}; margin-bottom: 28px; font-size: 12px; }
table { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
thead th { text-align: left; padding: 8px; background: ${theme.surface}; color: ${theme.mutedText}; border-bottom: 2px solid ${theme.border}; font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; }
tbody td { padding: 8px; border-bottom: 1px solid ${theme.border}; vertical-align: top; word-break: break-all; }
tbody tr:nth-child(even) td { background: ${theme.surface}; }
.amount { white-space: nowrap; font-variant-numeric: tabular-nums; }
.hash { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; }
.total-row td { font-weight: 700; border-top: 2px solid ${theme.accent}; border-bottom: none; }
.empty { text-align: center; color: ${theme.mutedText}; padding: 24px 0; }
a { color: ${theme.accent}; }
.footer { margin-top: 32px; font-size: 11px; color: ${theme.mutedText}; border-top: 1px solid ${theme.border}; padding-top: 12px; }
@media print { body { margin: 20px; } }
</style>
</head>
<body>
<h1>✦ StellarSplit</h1>
<p class="subtitle">Payment Summary — Invoice #${escapeHtml(invoiceId)}</p>
<table>
<thead>
<tr>
<th>Recipient Address</th>
<th>Amount</th>
<th>Timestamp</th>
<th>Transaction Hash</th>
</tr>
</thead>
<tbody>
${payments.length > 0 ? rows : emptyRow}
${totalRow}
</tbody>
</table>
<div class="footer">Generated by StellarSplit &mdash; On-chain invoice splitting on Stellar</div>
<script>window.onload = () => { window.print(); };<\/script>
</body>
</html>`;
}

/** Filename (without download path) for the PDF payment summary. */
export function generatePaymentSummaryFilename(invoiceId: string): string {
const date = new Date().toISOString().split("T")[0];
return `payments-invoice-${invoiceId}-${date}.pdf`;
}

/** Open the summary document and trigger the browser's print/PDF dialog. */
export function downloadPaymentSummaryPDF(html: string): void {
if (typeof window === "undefined") return;

const blob = new Blob([html], { type: "text/html" });
const url = URL.createObjectURL(blob);
const win = window.open(url, "_blank");

if (win) {
win.onafterprint = () => {
URL.revokeObjectURL(url);
win.close();
};
} else {
URL.revokeObjectURL(url);
}
}