Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
bd762e7
feat: allow to download receipt
rogaldh Mar 3, 2026
21788a3
wip
rogaldh Mar 3, 2026
c4fb7e2
WIP: PDF generation
rogaldh Mar 3, 2026
23b09ce
remove print version
rogaldh Mar 5, 2026
2a3d388
improve receipt generation
rogaldh Mar 5, 2026
e6b6147
updated pdf generation
C0mberry Mar 11, 2026
817ba9d
build fix + build info
C0mberry Mar 12, 2026
015c91b
resolve comments
C0mberry Mar 12, 2026
2ab1047
resolve comments
C0mberry Mar 13, 2026
553b0b5
csv download
C0mberry Mar 16, 2026
7368657
added fast-csv lib
C0mberry Mar 17, 2026
5e50311
fixed logger
C0mberry Mar 23, 2026
d839f40
fix merge
C0mberry Mar 23, 2026
4d8aa4d
test rename
C0mberry Mar 23, 2026
da2b074
removed duplicated test
C0mberry Mar 23, 2026
ee1acc3
format prettier
C0mberry Mar 24, 2026
4747ba0
added sanitizeCsvField
C0mberry Mar 24, 2026
923680d
add eslint disable comment
C0mberry Mar 24, 2026
b9a86be
resolve comments
C0mberry Mar 25, 2026
ef9bd1c
lock file update
C0mberry Mar 25, 2026
ff1162e
added kind to tests
C0mberry Mar 25, 2026
55e42e8
added analytics by format
C0mberry Mar 26, 2026
33adcea
removed unused style
C0mberry Mar 26, 2026
2cde777
fix: add sanitization for incoming data
rogaldh Mar 26, 2026
12e3e2d
fix style
C0mberry Mar 26, 2026
3205a7d
rollback native share + x share
C0mberry Mar 26, 2026
7ba408a
fix: remove dead code
rogaldh Mar 27, 2026
84f7975
fix: install only fast-csv subpackage
rogaldh Mar 27, 2026
23352ff
fix: extract entity-level logic from the feature
rogaldh Mar 27, 2026
83d8d8d
fix: codestyle
rogaldh Mar 27, 2026
02332e4
add loading state for download btn
C0mberry Mar 30, 2026
77717de
ui updates
C0mberry Mar 30, 2026
3b4228f
sb fix
C0mberry Mar 30, 2026
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
58 changes: 48 additions & 10 deletions app/features/receipt/__e2e__/receipt.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,16 +47,7 @@ test.describe('when feature enabled', () => {
});

test('renders receipt for valid transaction', async ({ page }) => {
await waitForPage(page, VALID_TX, 'receipt');

await page
.locator('h3:has-text("Solana Receipt")')
.or(page.locator('text=Not Found'))
.or(page.locator('text=Receipts can only be generated'))
.first()
.waitFor({ state: 'visible', timeout: CONTENT_TIMEOUT });

const hasReceipt = await hasElement(page, 'h3:has-text("Solana Receipt")');
const hasReceipt = await navigateToReceipt(page);
const hasError = await hasElement(page, 'text=Not Found');
const hasNoReceipt = await hasElement(page, 'text=Receipts can only be generated');

Expand Down Expand Up @@ -93,6 +84,40 @@ test.describe('when feature enabled', () => {
expect(showsError).toBe(true);
});

test('shows CSV and PDF options in download menu', async ({ page }) => {
const hasReceipt = await navigateToReceipt(page);
if (!hasReceipt) return;

await page.locator('button:has-text("Download")').click();
await expect(page.locator('button:has-text("CSV")')).toBeVisible({ timeout: CONTENT_TIMEOUT });
await expect(page.locator('button:has-text("PDF")')).toBeVisible({ timeout: CONTENT_TIMEOUT });
});

test('triggers CSV download with correct filename', async ({ page }) => {
const hasReceipt = await navigateToReceipt(page);
if (!hasReceipt) return;

await page.locator('button:has-text("Download")').click();

const [download] = await Promise.all([
page.waitForEvent('download'),
page.locator('button:has-text("CSV")').click(),
]);

// eslint-disable-next-line no-restricted-syntax -- regex needed to validate filename pattern: solana-receipt-<signature>.csv
expect(download.suggestedFilename()).toMatch(/^solana-receipt-.+\.csv$/);
});

test('shows Downloaded! state after CSV download completes', async ({ page }) => {
const hasReceipt = await navigateToReceipt(page);
if (!hasReceipt) return;

await page.locator('button:has-text("Download")').click();
await page.locator('button:has-text("CSV")').click();

await expect(page.locator('button:has-text("Downloaded!")')).toBeVisible({ timeout: CONTENT_TIMEOUT });
});

test('shows View Receipt button', async ({ page }) => {
await waitForPage(page, VALID_TX);

Expand Down Expand Up @@ -159,6 +184,19 @@ async function hasElement(page: Page, selector: string, timeout = 10000): Promis
}
}

async function navigateToReceipt(page: Page): Promise<boolean> {
await waitForPage(page, VALID_TX, 'receipt');

await page
.locator('h3:has-text("Solana Receipt")')
.or(page.locator('text=Not Found'))
.or(page.locator('text=Receipts can only be generated'))
.first()
.waitFor({ state: 'visible', timeout: CONTENT_TIMEOUT });

return hasElement(page, 'h3:has-text("Solana Receipt")');
}

async function waitForPage(page: Page, tx: string, view?: 'receipt') {
const url = view ? `/tx/${tx}?view=${view}` : `/tx/${tx}`;

Expand Down
112 changes: 112 additions & 0 deletions app/features/receipt/lib/__tests__/generate-receipt-csv.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import type { FormattedReceipt } from '../../types';
import { buildReceiptCsvRow, generateReceiptCsv } from '../generate-receipt-csv';

const RECEIPT: FormattedReceipt = {
date: { timestamp: 1700000000, utc: '2023-11-14 22:13:20 UTC' },
fee: { formatted: '0.000005', raw: 5000 },
kind: 'sol',
memo: 'Payment for services',
network: 'mainnet-beta',
receiver: { address: 'ReceiverAddr2222222222222222222222222222222', truncated: 'Recv...2222' },
sender: { address: 'SenderAddr111111111111111111111111111111111', truncated: 'Send...1111' },
total: { formatted: '1.0', raw: 1000000000, unit: 'SOL' },
};

const SIGNATURE = '5UfDuX7hXbGjGHqPXRGaHdSecretSignature1234567890abcdef';

describe('buildReceiptCsvRow', () => {
it('should include all expected fields in correct column order', () => {
const row = buildReceiptCsvRow(RECEIPT, SIGNATURE);

expect(row[0]).toBe('2023-11-14 22:13:20 UTC');
expect(row[1]).toBe(SIGNATURE);
expect(row[2]).toBe('mainnet-beta');
expect(row[3]).toBe('SenderAddr111111111111111111111111111111111');
expect(row[4]).toBe('ReceiverAddr2222222222222222222222222222222');
expect(row[5]).toBe('1.0');
expect(row[6]).toBe('SOL');
expect(row[7]).toBe('');
expect(row[8]).toBe('');
expect(row[9]).toBe('0.000005');
expect(row[10]).toBe('Payment for services');
expect(row).toHaveLength(11);
});

it('should include USD value when provided', () => {
const row = buildReceiptCsvRow(RECEIPT, SIGNATURE, '$150.00');
expect(row[8]).toBe('$150.00');
});

it('should include mint address for token receipts', () => {
const tokenReceipt: FormattedReceipt = {
...RECEIPT,
kind: 'token',
mint: '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU',
symbol: 'USDC',
total: { formatted: '143.25', raw: 143.25, unit: 'USDC' },
};
const row = buildReceiptCsvRow(tokenReceipt, SIGNATURE);
expect(row[6]).toBe('USDC');
expect(row[7]).toBe('4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU');
});

it('should leave memo field empty when absent', () => {
const receiptNoMemo: FormattedReceipt = { ...RECEIPT, memo: undefined };
const row = buildReceiptCsvRow(receiptNoMemo, SIGNATURE);
expect(row[10]).toBe('');
});
});

describe('generateReceiptCsv', () => {
let mockClick: ReturnType<typeof vi.fn>;
let linkElement: Record<string, unknown>;

beforeEach(() => {
mockClick = vi.fn();
linkElement = { click: mockClick, download: '', href: '' };
vi.spyOn(document, 'createElement').mockReturnValue(linkElement as unknown as HTMLElement);
vi.spyOn(document.body, 'appendChild').mockReturnValue(linkElement as unknown as ChildNode);
vi.spyOn(document.body, 'removeChild').mockReturnValue(linkElement as unknown as ChildNode);
vi.stubGlobal('URL', {
createObjectURL: vi.fn().mockReturnValue('blob:test-url'),
revokeObjectURL: vi.fn(),
});
});

afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

it('should set the correct download filename', async () => {
await generateReceiptCsv(RECEIPT, SIGNATURE);
expect(linkElement.download).toBe(`solana-receipt-${SIGNATURE}.csv`);
});

it('should set the href to the object URL', async () => {
await generateReceiptCsv(RECEIPT, SIGNATURE);
expect(linkElement.href).toBe('blob:test-url');
});

it('should revoke the object URL after triggering download', async () => {
await generateReceiptCsv(RECEIPT, SIGNATURE);
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:test-url');
});

it('should pass a Blob with CSV mime type to createObjectURL', async () => {
await generateReceiptCsv(RECEIPT, SIGNATURE);
// eslint-disable-next-line no-restricted-syntax -- accessing vitest mock internals for assertion
const blobArg = (URL.createObjectURL as ReturnType<typeof vi.fn>).mock.calls[0][0] as Blob;
expect(blobArg).toBeInstanceOf(Blob);
expect(blobArg.type).toBe('text/csv;charset=utf-8;');
});

it('should pass a non-empty Blob to createObjectURL', async () => {
await generateReceiptCsv(RECEIPT, SIGNATURE);
// eslint-disable-next-line no-restricted-syntax -- accessing vitest mock internals for assertion
const blobArg = (URL.createObjectURL as ReturnType<typeof vi.fn>).mock.calls[0][0] as Blob;
expect(blobArg.size).toBeGreaterThan(0);
});
});
120 changes: 0 additions & 120 deletions app/features/receipt/lib/__tests__/normalize-search-params.test.ts

This file was deleted.

Loading