Skip to content

Commit 54648d6

Browse files
committed
feat: implement billing history CSV export functionality
- Added `useBillingCsvExport` hook to manage the export of billing history to CSV format. - Introduced utility functions for CSV generation and file download in `csv-export.ts`. - Updated `BillingBlock` component to include a button for exporting billing history, displaying export status and count. - Enhanced the overall billing model by integrating new export features, improving user experience for billing data management.
1 parent 9fbd898 commit 54648d6

File tree

5 files changed

+218
-4
lines changed

5 files changed

+218
-4
lines changed
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { useCallback, useEffect, useRef, useState } from 'react';
2+
import { useToast } from '@chakra-ui/react';
3+
import { getProjectBillingHistory } from 'src/shared/api';
4+
import { mapDTOTransactionToBillingHistoryItem } from 'src/shared/hooks/useBillingHistoryQuery';
5+
import { useProjectId, useMaybeProject } from 'src/shared/contexts/ProjectContext';
6+
import {
7+
buildBillingHistoryCsv,
8+
buildBillingHistoryFilename,
9+
delay,
10+
downloadCsvFile,
11+
EXPORT_PAGE_SIZE,
12+
MIN_REQUEST_INTERVAL_MS
13+
} from './csv-export';
14+
15+
export interface UseBillingExportReturn {
16+
isExporting: boolean;
17+
exportedCount: number;
18+
exportFullHistory: () => Promise<void>;
19+
}
20+
21+
export function useBillingCsvExport(): UseBillingExportReturn {
22+
const [isExporting, setIsExporting] = useState(false);
23+
const [exportedCount, setExportedCount] = useState(0);
24+
const projectId = useProjectId();
25+
const project = useMaybeProject();
26+
const toast = useToast();
27+
const isMountedRef = useRef(true);
28+
29+
// Cleanup on unmount
30+
useEffect(() => {
31+
return () => {
32+
isMountedRef.current = false;
33+
};
34+
}, []);
35+
36+
const exportFullHistory = useCallback(async () => {
37+
if (!projectId || isExporting) {
38+
return;
39+
}
40+
41+
setIsExporting(true);
42+
setExportedCount(0);
43+
44+
const allItems = [];
45+
let beforeTx: string | undefined;
46+
let lastRequestTime = 0;
47+
48+
try {
49+
while (true) {
50+
const now = Date.now();
51+
if (lastRequestTime) {
52+
const elapsed = now - lastRequestTime;
53+
if (elapsed < MIN_REQUEST_INTERVAL_MS) {
54+
await delay(MIN_REQUEST_INTERVAL_MS - elapsed);
55+
}
56+
}
57+
lastRequestTime = Date.now();
58+
59+
const { data, error } = await getProjectBillingHistory({
60+
path: { id: projectId },
61+
query: { before_tx: beforeTx, limit: EXPORT_PAGE_SIZE }
62+
});
63+
64+
if (error) {
65+
throw error;
66+
}
67+
68+
const currentPage = data.history;
69+
70+
if (!currentPage.length) {
71+
break;
72+
}
73+
74+
const mappedPage = currentPage.map(mapDTOTransactionToBillingHistoryItem);
75+
allItems.push(...mappedPage);
76+
77+
setExportedCount(prev => prev + currentPage.length);
78+
79+
if (currentPage.length < EXPORT_PAGE_SIZE) {
80+
break;
81+
}
82+
83+
beforeTx = currentPage[currentPage.length - 1].id;
84+
}
85+
86+
const csvContent = buildBillingHistoryCsv(allItems);
87+
const projectName = project?.name || 'unknown';
88+
const filename = buildBillingHistoryFilename(projectName, projectId);
89+
downloadCsvFile(csvContent, filename);
90+
91+
if (isMountedRef.current) {
92+
toast({
93+
title: 'Billing history exported',
94+
status: 'success',
95+
duration: 4000,
96+
isClosable: true
97+
});
98+
}
99+
} catch (error) {
100+
const message = error instanceof Error ? error.message : 'Unknown error';
101+
if (isMountedRef.current) {
102+
toast({
103+
title: 'Failed to export billing history',
104+
description: message,
105+
status: 'error',
106+
duration: 6000,
107+
isClosable: true
108+
});
109+
}
110+
} finally {
111+
setIsExporting(false);
112+
}
113+
}, [projectId, isExporting, toast]);
114+
115+
return {
116+
isExporting,
117+
exportedCount,
118+
exportFullHistory
119+
};
120+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { BillingHistoryItem } from 'src/shared/hooks/useBillingHistoryQuery';
2+
3+
export const EXPORT_PAGE_SIZE = 100;
4+
export const MIN_REQUEST_INTERVAL_MS = Math.ceil(1000 / 3);
5+
6+
const CSV_HEADERS = [
7+
'Transaction ID',
8+
'Created At (ISO)',
9+
'Type',
10+
'Reason',
11+
'Description',
12+
'Signed Amount',
13+
'Currency',
14+
'Metadata'
15+
] as const;
16+
17+
export const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
18+
19+
export const escapeCsvValue = (value: string): string => {
20+
const stringValue = String(value ?? '');
21+
const escapedValue = stringValue.replace(/"/g, '""');
22+
return `"${escapedValue}"`;
23+
};
24+
25+
export const serializeCsvRow = (row: string[]): string => row.map(escapeCsvValue).join(',');
26+
27+
export const formatBillingHistoryCsvRow = (item: BillingHistoryItem): string[] => {
28+
const amount = item.amount.toStringAmount({ decimalPlaces: null, thousandSeparators: false });
29+
const sign = item.type === 'charge' ? '-' : '+';
30+
const reason = item.info.reason ?? '';
31+
32+
// Extract metadata without 'reason' field
33+
const { reason: _, ...metadata } = item.info as Record<string, unknown>;
34+
const metadataJson = Object.keys(metadata).length > 0 ? JSON.stringify(metadata) : '';
35+
36+
return [
37+
item.id,
38+
item.date.toISOString(),
39+
item.type,
40+
reason,
41+
item.description,
42+
`${sign}${amount}`,
43+
item.amount.stringCurrency,
44+
metadataJson
45+
];
46+
};
47+
48+
export const buildBillingHistoryCsv = (items: BillingHistoryItem[]): string => {
49+
const header = serializeCsvRow([...CSV_HEADERS]);
50+
const rows = items.map(item => serializeCsvRow(formatBillingHistoryCsvRow(item)));
51+
return [header, ...rows].join('\n');
52+
};
53+
54+
export const buildBillingHistoryFilename = (projectName: string, projectId: number): string => {
55+
const timestamp = new Date().toISOString().replace(/:/g, '-');
56+
const sanitizedProjectName = projectName.replace(/[^a-zA-Z0-9-_]/g, '-').toLowerCase();
57+
return `ton-console-billing-history-${sanitizedProjectName}-${projectId}-${timestamp}.csv`;
58+
};
59+
60+
export const downloadCsvFile = (content: string, filename: string): void => {
61+
const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' });
62+
const url = URL.createObjectURL(blob);
63+
const link = document.createElement('a');
64+
link.href = url;
65+
link.download = filename;
66+
document.body.appendChild(link);
67+
link.click();
68+
document.body.removeChild(link);
69+
URL.revokeObjectURL(url);
70+
};
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
export * from './interfaces';
22
export * from './queries';
3+
export * from './csv-export';
4+
export { useBillingCsvExport } from './billing-export';

src/pages/balance/BillingBlock.tsx

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
import { FC, useEffect, useState } from 'react';
2-
import { Box, Button, HStack, Select } from '@chakra-ui/react';
2+
import {
3+
Box,
4+
Button,
5+
HStack,
6+
Select
7+
} from '@chakra-ui/react';
38
import { H4, useBillingHistoryQuery } from 'src/shared';
49
import { useProjectId } from 'src/shared/contexts/ProjectContext';
5-
import { BillingHistoryTable } from 'src/features/billing';
10+
import { BillingHistoryTable, useBillingCsvExport } from 'src/features/billing';
611

712
const DEFAULT_PAGE_SIZE = 10;
813
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100] as const;
@@ -14,6 +19,7 @@ const BillingBlock: FC = () => {
1419
const [cursorStack, setCursorStack] = useState<string[]>([]);
1520
const [currentCursor, setCurrentCursor] = useState<string | undefined>(undefined);
1621
const projectId = useProjectId();
22+
const { isExporting, exportedCount, exportFullHistory } = useBillingCsvExport();
1723

1824
const { data: billingHistory = [], isLoading } = useBillingHistoryQuery({
1925
before_tx: currentCursor,
@@ -67,9 +73,23 @@ const BillingBlock: FC = () => {
6773
setPageNumber(1);
6874
};
6975

76+
const handleExportFullHistory = async () => {
77+
await exportFullHistory();
78+
};
79+
7080
return (
7181
<Box px="6" py="5">
72-
<H4 mb="5">Billing History</H4>
82+
<HStack align="center" justify="space-between" mb="5">
83+
<H4 m="0">Billing History</H4>
84+
<Button
85+
isDisabled={!projectId || isExporting}
86+
onClick={handleExportFullHistory}
87+
size="sm"
88+
variant="secondary"
89+
>
90+
{isExporting ? `Exporting (${exportedCount})...` : 'Download CSV'}
91+
</Button>
92+
</HStack>
7393
<BillingHistoryTable
7494
billingHistory={billingHistory}
7595
isLoading={isLoading}

src/shared/hooks/useBillingHistoryQuery.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@ export function useBillingHistoryQuery(options: UseBillingHistoryQueryOptions =
4747
});
4848
}
4949

50-
function mapDTOTransactionToBillingHistoryItem(dtoTx: DTOBillingTransaction): BillingHistoryItem {
50+
export function mapDTOTransactionToBillingHistoryItem(
51+
dtoTx: DTOBillingTransaction
52+
): BillingHistoryItem {
5153
const date = new Date(dtoTx.created_at * 1000);
5254
const amount = mapDTOCurrencyToAmount(dtoTx.currency, dtoTx.amount);
5355

0 commit comments

Comments
 (0)