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
118 changes: 118 additions & 0 deletions src/components/TransactionHistory.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TransactionHistory from './TransactionHistory';
import * as useTransactionsModule from '@/hooks/useTransactions';
import * as receiptPdfModule from '@/lib/wallet/transactionReceiptPdf';

const mockTransactions = [
{
Expand Down Expand Up @@ -131,4 +133,120 @@ describe('TransactionHistory', () => {
expect(statusBadges.length).toBeGreaterThan(0);
});
});

it('downloads a receipt when the confirmed transaction action is activated by keyboard', async () => {
const user = userEvent.setup();
const downloadReceipt = jest.spyOn(receiptPdfModule, 'downloadTransactionReceiptPdf');
const confirmedTransaction = {
...mockTransactions[0],
type: 'transfer' as const,
blockNumber: 12345,
};
jest.spyOn(useTransactionsModule, 'useTransactions').mockReturnValue({
transactions: [confirmedTransaction],
pending: [],
failed: [],
loading: false,
error: null,
fetchTransactions: jest.fn(),
getTransaction: jest.fn(),
getTransactionReceipt: jest.fn(),
getTransactionCost: jest.fn(),
retryTransaction: jest.fn(),
cancelTransaction: jest.fn(),
estimateCost: jest.fn(),
fetchPendingTransactions: jest.fn(),
fetchFailedTransactions: jest.fn(),
getTotalCosts: jest.fn(),
});

render(<TransactionHistory />);
await user.tab();
await user.tab();
const downloadButton = screen.getByRole('button', {
name: `Download PDF receipt for transaction ${confirmedTransaction.hash}`,
});
expect(downloadButton).toHaveFocus();
await user.keyboard('{Enter}');

expect(downloadReceipt).toHaveBeenCalledWith(
confirmedTransaction,
expect.stringMatching(/^(TESTNET|PUBLIC)$/)
);
});

it('shows an explicit empty state', () => {
jest.spyOn(useTransactionsModule, 'useTransactions').mockReturnValue({
transactions: [],
pending: [],
failed: [],
loading: false,
error: null,
fetchTransactions: jest.fn(),
getTransaction: jest.fn(),
getTransactionReceipt: jest.fn(),
getTransactionCost: jest.fn(),
retryTransaction: jest.fn(),
cancelTransaction: jest.fn(),
estimateCost: jest.fn(),
fetchPendingTransactions: jest.fn(),
fetchFailedTransactions: jest.fn(),
getTotalCosts: jest.fn(),
});

render(<TransactionHistory />);

expect(screen.getByText('No transactions to display.')).toBeInTheDocument();
});

it('surfaces an access-denied response and does not provide a receipt', () => {
jest.spyOn(useTransactionsModule, 'useTransactions').mockReturnValue({
transactions: [],
pending: [],
failed: [],
loading: false,
error: 'Access denied',
fetchTransactions: jest.fn(),
getTransaction: jest.fn(),
getTransactionReceipt: jest.fn(),
getTransactionCost: jest.fn(),
retryTransaction: jest.fn(),
cancelTransaction: jest.fn(),
estimateCost: jest.fn(),
fetchPendingTransactions: jest.fn(),
fetchFailedTransactions: jest.fn(),
getTotalCosts: jest.fn(),
});

render(<TransactionHistory />);

expect(screen.getByRole('alert')).toHaveTextContent('Access denied');
expect(screen.queryByRole('button', { name: /Download PDF receipt/ })).not.toBeInTheDocument();
});

it('disables receipt downloads when confirmed ledger data is incomplete', () => {
jest.spyOn(useTransactionsModule, 'useTransactions').mockReturnValue({
transactions: [{ ...mockTransactions[0], type: 'transfer' as const }],
pending: [],
failed: [],
loading: false,
error: null,
fetchTransactions: jest.fn(),
getTransaction: jest.fn(),
getTransactionReceipt: jest.fn(),
getTransactionCost: jest.fn(),
retryTransaction: jest.fn(),
cancelTransaction: jest.fn(),
estimateCost: jest.fn(),
fetchPendingTransactions: jest.fn(),
fetchFailedTransactions: jest.fn(),
getTotalCosts: jest.fn(),
});

render(<TransactionHistory />);

expect(
screen.getByRole('button', { name: /Download PDF receipt/ })
).toBeDisabled();
});
});
63 changes: 52 additions & 11 deletions src/components/TransactionHistory.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { useEffect, useState } from 'react';
import {
transactionAPI,
Transaction,
import type {
TransactionFilters,
TransactionStatus,
TransactionType,
} from '@/lib/api/transactionAPI';
import { useTransactions } from '@/hooks/useTransactions';
import { formatDateTime } from '@/utils/formatDate';
import { isTestnetNetwork } from '@/lib/blockchain/network';
import {
downloadTransactionReceiptPdf,
isReceiptEligibleTransaction,
} from '@/lib/wallet/transactionReceiptPdf';

const statusColors: Record<TransactionStatus, string> = {
pending: 'bg-yellow-100 text-yellow-800',
Expand All @@ -16,7 +20,7 @@ const statusColors: Record<TransactionStatus, string> = {
};

export default function TransactionHistory() {
const { transactions, loading, fetchTransactions, retryTransaction } = useTransactions();
const { transactions, loading, error, fetchTransactions, retryTransaction } = useTransactions();
const [filters, setFilters] = useState<TransactionFilters>({});

useEffect(() => {
Expand All @@ -32,7 +36,7 @@ export default function TransactionHistory() {
}
};

if (loading) return <div className="p-4">Loading...</div>;
if (loading) return <div className="p-4" role="status">Loading...</div>;

return (
<div className="p-6">
Expand Down Expand Up @@ -68,15 +72,26 @@ export default function TransactionHistory() {
<table className="min-w-full bg-white border">
<thead>
<tr className="bg-gray-100">
<th className="px-4 py-2 border">Hash</th>
<th className="px-4 py-2 border">Type</th>
<th className="px-4 py-2 border">Status</th>
<th className="px-4 py-2 border">Fee</th>
<th className="px-4 py-2 border">Time</th>
<th className="px-4 py-2 border">Actions</th>
<th scope="col" className="px-4 py-2 border">Hash</th>
<th scope="col" className="px-4 py-2 border">Type</th>
<th scope="col" className="px-4 py-2 border">Status</th>
<th scope="col" className="px-4 py-2 border">Fee</th>
<th scope="col" className="px-4 py-2 border">Time</th>
<th scope="col" className="px-4 py-2 border">Actions</th>
</tr>
</thead>
<tbody>
{transactions.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-8 border text-center text-sm text-gray-500">
{error ? (
<span role="alert">Unable to load transactions: {error}</span>
) : (
'No transactions to display.'
)}
</td>
</tr>
)}
{transactions.map((tx) => (
<tr key={tx.id} className="hover:bg-gray-50">
<td className="px-4 py-2 border font-mono text-sm">
Expand All @@ -99,12 +114,38 @@ export default function TransactionHistory() {
Retry
</button>
)}
{tx.status === 'confirmed' && tx.type === 'transfer' && (
<button
type="button"
onClick={() =>
downloadTransactionReceiptPdf(
tx,
isTestnetNetwork() ? 'TESTNET' : 'PUBLIC'
)
}
disabled={!isReceiptEligibleTransaction(tx)}
className="ml-2 text-blue-600 hover:underline text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:text-gray-400 disabled:no-underline disabled:cursor-not-allowed"
aria-label={`Download PDF receipt for transaction ${tx.hash}`}
title={
isReceiptEligibleTransaction(tx)
? 'Download PDF receipt'
: 'Receipt unavailable without a confirmed ledger and transaction hash'
}
>
Download receipt PDF
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{error && transactions.length > 0 && (
<p role="alert" className="mt-3 text-sm text-red-700">
Unable to refresh transactions: {error}
</p>
)}
</div>
);
}
52 changes: 52 additions & 0 deletions src/lib/wallet/transactionReceiptPdf.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { Transaction } from '@/lib/api/transactionAPI';
import {
createTransactionReceiptPdf,
isReceiptEligibleTransaction,
} from './transactionReceiptPdf';

const confirmedTransaction: Transaction = {
id: 'server-transaction-id',
hash: 'a'.repeat(64),
type: 'transfer',
status: 'confirmed',
fromAddress: 'private-source-address',
toAddress: 'private-recipient-address',
amount: '50',
fee: '100',
timestamp: '2026-10-22T10:00:00.000Z',
blockNumber: 12345,
confirmations: 10,
metadata: { memo: 'private veterinary information' },
};

describe('transaction receipt PDF', () => {
it('creates a PDF using confirmed ledger fields without account metadata', () => {
const pdf = new TextDecoder().decode(
createTransactionReceiptPdf(confirmedTransaction, 'TESTNET')
);

expect(pdf.startsWith('%PDF-1.4')).toBe(true);
expect(pdf).toContain('Status: Confirmed');
expect(pdf).toContain('Network: Stellar Testnet');
expect(pdf).toContain(`Transaction hash: ${confirmedTransaction.hash}`);
expect(pdf).toContain('Ledger: 12345');
expect(pdf).toContain('Confirmations: 10');
expect(pdf).not.toContain('private-source-address');
expect(pdf).not.toContain('private-recipient-address');
expect(pdf).not.toContain('private veterinary information');
});

it.each([
['non-wallet transaction', { type: 'record_creation' as const }],
['pending', { status: 'pending' as const }],
['missing hash', { hash: '' }],
['missing ledger', { blockNumber: undefined }],
['invalid ledger', { blockNumber: 0 }],
])('rejects transactions that are not receipt-eligible: %s', (_case, overrides) => {
const transaction = { ...confirmedTransaction, ...overrides };
expect(isReceiptEligibleTransaction(transaction)).toBe(false);
expect(() => createTransactionReceiptPdf(transaction, 'PUBLIC')).toThrow(
/confirmed transactions/
);
});
});
97 changes: 97 additions & 0 deletions src/lib/wallet/transactionReceiptPdf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type { Transaction } from '@/lib/api/transactionAPI';

export type ReceiptNetwork = 'TESTNET' | 'PUBLIC';

export function isReceiptEligibleTransaction(transaction: Transaction): boolean {
return (
transaction.type === 'transfer' &&
transaction.status === 'confirmed' &&
typeof transaction.hash === 'string' &&
Boolean(transaction.hash.trim()) &&
Number.isSafeInteger(transaction.blockNumber) &&
(transaction.blockNumber ?? 0) > 0
);
}

function escapePdfText(value: string): string {
return value
.replace(/[^\x20-\x7E]/g, '?')
.replace(/([\\()])/g, '\\$1');
}

function formatTimestamp(timestamp: string): string | null {
const date = new Date(timestamp);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}

export function createTransactionReceiptPdf(
transaction: Transaction,
network: ReceiptNetwork
): Uint8Array {
if (!isReceiptEligibleTransaction(transaction)) {
throw new Error('A receipt is available only for confirmed transactions with a ledger and hash.');
}

const networkLabel = network === 'TESTNET' ? 'Stellar Testnet' : 'Stellar Mainnet';
const timestamp = formatTimestamp(transaction.timestamp);
const lines = [
'PetChain Wallet Transaction Receipt',
'Status: Confirmed',
`Network: ${networkLabel}`,
`Transaction hash: ${transaction.hash}`,
`Ledger: ${transaction.blockNumber}`,
`Confirmations: ${transaction.confirmations}`,
...(timestamp ? [`Transaction time: ${timestamp}`] : []),
];

const textCommands = lines
.map((line, index) => {
const fontSize = index === 0 ? 18 : 11;
const y = index === 0 ? 750 : 710 - (index - 1) * 24;
return `/F1 ${fontSize} Tf 1 0 0 1 50 ${y} Tm (${escapePdfText(line)}) Tj`;
})
.join('\n');
const content = `BT\n${textCommands}\nET`;

const objects = [
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>',
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
`<< /Length ${content.length} >>\nstream\n${content}\nendstream`,
];

let pdf = '%PDF-1.4\n';
const offsets = [0];
for (const [index, object] of objects.entries()) {
offsets.push(pdf.length);
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
}

const xrefOffset = pdf.length;
pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
for (const offset of offsets.slice(1)) {
pdf += `${offset.toString().padStart(10, '0')} 00000 n \n`;
}
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`;

return new TextEncoder().encode(pdf);
}

export function downloadTransactionReceiptPdf(
transaction: Transaction,
network: ReceiptNetwork
): void {
const pdf = createTransactionReceiptPdf(transaction, network);
const blob = new Blob([pdf], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
const safeHash = transaction.hash.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 16);
link.href = url;
link.download = `petchain-receipt-${safeHash}.pdf`;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 0);
}