Skip to content

feat: Download receipt as PDF#875

Open
C0mberry wants to merge 12 commits intosolana-foundation:masterfrom
hoodieshq:development-download-receipt
Open

feat: Download receipt as PDF#875
C0mberry wants to merge 12 commits intosolana-foundation:masterfrom
hoodieshq:development-download-receipt

Conversation

@C0mberry
Copy link
Contributor

@C0mberry C0mberry commented Mar 11, 2026

Description

  • adding ability to download receipt as pdf

Type of change

  • New feature

Screenshots

Screenshot 2026-03-11 at 18 03 11 Screenshot 2026-03-11 at 18 03 24

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

Checklist

  • My code follows the project's style guidelines
  • All tests pass locally and in CI
  • I have run build:info script to update build information
  • CI/CD checks pass
  • I have included screenshots for protocol screens (if applicable)

@vercel
Copy link

vercel bot commented Mar 11, 2026

@C0mberry is attempting to deploy a commit to the Solana Foundation Team on Vercel.

A member of the Team first needs to authorize it.

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 11, 2026

Greptile Summary

This PR adds a "Download PDF" button to the receipt page, generating a structured jsPDF document that includes on-chain payment details, editable supplier/items fields, a QR code for verification, and an optional USD value sourced from a new /api/receipt/price/[mintAddress] Jupiter-backed API route.

Key changes:

  • New /api/receipt/price/[mintAddress] Next.js API route proxies Jupiter price data with mint address validation, rate-limit detection, and Sentry instrumentation.
  • generateReceiptPdf + loadPdfDeps produce a print-ready A4 PDF with both static and AcroForm-editable fields; jsPDF and qrcode are lazy-loaded to avoid bloating the initial bundle.
  • useDownloadReceipt manages a download state machine (idle → downloading → downloaded/errored → idle).
  • useTokenPrice fetches and caches the USD price via SWR for use in the PDF.
  • formatUsdValue is added to app/utils/index.ts with full unit test coverage.

Issues found:

  • The trigger callback in useDownloadReceipt reads state from a stale render-time closure. If the user clicks the button twice before React flushes the setState('downloading') update, both invocations pass the guard and call download() twice. A ref-based guard would eliminate this race.
  • fetchPrice in use-price.ts never throws, so SWR treats every failure (including transient server errors) as successfully-cached data for 5 minutes, preventing automatic retry on recoverable failures.
  • The API route proxies the raw upstream HTTP status code from Jupiter, which leaks implementation details and may surprise callers if Jupiter introduces new error codes.

Confidence Score: 3/5

  • Safe to merge with minor issues — no data loss or security risk, but a stale-closure race condition and a SWR caching edge case should be addressed before this ships to high-traffic users.
  • The overall feature is well-structured with good test coverage, lazy loading, and proper SVG cleanup (finally block). The main concerns are: (1) a stale closure in useDownloadReceipt that can allow duplicate downloads on rapid clicks, (2) fetchPrice swallowing errors as SWR data meaning transient failures cache for 5 minutes with no retry, and (3) raw upstream status codes being proxied. None of these are critical blockers but they degrade the user experience in edge cases.
  • app/features/receipt/lib/use-download-receipt.ts and app/features/receipt/model/use-price.ts need the most attention.

Important Files Changed

Filename Overview
app/api/receipt/price/[mintAddress]/route.ts New API route that proxies Jupiter price data for a given mint address — input validation, rate-limit handling, and Sentry instrumentation are solid; upstream status codes are proxied verbatim rather than normalised.
app/features/receipt/lib/use-download-receipt.ts Custom hook managing download state machine; the in-flight guard reads state from a stale closure (captured at render time), which can allow duplicate downloads on rapid clicks, and the effect body contains a redundant assignment.
app/features/receipt/model/use-price.ts SWR-backed hook for token USD price; because fetchPrice never throws, SWR treats all failures as successful data, permanently caching error results for 5 minutes and preventing automatic retry on transient failures.
app/features/receipt/lib/generate-receipt-pdf.ts Core PDF generation logic using jsPDF; SVG-to-PNG conversion now correctly uses a finally block for cleanup; dynamic font scaling and clickable link placement use hardcoded offsets tied to internal layout constants.
app/features/receipt/ui/DownloadReceiptItem.tsx New UI component wiring useDownloadReceipt into a PopoverMenuItem with per-state icon and label feedback; clean and straightforward.
app/features/receipt/receipt-page.tsx Integrates PDF download into the receipt page by wiring useTokenPrice, formatUsdValue, and loadPdfDeps/generateReceiptPdf into a useCallback; dependency array is correct.
app/utils/index.ts Adds formatUsdValue utility with proper NaN/negative guard; thoroughly unit-tested.

Sequence Diagram

sequenceDiagram
    participant User
    participant DownloadReceiptItem
    participant useDownloadReceipt
    participant receipt-page (downloadPdf)
    participant loadPdfDeps
    participant generateReceiptPdf
    participant useTokenPrice
    participant /api/receipt/price
    participant Jupiter API

    User->>DownloadReceiptItem: click Download > PDF
    DownloadReceiptItem->>useDownloadReceipt: trigger()
    useDownloadReceipt->>useDownloadReceipt: setState('downloading')
    useDownloadReceipt->>receipt-page (downloadPdf): download()
    receipt-page (downloadPdf)->>loadPdfDeps: import('jspdf'), import('qrcode')
    loadPdfDeps-->>receipt-page (downloadPdf): { JsPDF, qrToDataURL }
    receipt-page (downloadPdf)->>generateReceiptPdf: generateReceiptPdf(deps, receipt, sig, url, txUrl, usdValue)
    Note over receipt-page (downloadPdf),generateReceiptPdf: usdValue sourced from useTokenPrice (SWR cache)
    generateReceiptPdf->>generateReceiptPdf: build jsPDF document (layout, editable fields, QR code, logo)
    generateReceiptPdf->>User: doc.save('solana-receipt-<sig>.pdf')
    generateReceiptPdf-->>receipt-page (downloadPdf): resolved
    receipt-page (downloadPdf)-->>useDownloadReceipt: resolved
    useDownloadReceipt->>useDownloadReceipt: setState('downloaded') → scheduleReset(2000ms) → setState('idle')

    Note over useTokenPrice,Jupiter API: Price fetch (mainnet-beta only, SWR-cached 5 min)
    useTokenPrice->>/api/receipt/price: GET /api/receipt/price/{mintAddress}
    /api/receipt/price->>Jupiter API: GET api.jup.ag/price/v3?ids={mintAddress}
    Jupiter API-->>/api/receipt/price: { usdPrice }
    /api/receipt/price-->>useTokenPrice: { price: number | null }
Loading

Last reviewed commit: c8f207d

import { CACHE_HEADERS, NO_STORE_HEADERS } from './config';

const JupiterPriceTokenSchema = type({
usdPrice: number(),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Maybe use refine(number(), 'positive', (value) => value > 0) to make sure it makes sense.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you resolve threads here? I can't. Resolved

return new Intl.NumberFormat('en-US', { maximumFractionDigits }).format(sol);
}

export function formatUsdValue(amount: number, price: number): string {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: It should be tested. It seems simple, but nothing currently filters out negatives or NaN values.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved

qrToDataURL: typeof ToDataURL;
};

const LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="229" height="28" fill="none" viewBox="0 0 229 28"><mask id="a" width="229" height="28" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:luminance"><path fill="#fff" d="M228.83 0H0v27.538h228.83z"/></mask><g mask="url(#a)"><path fill="#1a1a1a" d="M67.472 11.015h-16.68V5.508H71.79V0H50.753a5.53 5.53 0 0 0-3.895 1.602 5.45 5.45 0 0 0-1.614 3.867v5.584c0 1.451.58 2.842 1.614 3.868a5.53 5.53 0 0 0 3.895 1.602h16.68v5.508H45.641v5.507h21.831a5.53 5.53 0 0 0 3.896-1.602 5.45 5.45 0 0 0 1.613-3.867v-5.584c0-1.45-.58-2.842-1.613-3.868a5.53 5.53 0 0 0-3.896-1.602M99.775 0H83.033a5.5 5.5 0 0 0-2.108.416 5.5 5.5 0 0 0-2.979 2.96 5.4 5.4 0 0 0-.418 2.093v16.6a5.44 5.44 0 0 0 1.611 3.867 5.5 5.5 0 0 0 1.786 1.186 5.5 5.5 0 0 0 2.108.416h16.742a5.53 5.53 0 0 0 3.896-1.602 5.45 5.45 0 0 0 1.613-3.867v-16.6a5.45 5.45 0 0 0-1.613-3.867A5.53 5.53 0 0 0 99.775 0m-.038 22.03H83.095V5.509h16.642zM158.369 0h-16.326a5.53 5.53 0 0 0-3.895 1.602 5.45 5.45 0 0 0-1.614 3.867v22.07h5.547v-9.05h16.25v9.05h5.547V5.468a5.44 5.44 0 0 0-1.613-3.868 5.5 5.5 0 0 0-1.787-1.186A5.5 5.5 0 0 0 158.369 0m-.038 12.981h-16.25V5.508h16.25zM223.32 0h-16.322a5.55 5.55 0 0 0-3.903 1.598 5.46 5.46 0 0 0-1.617 3.871v22.07h5.547v-9.05h16.257v9.05h5.547V5.468c0-1.45-.58-2.841-1.614-3.867A5.53 5.53 0 0 0 223.32 0m-.038 12.981h-16.246V5.508h16.246zm-32.278 9.049h-2.219l-7.951-19.735a3.66 3.66 0 0 0-1.35-1.667 3.7 3.7 0 0 0-2.06-.628h-4.938c-.974 0-1.908.384-2.596 1.068a3.63 3.63 0 0 0-1.076 2.577v23.893h5.547V5.508h2.22l7.947 19.735a3.65 3.65 0 0 0 1.348 1.668 3.7 3.7 0 0 0 2.057.627h4.939c.974 0 1.908-.384 2.596-1.067a3.63 3.63 0 0 0 1.075-2.578V0h-5.547zM115.747 0h-5.548v22.069c0 1.45.58 2.842 1.614 3.867a5.53 5.53 0 0 0 3.895 1.602h16.681v-5.507h-16.642z"/><g clip-path="url(#b)" transform="scale(.4375)"><path fill="url(#d)" d="M70.665 50.177 58.939 62.775a2.72 2.72 0 0 1-1.992.867H1.361a1.36 1.36 0 0 1-1.248-.82 1.37 1.37 0 0 1 .253-1.474L12.101 48.75a2.72 2.72 0 0 1 1.986-.867h55.582a1.36 1.36 0 0 1 1.249.82 1.37 1.37 0 0 1-.253 1.474M58.939 24.808a2.72 2.72 0 0 0-1.992-.867H1.361a1.36 1.36 0 0 0-1.248.82 1.37 1.37 0 0 0 .253 1.474l11.735 12.599a2.72 2.72 0 0 0 1.986.866h55.582a1.36 1.36 0 0 0 1.249-.82 1.37 1.37 0 0 0-.253-1.474zm-57.578-9.05h55.586a2.72 2.72 0 0 0 1.992-.866L70.665 2.294A1.364 1.364 0 0 0 69.669 0H14.087A2.72 2.72 0 0 0 12.1.867L.369 13.465a1.364 1.364 0 0 0 .992 2.294"/></g></g><defs><linearGradient id="d" x1="5.996" x2="64.404" y1="65.159" y2="-.566" gradientUnits="userSpaceOnUse"><stop offset=".08" stop-color="#9945ff"/><stop offset=".3" stop-color="#8752f3"/><stop offset=".5" stop-color="#5497d5"/><stop offset=".6" stop-color="#43b4ca"/><stop offset=".72" stop-color="#28e0b9"/><stop offset=".97" stop-color="#19fb9b"/></linearGradient><clipPath id="b"><path fill="#fff" d="M0 0h71.031v63.642H0z"/></clipPath></defs></svg>`;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Maybe it would be better to extract it to a different file. It’s not something that one would actually read as code in this case.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved

@@ -0,0 +1,39 @@
import type { Meta, StoryObj } from '@storybook/react';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: Isn't working for me: Cannot read properties of undefined (reading 'customEqualityTesters')

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved

app/styles.css Outdated
mask: conic-gradient(from -45deg at bottom,#0000,#000 1deg 89deg,#0000 90deg) 50%/21px 100%
}
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Is this code dead? I can't see how it's being used.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved

@@ -0,0 +1,41 @@
import { useCallback, useEffect, useRef, useState } from 'react';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Let's use the same naming conventions at least inside a feature, should be use-download-receipt.ts (+ tests file)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved


useEffect(() => {
return () => {
clearTimeout(timeoutRef.current);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: nothing checks the progress of the download, so setting state on an unmounted component is still possible. Not that important in this case, though

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved (we could also use the AbortController, but might be an overkill)

expect(download).toHaveBeenCalledTimes(2);
});

it('should clean up timeout on unmount', async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

chore: The intent here is not clear, the is no expect at all. Expect something 😀

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved

@C0mberry C0mberry requested a review from askov March 12, 2026 13:39
@C0mberry
Copy link
Contributor Author

@greptile-apps issue was addressed

Comment on lines +24 to +43
const trigger = useCallback(() => {
if (state === 'downloading') return;

clearTimeout(timeoutRef.current);
setState('downloading');

download().then(
() => {
if (!mountedRef.current) return;
setState('downloaded');
scheduleReset();
},
(error: unknown) => {
if (!mountedRef.current) return;
console.error('Download failed:', error);
setState('errored');
scheduleReset();
}
);
}, [state, download, scheduleReset]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale closure makes the in-flight guard unreliable

trigger captures state from the last render. Because state is in the useCallback dependency array, React replaces the trigger reference only after a re-render. If the user clicks twice in rapid succession before React has flushed the state update and re-rendered, both invocations see state === 'idle' and both call download().

A ref-based guard is immune to this race because it reads the current value synchronously, not the captured render-time value:

const stateRef = useRef<DownloadState>('idle');

const trigger = useCallback(() => {
    if (stateRef.current === 'downloading') return;

    clearTimeout(timeoutRef.current);
    stateRef.current = 'downloading';
    setState('downloading');

    download().then(
        () => {
            stateRef.current = 'downloaded';
            if (!mountedRef.current) return;
            setState('downloaded');
            scheduleReset();
        },
        (error: unknown) => {
            stateRef.current = 'errored';
            if (!mountedRef.current) return;
            console.error('Download failed:', error);
            setState('errored');
            scheduleReset();
        }
    );
}, [download, scheduleReset]); // state removed from deps

This also removes state from the useCallback dependency array, meaning trigger keeps the same reference across state transitions and avoids unnecessary re-renders in child components.

Comment on lines +35 to +55
async function fetchPrice([, mintAddress]: PriceSwrKey): Promise<PriceResult> {
try {
const response = await fetch(`/api/receipt/price/${mintAddress}`);

if (!response.ok) {
if (response.status === 429) {
return { price: null, status: PriceStatus.RateLimited };
}
return { price: null, status: PriceStatus.FetchFailed };
}

const data = await response.json();

if (!is(data, PriceResponseSchema)) {
return { price: null, status: PriceStatus.FetchFailed };
}

return { price: data.price, status: PriceStatus.Success };
} catch {
return { price: null, status: PriceStatus.FetchFailed };
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Transient API failures are permanently cached for dedupingInterval

fetchPrice never throws — it always returns a PriceResult even on error. SWR only retries a key when the fetcher throws (or rejects); when the fetcher returns data, that result is cached as "successful" data for the full dedupingInterval (5 minutes, per PRICE_SWR_CONFIG).

This means a transient failure (e.g. a temporary Jupiter outage at page load) results in { price: null, status: FetchFailed } being cached as valid data for the rest of the session window. The USD amount will be silently absent until the cache expires.

Consider throwing for error conditions you want SWR to retry (e.g., non-429 network errors), and only returning FetchFailed for cases that genuinely should not be retried (e.g., token not found / 404):

async function fetchPrice([, mintAddress]: PriceSwrKey): Promise<PriceResult> {
    const response = await fetch(`/api/receipt/price/${mintAddress}`);

    if (!response.ok) {
        if (response.status === 429) {
            return { price: null, status: PriceStatus.RateLimited };
        }
        // Throw so SWR retries transiently failing requests
        throw new Error(`Price fetch failed: ${response.status}`);
    }
    // ...
}

Comment on lines +50 to +53
return NextResponse.json(
{ error: 'Failed to fetch price data' },
{ headers: NO_STORE_HEADERS, status: response.status }
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Upstream status codes are proxied verbatim to clients

When Jupiter returns an error (e.g., 503 Service Unavailable, 404 Not Found), the response status is forwarded directly to the caller via status: response.status. The client-side fetchPrice only special-cases 429; everything else is treated as a generic FetchFailed. While this is functionally OK today, proxying raw upstream statuses leaks implementation details and can cause unexpected behaviour if Jupiter introduces new codes.

Consider normalising non-429 errors to a consistent 502 (bad gateway) or 503 instead:

return NextResponse.json(
    { error: 'Failed to fetch price data' },
    { headers: NO_STORE_HEADERS, status: response.status === 429 ? 429 : 502 }
);

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

},
(error: unknown) => {
if (!mountedRef.current) return;
console.error('Download failed:', error);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

todo: Please user Logger.error instead

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants