Skip to content

Commit a60f28d

Browse files
C0mberryrogaldh
andauthored
feat: Download receipt as PDF (#875)
## Description - adding ability to download receipt as pdf ## Type of change - [x] New feature ## Screenshots <img width="600" height="813" alt="Screenshot 2026-03-11 at 18 03 11" src="https://github.com/user-attachments/assets/c8c8fc51-94da-4836-99b8-1319bb0e68ef" /> <img width="725" height="766" alt="Screenshot 2026-03-11 at 18 03 24" src="https://github.com/user-attachments/assets/4edf80db-290c-4a7c-84a8-c39a502a0034" /> ## Testing 1. open http://localhost:3000/tx/4izwTCUeRGAMGReXeXDumiBzAgXPGz6KzccacCf1WU5YXCCpSDjAQT7J6D6dY45bL1NW9AiqwCuWEnz3hbGtZS2y?view=receipt&cluster=mainnet-beta 2. click on download > pdf btn 3. see the pdf ## Related Issues [HOO-326](https://linear.app/solana-fndn/issue/HOO-326/allow-to-download-receipt-in-pdf-format) ## Checklist - [x] My code follows the project's style guidelines - [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 3904b97 commit a60f28d

34 files changed

+2433
-65
lines changed

.storybook/decorators.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export const createNextjsParameters = (options?: NextjsNavigationOptions): Param
6565

6666
export const nextjsParameters: Parameters = createNextjsParameters();
6767

68-
/** Mocks navigator.clipboard for stories that copy text. Usage: `decorators: [withClipboardMock]` */
68+
/** Mocks navigator.clipboard.writeText for stories that copy text. Usage: `decorators: [withClipboardMock]` */
6969
export const withClipboardMock: Decorator = Story => {
7070
Object.defineProperty(navigator, 'clipboard', {
7171
configurable: true,
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import fetch from 'node-fetch';
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
4+
import { Logger } from '@/app/shared/lib/logger';
5+
6+
import { CACHE_HEADERS, JUPITER_PRICE_ENDPOINT, NO_STORE_HEADERS } from '../config';
7+
8+
vi.mock('@/app/shared/lib/logger', () => ({
9+
Logger: {
10+
error: vi.fn(),
11+
panic: vi.fn(),
12+
warn: vi.fn(),
13+
},
14+
}));
15+
16+
vi.mock('node-fetch', () => ({
17+
default: vi.fn(),
18+
}));
19+
20+
const VALID_MINT = 'So11111111111111111111111111111111111111112';
21+
const mockRequest = new Request(`http://localhost:3000/api/receipt/price/${VALID_MINT}`);
22+
23+
describe('GET /api/receipt/price/[mintAddress]', () => {
24+
beforeEach(() => {
25+
vi.clearAllMocks();
26+
vi.stubEnv('JUPITER_API_KEY', 'test-api-key');
27+
});
28+
29+
afterEach(() => {
30+
vi.unstubAllEnvs();
31+
});
32+
33+
describe('validation', () => {
34+
it('returns 400 for an invalid mint address', async () => {
35+
const { GET } = await import('../route');
36+
const response = await GET(mockRequest, { params: { mintAddress: 'not-a-valid-pubkey' } });
37+
38+
expect(response.status).toBe(400);
39+
const data = await response.json();
40+
expect(data).toEqual({ error: 'Invalid mint address' });
41+
});
42+
});
43+
44+
describe('missing API key', () => {
45+
it('returns 500 when JUPITER_API_KEY is not set', async () => {
46+
vi.unstubAllEnvs();
47+
vi.stubEnv('JUPITER_API_KEY', '');
48+
vi.resetModules();
49+
const { GET } = await import('../route');
50+
51+
const response = await GET(mockRequest, { params: { mintAddress: VALID_MINT } });
52+
53+
expect(response.status).toBe(500);
54+
const data = await response.json();
55+
expect(data).toEqual({ error: 'Jupiter API is misconfigured' });
56+
expect(response.headers.get('Cache-Control')).toBe(NO_STORE_HEADERS['Cache-Control']);
57+
});
58+
});
59+
60+
describe('Jupiter API errors', () => {
61+
it('returns 429 and calls Logger.warn on rate limit', async () => {
62+
vi.resetModules();
63+
const { GET } = await import('../route');
64+
vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 429 } as ReturnType<
65+
typeof fetch
66+
> extends Promise<infer T>
67+
? T
68+
: never);
69+
70+
const response = await GET(mockRequest, { params: { mintAddress: VALID_MINT } });
71+
72+
expect(response.status).toBe(429);
73+
expect(Logger.warn).toHaveBeenCalledWith('Jupiter price API rate limit exceeded', { sentry: true });
74+
expect(response.headers.get('Cache-Control')).toBe(NO_STORE_HEADERS['Cache-Control']);
75+
});
76+
77+
it('returns 502 and calls Logger.error on non-rate-limit HTTP error', async () => {
78+
vi.resetModules();
79+
const { GET } = await import('../route');
80+
vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 503 } as ReturnType<
81+
typeof fetch
82+
> extends Promise<infer T>
83+
? T
84+
: never);
85+
86+
const response = await GET(mockRequest, { params: { mintAddress: VALID_MINT } });
87+
88+
expect(response.status).toBe(502);
89+
expect(Logger.error).toHaveBeenCalledWith(new Error('Jupiter price API error: 503'), { sentry: true });
90+
expect(response.headers.get('Cache-Control')).toBe(NO_STORE_HEADERS['Cache-Control']);
91+
});
92+
});
93+
94+
describe('schema mismatch', () => {
95+
it('returns { price: null } with no-store headers when response schema is unexpected', async () => {
96+
vi.resetModules();
97+
const { GET } = await import('../route');
98+
vi.mocked(fetch).mockResolvedValueOnce({
99+
json: async () => ({ [VALID_MINT]: { usdPrice: -1 } }),
100+
ok: true,
101+
} as ReturnType<typeof fetch> extends Promise<infer T> ? T : never);
102+
103+
const response = await GET(mockRequest, { params: { mintAddress: VALID_MINT } });
104+
105+
expect(response.status).toBe(200);
106+
const data = await response.json();
107+
expect(data).toEqual({ price: null });
108+
expect(response.headers.get('Cache-Control')).toBe(NO_STORE_HEADERS['Cache-Control']);
109+
});
110+
111+
it('logs and captures the error on schema mismatch', async () => {
112+
vi.resetModules();
113+
const { GET } = await import('../route');
114+
vi.mocked(fetch).mockResolvedValueOnce({
115+
json: async () => ({ [VALID_MINT]: { usdPrice: 0 } }),
116+
ok: true,
117+
} as ReturnType<typeof fetch> extends Promise<infer T> ? T : never);
118+
119+
await GET(mockRequest, { params: { mintAddress: VALID_MINT } });
120+
121+
const expectedErr = new Error(`Jupiter price API returned unexpected schema for ${VALID_MINT}`);
122+
expect(Logger.error).toHaveBeenCalledWith(expectedErr, { sentry: true });
123+
});
124+
});
125+
126+
describe('successful response', () => {
127+
it('returns the price with cache headers', async () => {
128+
vi.resetModules();
129+
const { GET } = await import('../route');
130+
vi.mocked(fetch).mockResolvedValueOnce({
131+
json: async () => ({ [VALID_MINT]: { usdPrice: 180.5 } }),
132+
ok: true,
133+
} as ReturnType<typeof fetch> extends Promise<infer T> ? T : never);
134+
135+
const response = await GET(mockRequest, { params: { mintAddress: VALID_MINT } });
136+
137+
expect(response.status).toBe(200);
138+
const data = await response.json();
139+
expect(data).toEqual({ price: 180.5 });
140+
expect(response.headers.get('Cache-Control')).toBe(CACHE_HEADERS['Cache-Control']);
141+
});
142+
143+
it('calls the Jupiter price endpoint with the correct URL', async () => {
144+
vi.resetModules();
145+
const { GET } = await import('../route');
146+
vi.mocked(fetch).mockResolvedValueOnce({
147+
json: async () => ({ [VALID_MINT]: { usdPrice: 180.5 } }),
148+
ok: true,
149+
} as ReturnType<typeof fetch> extends Promise<infer T> ? T : never);
150+
151+
await GET(mockRequest, { params: { mintAddress: VALID_MINT } });
152+
153+
expect(fetch).toHaveBeenCalledWith(`${JUPITER_PRICE_ENDPOINT}?ids=${VALID_MINT}`, expect.any(Object));
154+
});
155+
});
156+
157+
describe('fetch exception', () => {
158+
it('returns 500 and calls Logger.panic on unexpected error', async () => {
159+
vi.resetModules();
160+
const { GET } = await import('../route');
161+
const error = new Error('Network failure');
162+
vi.mocked(fetch).mockRejectedValueOnce(error);
163+
164+
const response = await GET(mockRequest, { params: { mintAddress: VALID_MINT } });
165+
166+
expect(response.status).toBe(500);
167+
const data = await response.json();
168+
expect(data).toEqual({ error: 'Failed to fetch price data' });
169+
expect(Logger.panic).toHaveBeenCalledWith(new Error('Jupiter price API error', { cause: error }));
170+
expect(response.headers.get('Cache-Control')).toBe(NO_STORE_HEADERS['Cache-Control']);
171+
});
172+
});
173+
});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export const JUPITER_PRICE_ENDPOINT = 'https://api.jup.ag/price/v3';
2+
3+
export const CACHE_MAX_AGE = 14400;
4+
export const CACHE_HEADERS = {
5+
'Cache-Control': `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_MAX_AGE}, stale-while-revalidate=3600`,
6+
};
7+
export const NO_STORE_HEADERS = { 'Cache-Control': 'no-store, max-age=0' };
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { PublicKey } from '@solana/web3.js';
2+
import { NextResponse } from 'next/server';
3+
import fetch from 'node-fetch';
4+
import { is, number, refine, type } from 'superstruct';
5+
6+
import { Logger } from '@/app/shared/lib/logger';
7+
8+
import { CACHE_HEADERS, JUPITER_PRICE_ENDPOINT, NO_STORE_HEADERS } from './config';
9+
10+
const JupiterPriceTokenSchema = type({
11+
usdPrice: refine(number(), 'positive', value => value > 0),
12+
});
13+
14+
type JupiterPriceV3Response = Record<string, { usdPrice: number }>;
15+
16+
const JUPITER_API_KEY = process.env.JUPITER_API_KEY;
17+
18+
type Params = {
19+
params: {
20+
mintAddress: string;
21+
};
22+
};
23+
24+
export async function GET(_request: Request, { params: { mintAddress } }: Params) {
25+
try {
26+
new PublicKey(mintAddress);
27+
} catch {
28+
return NextResponse.json({ error: 'Invalid mint address' }, { status: 400 });
29+
}
30+
31+
if (!JUPITER_API_KEY) {
32+
return NextResponse.json({ error: 'Jupiter API is misconfigured' }, { headers: NO_STORE_HEADERS, status: 500 });
33+
}
34+
35+
try {
36+
const response = await fetch(`${JUPITER_PRICE_ENDPOINT}?ids=${mintAddress}`, {
37+
headers: {
38+
'Content-Type': 'application/json',
39+
'x-api-key': JUPITER_API_KEY,
40+
},
41+
});
42+
43+
if (!response.ok) {
44+
if (response.status === 429) {
45+
Logger.warn('Jupiter price API rate limit exceeded', { sentry: true });
46+
} else {
47+
Logger.error(new Error(`Jupiter price API error: ${response.status}`), { sentry: true });
48+
}
49+
return NextResponse.json(
50+
{ error: 'Failed to fetch price data' },
51+
{ headers: NO_STORE_HEADERS, status: response.status === 429 ? 429 : 502 }
52+
);
53+
}
54+
55+
const data = (await response.json()) as JupiterPriceV3Response;
56+
const token = data?.[mintAddress];
57+
58+
if (!is(token, JupiterPriceTokenSchema)) {
59+
const err = new Error(`Jupiter price API returned unexpected schema for ${mintAddress}`);
60+
Logger.error(err, { sentry: true });
61+
return NextResponse.json({ price: null }, { headers: NO_STORE_HEADERS });
62+
}
63+
64+
return NextResponse.json({ price: token.usdPrice }, { headers: CACHE_HEADERS });
65+
} catch (error) {
66+
Logger.panic(new Error('Jupiter price API error', { cause: error }));
67+
return NextResponse.json({ error: 'Failed to fetch price data' }, { headers: NO_STORE_HEADERS, status: 500 });
68+
}
69+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
import { Cluster } from '@/app/utils/cluster';
4+
5+
import { getTokenInfo } from '../get-token-info';
6+
7+
describe('getTokenInfo', () => {
8+
const mintAddress = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
9+
10+
beforeEach(() => {
11+
vi.clearAllMocks();
12+
vi.spyOn(console, 'error').mockImplementationOnce(() => {});
13+
});
14+
15+
it('should return token info on success', async () => {
16+
const tokenInfo = {
17+
logoURI: 'https://example.com/usdc.png',
18+
symbol: 'USDC',
19+
};
20+
21+
vi.stubGlobal(
22+
'fetch',
23+
vi.fn().mockResolvedValueOnce({
24+
json: () => Promise.resolve({ content: [tokenInfo] }),
25+
status: 200,
26+
})
27+
);
28+
29+
const result = await getTokenInfo(mintAddress, Cluster.MainnetBeta);
30+
31+
expect(result).toEqual(tokenInfo);
32+
});
33+
34+
it('should return undefined when response has missing content', async () => {
35+
vi.stubGlobal(
36+
'fetch',
37+
vi.fn().mockResolvedValueOnce({
38+
json: () => Promise.resolve({}),
39+
status: 200,
40+
})
41+
);
42+
43+
const result = await getTokenInfo(mintAddress, Cluster.MainnetBeta);
44+
45+
expect(result).toBeUndefined();
46+
});
47+
});

0 commit comments

Comments
 (0)