Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 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
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
68 changes: 68 additions & 0 deletions app/entities/token-receipt/__tests__/lib.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';

import { getReceiptAmount, getReceiptMint, getReceiptSymbol } from '../lib';
import type { FormattedReceipt } from '../types';

const SOL_RECEIPT: FormattedReceipt = {
date: { timestamp: 1700000000, utc: '2023-11-14 22:13:20 UTC' },
fee: { formatted: '0.000005', raw: 5000 },
kind: 'sol',
memo: undefined,
network: 'mainnet-beta',
receiver: { address: 'Recv2222', truncated: 'Recv...22' },
sender: { address: 'Send1111', truncated: 'Send...11' },
total: { formatted: '1.5', raw: 1_500_000_000, unit: 'SOL' },
};

const TOKEN_RECEIPT: FormattedReceipt = {
date: { timestamp: 1700000000, utc: '2023-11-14 22:13:20 UTC' },
fee: { formatted: '0.000005', raw: 5000 },
kind: 'token',
memo: undefined,
mint: '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU',
network: 'mainnet-beta',
receiver: { address: 'Recv2222', truncated: 'Recv...22' },
sender: { address: 'Send1111', truncated: 'Send...11' },
symbol: 'USDC',
total: { formatted: '143.25', raw: 143.25, unit: 'USDC' },
};

describe('getReceiptMint', () => {
it('should return undefined for SOL receipts', () => {
expect(getReceiptMint(SOL_RECEIPT)).toBeUndefined();
});

it('should return mint address for token receipts', () => {
expect(getReceiptMint(TOKEN_RECEIPT)).toBe('4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU');
});

it('should return undefined for token receipts without mint', () => {
const receipt: FormattedReceipt = { ...TOKEN_RECEIPT, mint: undefined };
expect(getReceiptMint(receipt)).toBeUndefined();
});
});

describe('getReceiptSymbol', () => {
it('should return undefined for SOL receipts', () => {
expect(getReceiptSymbol(SOL_RECEIPT)).toBeUndefined();
});

it('should return symbol for token receipts', () => {
expect(getReceiptSymbol(TOKEN_RECEIPT)).toBe('USDC');
});

it('should return undefined for token receipts without symbol', () => {
const receipt: FormattedReceipt = { ...TOKEN_RECEIPT, symbol: undefined };
expect(getReceiptSymbol(receipt)).toBeUndefined();
});
});

describe('getReceiptAmount', () => {
it('should convert lamports to SOL for SOL receipts', () => {
expect(getReceiptAmount(SOL_RECEIPT)).toBe(1.5);
});

it('should return raw amount for token receipts', () => {
expect(getReceiptAmount(TOKEN_RECEIPT)).toBe(143.25);
});
});
2 changes: 2 additions & 0 deletions app/entities/token-receipt/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { getReceiptAmount, getReceiptMint, getReceiptSymbol } from './lib';
export type { FormattedBaseReceipt, FormattedReceipt, FormattedReceiptToken } from './types';
17 changes: 17 additions & 0 deletions app/entities/token-receipt/lib.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { lamportsToSol } from '@utils/index';

import type { FormattedReceipt } from './types';

export function getReceiptMint(receipt: FormattedReceipt): string | undefined {
return 'mint' in receipt ? receipt.mint : undefined;
}

export function getReceiptSymbol(receipt: FormattedReceipt): string | undefined {
return receipt.kind === 'token' ? receipt.symbol : undefined;
}

// SOL receipts store total.raw in lamports; token receipts store it as a UI amount.
// Returns the amount in whole units suitable for price math.
export function getReceiptAmount(receipt: FormattedReceipt): number {
return receipt.kind === 'sol' ? lamportsToSol(receipt.total.raw) : receipt.total.raw;
}
36 changes: 36 additions & 0 deletions app/entities/token-receipt/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
export type FormattedBaseReceipt = {
date: {
timestamp: number;
utc: string;
};
fee: {
raw: number;
formatted: string;
};
total: {
raw: number;
formatted: string;
unit: string;
};
network: string;
sender: {
address: string;
truncated: string;
domain?: string;
};
receiver: {
address: string;
truncated: string;
domain?: string;
};
memo?: string | undefined;
logoURI?: string | undefined;
};

export type FormattedReceiptToken = FormattedBaseReceipt & {
kind: 'token';
mint?: string | undefined;
symbol?: string | undefined;
};

export type FormattedReceipt = (FormattedBaseReceipt & { kind: 'sol' }) | FormattedReceiptToken;
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
135 changes: 135 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,135 @@
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 leave mint field empty for SOL receipts', () => {
const row = buildReceiptCsvRow(RECEIPT, SIGNATURE);
expect(row[7]).toBe('');
});

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('');
});

it('should sanitize memo with formula-injection prefix', () => {
const receipt: FormattedReceipt = { ...RECEIPT, memo: '=SUM(A1)' };
const row = buildReceiptCsvRow(receipt, SIGNATURE);
expect(row[10]).toBe("'=SUM(A1)");
});

it('should sanitize token symbol with formula-injection prefix', () => {
const receipt: FormattedReceipt = {
...RECEIPT,
kind: 'token',
mint: '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU',
symbol: '=EVIL',
total: { formatted: '100', raw: 100, unit: '=EVIL' },
};
const row = buildReceiptCsvRow(receipt, SIGNATURE);
expect(row[6]).toBe("'=EVIL");
});
});

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);

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);

const blobArg = (URL.createObjectURL as ReturnType<typeof vi.fn>).mock.calls[0][0] as Blob;
expect(blobArg.size).toBeGreaterThan(0);
});
});
Loading
Loading