Skip to content

Commit a22de84

Browse files
C0mberryrogaldh
andauthored
feat: Download receipt as csv (#880)
## Description - adding ability to download receipt as csv ## Type of change - [x] New feature ## Screenshots <img width="663" height="623" alt="Screenshot 2026-03-16 at 18 36 56" src="https://github.com/user-attachments/assets/fd525053-ea32-423d-b2c3-f7b88ed543a2" /> [solana-receipt.csv](https://github.com/user-attachments/files/26032500/solana-receipt-4izwTCUeRGAMGReXeXDumiBzAgXPGz6KzccacCf1WU5YXCCpSDjAQT7J6D6dY45bL1NW9AiqwCuWEnz3hbGtZS2y.8.csv) ## Testing 1. open http://localhost:3000/tx/4izwTCUeRGAMGReXeXDumiBzAgXPGz6KzccacCf1WU5YXCCpSDjAQT7J6D6dY45bL1NW9AiqwCuWEnz3hbGtZS2y?view=receipt&cluster=mainnet-beta http://localhost:3000/tx/2kHbPUGzehenUXQbBfAVZGcuTrSUVDMEyU2aGcjFbuUAJkG28CyQPCGZF68u369MU7WHMvJboyioqyihvtR75nLn?view=receipt&cluster=mainnet-beta 3. click on download > csv btn 4. see the csv ## Related Issues [HOO-327/](https://linear.app/solana-fndn/issue/HOO-327/allow-to-download-receipt-in-csv-format) ## Checklist <!-- Verify that you have completed the following before requesting review --> - [x] My code follows the project's style guidelines - [x] I have added tests that prove my fix/feature works - [x] All tests pass locally and in CI - [x] I have run `build:info` script to update build information - [x] CI/CD checks pass - [x] I have included screenshots for protocol screens (if applicable) --------- Co-authored-by: Sergo <rogaldh@radsh.red>
1 parent 665b5ed commit a22de84

23 files changed

+576
-306
lines changed
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { getReceiptAmount, getReceiptMint, getReceiptSymbol } from '../lib';
4+
import type { FormattedReceipt } from '../types';
5+
6+
const SOL_RECEIPT: FormattedReceipt = {
7+
date: { timestamp: 1700000000, utc: '2023-11-14 22:13:20 UTC' },
8+
fee: { formatted: '0.000005', raw: 5000 },
9+
kind: 'sol',
10+
memo: undefined,
11+
network: 'mainnet-beta',
12+
receiver: { address: 'Recv2222', truncated: 'Recv...22' },
13+
sender: { address: 'Send1111', truncated: 'Send...11' },
14+
total: { formatted: '1.5', raw: 1_500_000_000, unit: 'SOL' },
15+
};
16+
17+
const TOKEN_RECEIPT: FormattedReceipt = {
18+
date: { timestamp: 1700000000, utc: '2023-11-14 22:13:20 UTC' },
19+
fee: { formatted: '0.000005', raw: 5000 },
20+
kind: 'token',
21+
memo: undefined,
22+
mint: '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU',
23+
network: 'mainnet-beta',
24+
receiver: { address: 'Recv2222', truncated: 'Recv...22' },
25+
sender: { address: 'Send1111', truncated: 'Send...11' },
26+
symbol: 'USDC',
27+
total: { formatted: '143.25', raw: 143.25, unit: 'USDC' },
28+
};
29+
30+
describe('getReceiptMint', () => {
31+
it('should return undefined for SOL receipts', () => {
32+
expect(getReceiptMint(SOL_RECEIPT)).toBeUndefined();
33+
});
34+
35+
it('should return mint address for token receipts', () => {
36+
expect(getReceiptMint(TOKEN_RECEIPT)).toBe('4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU');
37+
});
38+
39+
it('should return undefined for token receipts without mint', () => {
40+
const receipt: FormattedReceipt = { ...TOKEN_RECEIPT, mint: undefined };
41+
expect(getReceiptMint(receipt)).toBeUndefined();
42+
});
43+
});
44+
45+
describe('getReceiptSymbol', () => {
46+
it('should return undefined for SOL receipts', () => {
47+
expect(getReceiptSymbol(SOL_RECEIPT)).toBeUndefined();
48+
});
49+
50+
it('should return symbol for token receipts', () => {
51+
expect(getReceiptSymbol(TOKEN_RECEIPT)).toBe('USDC');
52+
});
53+
54+
it('should return undefined for token receipts without symbol', () => {
55+
const receipt: FormattedReceipt = { ...TOKEN_RECEIPT, symbol: undefined };
56+
expect(getReceiptSymbol(receipt)).toBeUndefined();
57+
});
58+
});
59+
60+
describe('getReceiptAmount', () => {
61+
it('should convert lamports to SOL for SOL receipts', () => {
62+
expect(getReceiptAmount(SOL_RECEIPT)).toBe(1.5);
63+
});
64+
65+
it('should return raw amount for token receipts', () => {
66+
expect(getReceiptAmount(TOKEN_RECEIPT)).toBe(143.25);
67+
});
68+
});
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { getReceiptAmount, getReceiptMint, getReceiptSymbol } from './lib';
2+
export type { FormattedBaseReceipt, FormattedReceipt, FormattedReceiptToken } from './types';

app/entities/token-receipt/lib.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { lamportsToSol } from '@utils/index';
2+
3+
import type { FormattedReceipt } from './types';
4+
5+
export function getReceiptMint(receipt: FormattedReceipt): string | undefined {
6+
return 'mint' in receipt ? receipt.mint : undefined;
7+
}
8+
9+
export function getReceiptSymbol(receipt: FormattedReceipt): string | undefined {
10+
return receipt.kind === 'token' ? receipt.symbol : undefined;
11+
}
12+
13+
// SOL receipts store total.raw in lamports; token receipts store it as a UI amount.
14+
// Returns the amount in whole units suitable for price math.
15+
export function getReceiptAmount(receipt: FormattedReceipt): number {
16+
return receipt.kind === 'sol' ? lamportsToSol(receipt.total.raw) : receipt.total.raw;
17+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
export type FormattedBaseReceipt = {
2+
date: {
3+
timestamp: number;
4+
utc: string;
5+
};
6+
fee: {
7+
raw: number;
8+
formatted: string;
9+
};
10+
total: {
11+
raw: number;
12+
formatted: string;
13+
unit: string;
14+
};
15+
network: string;
16+
sender: {
17+
address: string;
18+
truncated: string;
19+
domain?: string;
20+
};
21+
receiver: {
22+
address: string;
23+
truncated: string;
24+
domain?: string;
25+
};
26+
memo?: string | undefined;
27+
logoURI?: string | undefined;
28+
};
29+
30+
export type FormattedReceiptToken = FormattedBaseReceipt & {
31+
kind: 'token';
32+
mint?: string | undefined;
33+
symbol?: string | undefined;
34+
};
35+
36+
export type FormattedReceipt = (FormattedBaseReceipt & { kind: 'sol' }) | FormattedReceiptToken;

app/features/receipt/__e2e__/receipt.e2e.ts

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -47,16 +47,7 @@ test.describe('when feature enabled', () => {
4747
});
4848

4949
test('renders receipt for valid transaction', async ({ page }) => {
50-
await waitForPage(page, VALID_TX, 'receipt');
51-
52-
await page
53-
.locator('h3:has-text("Solana Receipt")')
54-
.or(page.locator('text=Not Found'))
55-
.or(page.locator('text=Receipts can only be generated'))
56-
.first()
57-
.waitFor({ state: 'visible', timeout: CONTENT_TIMEOUT });
58-
59-
const hasReceipt = await hasElement(page, 'h3:has-text("Solana Receipt")');
50+
const hasReceipt = await navigateToReceipt(page);
6051
const hasError = await hasElement(page, 'text=Not Found');
6152
const hasNoReceipt = await hasElement(page, 'text=Receipts can only be generated');
6253

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

87+
test('shows CSV and PDF options in download menu', async ({ page }) => {
88+
const hasReceipt = await navigateToReceipt(page);
89+
if (!hasReceipt) return;
90+
91+
await page.locator('button:has-text("Download")').click();
92+
await expect(page.locator('button:has-text("CSV")')).toBeVisible({ timeout: CONTENT_TIMEOUT });
93+
await expect(page.locator('button:has-text("PDF")')).toBeVisible({ timeout: CONTENT_TIMEOUT });
94+
});
95+
96+
test('triggers CSV download with correct filename', async ({ page }) => {
97+
const hasReceipt = await navigateToReceipt(page);
98+
if (!hasReceipt) return;
99+
100+
await page.locator('button:has-text("Download")').click();
101+
102+
const [download] = await Promise.all([
103+
page.waitForEvent('download'),
104+
page.locator('button:has-text("CSV")').click(),
105+
]);
106+
107+
// eslint-disable-next-line no-restricted-syntax -- regex needed to validate filename pattern: solana-receipt-<signature>.csv
108+
expect(download.suggestedFilename()).toMatch(/^solana-receipt-.+\.csv$/);
109+
});
110+
111+
test('shows Downloaded! state after CSV download completes', async ({ page }) => {
112+
const hasReceipt = await navigateToReceipt(page);
113+
if (!hasReceipt) return;
114+
115+
await page.locator('button:has-text("Download")').click();
116+
await page.locator('button:has-text("CSV")').click();
117+
118+
await expect(page.locator('button:has-text("Downloaded!")')).toBeVisible({ timeout: CONTENT_TIMEOUT });
119+
});
120+
96121
test('shows View Receipt button', async ({ page }) => {
97122
await waitForPage(page, VALID_TX);
98123

@@ -159,6 +184,19 @@ async function hasElement(page: Page, selector: string, timeout = 10000): Promis
159184
}
160185
}
161186

187+
async function navigateToReceipt(page: Page): Promise<boolean> {
188+
await waitForPage(page, VALID_TX, 'receipt');
189+
190+
await page
191+
.locator('h3:has-text("Solana Receipt")')
192+
.or(page.locator('text=Not Found'))
193+
.or(page.locator('text=Receipts can only be generated'))
194+
.first()
195+
.waitFor({ state: 'visible', timeout: CONTENT_TIMEOUT });
196+
197+
return hasElement(page, 'h3:has-text("Solana Receipt")');
198+
}
199+
162200
async function waitForPage(page: Page, tx: string, view?: 'receipt') {
163201
const url = view ? `/tx/${tx}?view=${view}` : `/tx/${tx}`;
164202

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
import type { FormattedReceipt } from '../../types';
4+
import { buildReceiptCsvRow, generateReceiptCsv } from '../generate-receipt-csv';
5+
6+
const RECEIPT: FormattedReceipt = {
7+
date: { timestamp: 1700000000, utc: '2023-11-14 22:13:20 UTC' },
8+
fee: { formatted: '0.000005', raw: 5000 },
9+
kind: 'sol',
10+
memo: 'Payment for services',
11+
network: 'mainnet-beta',
12+
receiver: { address: 'ReceiverAddr2222222222222222222222222222222', truncated: 'Recv...2222' },
13+
sender: { address: 'SenderAddr111111111111111111111111111111111', truncated: 'Send...1111' },
14+
total: { formatted: '1.0', raw: 1000000000, unit: 'SOL' },
15+
};
16+
17+
const SIGNATURE = '5UfDuX7hXbGjGHqPXRGaHdSecretSignature1234567890abcdef';
18+
19+
describe('buildReceiptCsvRow', () => {
20+
it('should include all expected fields in correct column order', () => {
21+
const row = buildReceiptCsvRow(RECEIPT, SIGNATURE);
22+
23+
expect(row[0]).toBe('2023-11-14 22:13:20 UTC');
24+
expect(row[1]).toBe(SIGNATURE);
25+
expect(row[2]).toBe('mainnet-beta');
26+
expect(row[3]).toBe('SenderAddr111111111111111111111111111111111');
27+
expect(row[4]).toBe('ReceiverAddr2222222222222222222222222222222');
28+
expect(row[5]).toBe('1.0');
29+
expect(row[6]).toBe('SOL');
30+
expect(row[7]).toBe('');
31+
expect(row[8]).toBe('');
32+
expect(row[9]).toBe('0.000005');
33+
expect(row[10]).toBe('Payment for services');
34+
expect(row).toHaveLength(11);
35+
});
36+
37+
it('should include USD value when provided', () => {
38+
const row = buildReceiptCsvRow(RECEIPT, SIGNATURE, '$150.00');
39+
expect(row[8]).toBe('$150.00');
40+
});
41+
42+
it('should leave mint field empty for SOL receipts', () => {
43+
const row = buildReceiptCsvRow(RECEIPT, SIGNATURE);
44+
expect(row[7]).toBe('');
45+
});
46+
47+
it('should include mint address for token receipts', () => {
48+
const tokenReceipt: FormattedReceipt = {
49+
...RECEIPT,
50+
kind: 'token',
51+
mint: '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU',
52+
symbol: 'USDC',
53+
total: { formatted: '143.25', raw: 143.25, unit: 'USDC' },
54+
};
55+
const row = buildReceiptCsvRow(tokenReceipt, SIGNATURE);
56+
expect(row[6]).toBe('USDC');
57+
expect(row[7]).toBe('4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU');
58+
});
59+
60+
it('should leave memo field empty when absent', () => {
61+
const receiptNoMemo: FormattedReceipt = { ...RECEIPT, memo: undefined };
62+
const row = buildReceiptCsvRow(receiptNoMemo, SIGNATURE);
63+
expect(row[10]).toBe('');
64+
});
65+
66+
it('should sanitize memo with formula-injection prefix', () => {
67+
const receipt: FormattedReceipt = { ...RECEIPT, memo: '=SUM(A1)' };
68+
const row = buildReceiptCsvRow(receipt, SIGNATURE);
69+
expect(row[10]).toBe("'=SUM(A1)");
70+
});
71+
72+
it('should sanitize token symbol with formula-injection prefix', () => {
73+
const receipt: FormattedReceipt = {
74+
...RECEIPT,
75+
kind: 'token',
76+
mint: '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU',
77+
symbol: '=EVIL',
78+
total: { formatted: '100', raw: 100, unit: '=EVIL' },
79+
};
80+
const row = buildReceiptCsvRow(receipt, SIGNATURE);
81+
expect(row[6]).toBe("'=EVIL");
82+
});
83+
});
84+
85+
describe('generateReceiptCsv', () => {
86+
let mockClick: ReturnType<typeof vi.fn>;
87+
let linkElement: Record<string, unknown>;
88+
89+
beforeEach(() => {
90+
mockClick = vi.fn();
91+
linkElement = { click: mockClick, download: '', href: '' };
92+
vi.spyOn(document, 'createElement').mockReturnValue(linkElement as unknown as HTMLElement);
93+
vi.spyOn(document.body, 'appendChild').mockReturnValue(linkElement as unknown as ChildNode);
94+
vi.spyOn(document.body, 'removeChild').mockReturnValue(linkElement as unknown as ChildNode);
95+
vi.stubGlobal('URL', {
96+
createObjectURL: vi.fn().mockReturnValue('blob:test-url'),
97+
revokeObjectURL: vi.fn(),
98+
});
99+
});
100+
101+
afterEach(() => {
102+
vi.unstubAllGlobals();
103+
vi.restoreAllMocks();
104+
});
105+
106+
it('should set the correct download filename', async () => {
107+
await generateReceiptCsv(RECEIPT, SIGNATURE);
108+
expect(linkElement.download).toBe(`solana-receipt-${SIGNATURE}.csv`);
109+
});
110+
111+
it('should set the href to the object URL', async () => {
112+
await generateReceiptCsv(RECEIPT, SIGNATURE);
113+
expect(linkElement.href).toBe('blob:test-url');
114+
});
115+
116+
it('should revoke the object URL after triggering download', async () => {
117+
await generateReceiptCsv(RECEIPT, SIGNATURE);
118+
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:test-url');
119+
});
120+
121+
it('should pass a Blob with CSV mime type to createObjectURL', async () => {
122+
await generateReceiptCsv(RECEIPT, SIGNATURE);
123+
124+
const blobArg = (URL.createObjectURL as ReturnType<typeof vi.fn>).mock.calls[0][0] as Blob;
125+
expect(blobArg).toBeInstanceOf(Blob);
126+
expect(blobArg.type).toBe('text/csv;charset=utf-8;');
127+
});
128+
129+
it('should pass a non-empty Blob to createObjectURL', async () => {
130+
await generateReceiptCsv(RECEIPT, SIGNATURE);
131+
132+
const blobArg = (URL.createObjectURL as ReturnType<typeof vi.fn>).mock.calls[0][0] as Blob;
133+
expect(blobArg.size).toBeGreaterThan(0);
134+
});
135+
});

0 commit comments

Comments
 (0)