transactions-utils.tsx raw
1 import { toast } from "sonner";
2 import { ListTransactionsResponse, Transaction } from "src/types";
3 import { request } from "src/utils/request";
4
5 const LABEL_COLUMN_PREFIX = "label_";
6
7 // Use a large page size to keep the number of round-trips low when
8 // exporting wallets that have thousands of transactions.
9 const EXPORT_TRANSACTIONS_PAGE_SIZE = 1000;
10
11 // based on https://stackoverflow.com/a/68146412
12 const escapeCsvCell = (raw: string) => {
13 const safe = /^[\t\r ]*[=+\-@]/.test(raw) ? `'${raw}` : raw;
14 return `"${safe.replaceAll('"', '""')}"`;
15 };
16
17 export const convertToCSV = (transactions: Transaction[]) => {
18 if (!transactions.length) {
19 return "";
20 }
21
22 // Get headers from all transactions including the user_labels in metadata
23 const headers = Object.keys(transactions[0]);
24 const userLabelKeys = Array.from(
25 new Set(
26 transactions.flatMap((tx) =>
27 tx.metadata?.user_labels ? Object.keys(tx.metadata.user_labels) : []
28 )
29 )
30 ).sort();
31 const userLabelHeaders = userLabelKeys.map(
32 (key) => `${LABEL_COLUMN_PREFIX}${key}`
33 );
34
35 const csvHeaders = [...headers, ...userLabelHeaders].join(",");
36
37 // Convert each transaction to CSV row
38 const csvRows = transactions.map((tx) => {
39 return [
40 ...headers.map((header) => {
41 const value = tx[header as keyof typeof tx];
42 if (value === undefined || value === null) {
43 return "";
44 }
45 const stringValue =
46 typeof value === "object" ? JSON.stringify(value) : String(value);
47 return escapeCsvCell(stringValue);
48 }),
49 ...userLabelKeys.map((key) => {
50 const value = tx.metadata?.user_labels?.[key];
51 if (value === undefined || value === null) {
52 return "";
53 }
54 return escapeCsvCell(value);
55 }),
56 ].join(",");
57 });
58
59 return [csvHeaders, ...csvRows].join("\n");
60 };
61
62 export const handleExportTransactions = async (appId?: number) => {
63 const toastId = toast.loading("Exporting transactions…");
64 try {
65 // Fetch all transactions by paginating through all pages
66 let allTransactions: Transaction[] = [];
67 let offset = 0;
68
69 while (true) {
70 let url = `/api/transactions?limit=${EXPORT_TRANSACTIONS_PAGE_SIZE}&offset=${offset}`;
71 if (appId) {
72 url += `&appId=${appId}`;
73 }
74
75 const data = await request<ListTransactionsResponse>(url);
76
77 if (!data) {
78 throw new Error("no list transactions response");
79 }
80
81 allTransactions = [...allTransactions, ...data.transactions];
82 toast.loading(
83 `Exporting ${allTransactions.length.toLocaleString()} transactions…`,
84 { id: toastId }
85 );
86
87 if (data.transactions.length < EXPORT_TRANSACTIONS_PAGE_SIZE) {
88 break;
89 }
90 offset += EXPORT_TRANSACTIONS_PAGE_SIZE;
91 }
92
93 // Convert to CSV and create download
94 const csvString = convertToCSV(allTransactions);
95 const blob = new Blob([csvString], { type: "text/csv" });
96 const url = window.URL.createObjectURL(blob);
97 const link = document.createElement("a");
98 link.href = url;
99 const filename = appId
100 ? `transactions_app_${appId}.csv`
101 : `transactions_all.csv`;
102 link.download = filename;
103 document.body.appendChild(link);
104 link.click();
105 document.body.removeChild(link);
106 window.URL.revokeObjectURL(url);
107 toast.success("Transactions saved to your downloads folder", {
108 id: toastId,
109 });
110 } catch (error) {
111 console.error("Error downloading transactions:", error);
112 toast.error("Failed to export transactions", { id: toastId });
113 }
114 };
115