diff --git a/apps/sdp-web/messages/en/dashboard-payments.json b/apps/sdp-web/messages/en/dashboard-payments.json index d2ec72458..552f5a428 100644 --- a/apps/sdp-web/messages/en/dashboard-payments.json +++ b/apps/sdp-web/messages/en/dashboard-payments.json @@ -918,6 +918,9 @@ "searchPlaceholder": "Search ID, signature, address, reference, or counterparty (3+ characters)", "clearSearch": "Clear transaction search", "filters": "Filters", + "downloadCsv": "Download CSV", + "downloadingCsv": "Downloading…", + "downloadCsvFailed": "Transaction CSV could not be downloaded.", "activeFilters": "{count} active", "clearFilters": "Clear filters", "allStatuses": "All statuses", diff --git a/apps/sdp-web/src/app/api/dashboard/payments/transactions/export/route.ts b/apps/sdp-web/src/app/api/dashboard/payments/transactions/export/route.ts new file mode 100644 index 000000000..485eab7fa --- /dev/null +++ b/apps/sdp-web/src/app/api/dashboard/payments/transactions/export/route.ts @@ -0,0 +1,108 @@ +import type { PaymentTransferSummary } from "@sdp/types"; +import { NextResponse } from "next/server"; +import { + parseTransactionFilters, + toTransactionsApiQuery, +} from "@/app/dashboard/payments/transactions/transactions-query"; +import { createSdpApiClient } from "@/lib/sdp-api"; + +const EXPORT_PAGE_SIZE = 100; +const MAX_EXPORT_ROWS = 10_000; + +export async function GET(request: Request) { + const apiClient = await createSdpApiClient(); + const url = new URL(request.url); + const filters = parseTransactionFilters(Object.fromEntries(url.searchParams), new Date()); + const transfers: PaymentTransferSummary[] = []; + let totalPages = Math.ceil(MAX_EXPORT_ROWS / EXPORT_PAGE_SIZE); + + for (let page = 1; page <= totalPages; page += 1) { + const query = toTransactionsApiQuery({ ...filters, page, pageSize: EXPORT_PAGE_SIZE }); + const response = await apiClient.request(`/v1/payments/transfers?${query}`); + const body = (await response.json().catch(() => ({}))) as { + data?: PaymentTransferSummary[]; + meta?: { total?: number; hasMore?: boolean }; + error?: { message?: string }; + }; + + if (!response.ok) { + return NextResponse.json( + { + error: { + message: + body.error?.message ?? `Transaction export request failed (${response.status}).`, + }, + }, + { status: response.status } + ); + } + + if (page === 1) { + const cappedTotal = + typeof body.meta?.total === "number" ? Math.min(body.meta.total, MAX_EXPORT_ROWS) : null; + totalPages = + cappedTotal === null ? totalPages : Math.max(1, Math.ceil(cappedTotal / EXPORT_PAGE_SIZE)); + } + + transfers.push(...(body.data ?? []).slice(0, MAX_EXPORT_ROWS - transfers.length)); + if (!body.meta?.hasMore) { + break; + } + } + + const filename = `sdp-transactions-export-${new Date().toISOString().slice(0, 10)}.csv`; + return new Response(toCsv(transfers), { + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": `attachment; filename="${filename}"`, + }, + }); +} + +const COLUMNS = [ + ["id", (transfer: PaymentTransferSummary) => transfer.id], + ["createdAt", (transfer) => transfer.createdAt], + ["updatedAt", (transfer) => transfer.updatedAt], + ["type", (transfer) => transfer.type], + ["status", (transfer) => transfer.status], + ["direction", (transfer) => transfer.direction], + ["amount", (transfer) => transfer.amount], + ["token", (transfer) => transfer.token], + ["walletId", (transfer) => transfer.walletId], + ["counterpartyId", (transfer) => transfer.counterpartyId], + ["source", (transfer) => transfer.source], + ["destination", (transfer) => transfer.destination], + ["provider", (transfer) => transfer.provider], + ["providerReference", (transfer) => transfer.providerReference], + ["signature", (transfer) => transfer.signature], + ["memo", (transfer) => transfer.memo], +] as const satisfies readonly [ + string, + (transfer: PaymentTransferSummary) => string | null | undefined, +][]; + +const SPREADSHEET_FORMULA_PREFIXES = new Set(["=", "+", "-", "@"]); + +function toCsv(transfers: PaymentTransferSummary[]): string { + const rows = [ + COLUMNS.map(([header]) => header).join(","), + ...transfers.map((transfer) => + COLUMNS.map(([, read]) => escapeCsvValue(read(transfer))).join(",") + ), + ]; + return `${rows.join("\n")}\n`; +} + +function escapeCsvValue(value: string | null | undefined): string { + if (value === null || value === undefined) { + return ""; + } + + const safeValue = isSpreadsheetFormulaValue(value) ? `'${value}` : value; + return /[",\n\r]/.test(safeValue) ? `"${safeValue.replaceAll('"', '""')}"` : safeValue; +} + +function isSpreadsheetFormulaValue(value: string): boolean { + const normalizedValue = value.replace(/^[\p{Cc}\p{Cf}\p{Z}]+/u, ""); + return SPREADSHEET_FORMULA_PREFIXES.has(normalizedValue[0]); +} diff --git a/apps/sdp-web/src/app/api/dashboard/payments/transactions/export/route.unit.test.ts b/apps/sdp-web/src/app/api/dashboard/payments/transactions/export/route.unit.test.ts new file mode 100644 index 000000000..cd0aa9aa6 --- /dev/null +++ b/apps/sdp-web/src/app/api/dashboard/payments/transactions/export/route.unit.test.ts @@ -0,0 +1,217 @@ +import type { PaymentTransferSummary } from "@sdp/types"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + createSdpApiClient: vi.fn(), +})); + +vi.mock("@/lib/sdp-api", () => ({ + createSdpApiClient: mocks.createSdpApiClient, +})); + +import { GET } from "./route"; + +function exportRequest(query = ""): Request { + return new Request( + `https://dashboard.example.com/api/dashboard/payments/transactions/export${query}` + ); +} + +function transfer(overrides: Partial): PaymentTransferSummary { + return { + id: "trf_test", + status: "confirmed", + signature: null, + rampsMemo: {}, + ...overrides, + }; +} + +function jsonResponse(body: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(body), { + headers: { "Content-Type": "application/json" }, + ...init, + }); +} + +describe("GET /api/dashboard/payments/transactions/export", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("requests filtered transfer pages from the SDP API and returns escaped CSV", async () => { + const request = vi.fn().mockResolvedValueOnce( + jsonResponse({ + data: [ + transfer({ + id: "trf_1", + amount: "12.50", + token: "USDC", + memo: 'Invoice, "Q3"\npaid', + createdAt: "2026-07-27T10:00:00.000Z", + }), + ], + meta: { total: 1, hasMore: false }, + }) + ); + mocks.createSdpApiClient.mockResolvedValue({ request }); + + const response = await GET( + exportRequest("?search=invoice&status=confirmed&wallet=wal_1&page=4") + ); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("text/csv; charset=utf-8"); + expect(response.headers.get("Content-Disposition")).toMatch( + /^attachment; filename="sdp-transactions-export-\d{4}-\d{2}-\d{2}\.csv"$/ + ); + + expect(request).toHaveBeenCalledTimes(1); + const apiPath = new URL(`https://api.example.test${request.mock.calls[0]?.[0]}`); + expect(apiPath.pathname).toBe("/v1/payments/transfers"); + expect(apiPath.searchParams.get("search")).toBe("invoice"); + expect(apiPath.searchParams.get("status")).toBe("confirmed"); + expect(apiPath.searchParams.get("wallet")).toBe("wal_1"); + expect(apiPath.searchParams.get("page")).toBe("1"); + expect(apiPath.searchParams.get("pageSize")).toBe("100"); + + const csvHeader = + "id,createdAt,updatedAt,type,status,direction,amount,token,walletId,counterpartyId,source,destination,provider,providerReference,signature,memo"; + const csvRow = [ + "trf_1", + "2026-07-27T10:00:00.000Z", + "", + "", + "confirmed", + "", + "12.50", + "USDC", + "", + "", + "", + "", + "", + "", + "", + '"Invoice, ""Q3""\npaid"', + ].join(","); + await expect(response.text()).resolves.toBe(`${csvHeader}\n${csvRow}\n`); + }); + + it("prefixes spreadsheet formula values in exported CSV fields", async () => { + const request = vi.fn().mockResolvedValueOnce( + jsonResponse({ + data: [ + transfer({ + id: "trf_formula", + amount: "=1+1", + source: "+source", + providerReference: " \t@reference", + memo: "\u0000-memo", + }), + transfer({ + id: "trf_unicode_formula", + amount: "\u00a0=1+1", + source: "\u200b+source", + providerReference: "\u2060@reference", + memo: "\ufeff-memo", + }), + ], + meta: { total: 2, hasMore: false }, + }) + ); + mocks.createSdpApiClient.mockResolvedValue({ request }); + + const response = await GET(exportRequest()); + const csv = await response.text(); + + expect(csv).toContain( + "trf_formula,,,,confirmed,,'=1+1,,,,'+source,,,' \t@reference,,'\u0000-memo\n" + ); + + expect(csv).toContain( + "trf_unicode_formula,,,,confirmed,,'\u00a0=1+1,,,,'\u200b+source,,,'\u2060@reference,,'\ufeff-memo\n" + ); + }); + + it("uses response metadata to fetch the required pages", async () => { + const request = vi + .fn() + .mockResolvedValueOnce( + jsonResponse({ + data: [transfer({ id: "trf_1" })], + meta: { total: 250, hasMore: true }, + }) + ) + .mockResolvedValueOnce( + jsonResponse({ + data: [transfer({ id: "trf_2" })], + meta: { total: 250, hasMore: true }, + }) + ) + .mockResolvedValueOnce( + jsonResponse({ + data: [transfer({ id: "trf_3" })], + meta: { total: 250, hasMore: false }, + }) + ); + mocks.createSdpApiClient.mockResolvedValue({ request }); + + const response = await GET(exportRequest()); + + expect(response.status).toBe(200); + expect(request).toHaveBeenCalledTimes(3); + expect( + new URL(`https://api.example.test${request.mock.calls[0]?.[0]}`).searchParams.get("page") + ).toBe("1"); + expect( + new URL(`https://api.example.test${request.mock.calls[1]?.[0]}`).searchParams.get("page") + ).toBe("2"); + expect( + new URL(`https://api.example.test${request.mock.calls[2]?.[0]}`).searchParams.get("page") + ).toBe("3"); + await expect(response.text()).resolves.toContain("trf_3"); + }); + + it("does not keep paging past the reported total", async () => { + const request = vi + .fn() + .mockResolvedValueOnce( + jsonResponse({ + data: [transfer({ id: "trf_1" })], + meta: { total: 101, hasMore: true }, + }) + ) + .mockResolvedValueOnce( + jsonResponse({ + data: [transfer({ id: "trf_2" })], + meta: { total: 101, hasMore: true }, + }) + ); + mocks.createSdpApiClient.mockResolvedValue({ request }); + + const response = await GET(exportRequest()); + + expect(response.status).toBe(200); + expect(request).toHaveBeenCalledTimes(2); + }); + + it("returns upstream API errors as JSON", async () => { + const request = vi + .fn() + .mockResolvedValueOnce( + jsonResponse( + { error: { message: "Project scope is required" } }, + { status: 400, statusText: "Bad Request" } + ) + ); + mocks.createSdpApiClient.mockResolvedValue({ request }); + + const response = await GET(exportRequest()); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: { message: "Project scope is required" }, + }); + }); +}); diff --git a/apps/sdp-web/src/app/dashboard/payments/counterparty/counterparty-detail-workspace.tsx b/apps/sdp-web/src/app/dashboard/payments/counterparty/counterparty-detail-workspace.tsx index 8373d3eef..0b118c080 100644 --- a/apps/sdp-web/src/app/dashboard/payments/counterparty/counterparty-detail-workspace.tsx +++ b/apps/sdp-web/src/app/dashboard/payments/counterparty/counterparty-detail-workspace.tsx @@ -16,8 +16,10 @@ import { CheckIcon, ChevronDownIcon, CopyIcon, + DownloadIcon, ExternalLinkIcon, HashIcon, + LoaderCircleIcon, MailIcon, MapPinIcon, PhoneIcon, @@ -45,6 +47,7 @@ import { import { Modal } from "@/components/ui/modal"; import { useLocale, useTranslations } from "@/i18n/provider"; import { dashboardFetch } from "@/lib/dashboard-fetch"; +import { downloadResponseBlob } from "@/lib/download"; import { explorerTxUrl } from "@/lib/explorer"; import { getRampProviderLabel, RAMP_PROVIDER_LOGOS } from "@/lib/ramps"; import { useCopy } from "@/lib/use-copy"; @@ -223,9 +226,11 @@ function FilterChip({ } function CounterpartyTransactions({ + counterpartyId, transfers, counterpartyName, }: { + counterpartyId: string; transfers: PaymentTransferSummary[]; counterpartyName: string; }) { @@ -265,6 +270,8 @@ function CounterpartyTransactions({ const [typeFilter, setTypeFilter] = useState(null); const [providerFilter, setProviderFilter] = useState(null); const [selectedTransfer, setSelectedTransfer] = useState(null); + const [csvDownloading, setCsvDownloading] = useState(false); + const [csvError, setCsvError] = useState(null); const availableTypes = useMemo(() => { const types = new Set(); @@ -301,6 +308,40 @@ function CounterpartyTransactions({ ); const showFilters = availableTypes.length > 1 || availableProviders.length > 1; + const exportHref = useMemo(() => { + const query = new URLSearchParams({ counterparty: counterpartyId }); + if (typeFilter) query.set("type", typeFilter); + if (providerFilter) query.set("provider", providerFilter); + return `/api/dashboard/payments/transactions/export?${query.toString()}`; + }, [counterpartyId, providerFilter, typeFilter]); + + const downloadCsv = async () => { + if (csvDownloading) return; + setCsvDownloading(true); + setCsvError(null); + + try { + const response = await fetch(exportHref); + if (!response.ok) { + const body = (await response.json().catch(() => ({}))) as { + error?: { message?: string }; + }; + throw new Error( + body.error?.message ?? t("DashboardPayments.transactions.downloadCsvFailed") + ); + } + + await downloadResponseBlob(response, "sdp-transactions.csv"); + } catch (error) { + setCsvError( + error instanceof Error + ? error.message + : t("DashboardPayments.transactions.downloadCsvFailed") + ); + } finally { + setCsvDownloading(false); + } + }; return (
@@ -313,46 +354,65 @@ function CounterpartyTransactions({ ) : ( <> - {showFilters ? ( -
- {availableTypes.length > 1 ? ( - <> - setTypeFilter(null)}> - {t("DashboardPayments.counterparty.allTypes")} - - {availableTypes.map((type) => ( - setTypeFilter(type)} - > - {resolveTransferTypeLabel(type, t)} +
+ {showFilters ? ( +
+ {availableTypes.length > 1 ? ( + <> + setTypeFilter(null)}> + {t("DashboardPayments.counterparty.allTypes")} - ))} - - ) : null} - {availableProviders.length > 1 ? ( - <> - - setProviderFilter(null)} - > - {t("DashboardPayments.counterparty.allProviders")} - - {availableProviders.map((provider) => ( + {availableTypes.map((type) => ( + setTypeFilter(type)} + > + {resolveTransferTypeLabel(type, t)} + + ))} + + ) : null} + {availableProviders.length > 1 ? ( + <> + setProviderFilter(provider)} + active={providerFilter === null} + onClick={() => setProviderFilter(null)} > - {getRampProviderLabel(provider)} + {t("DashboardPayments.counterparty.allProviders")} - ))} - - ) : null} -
- ) : null} + {availableProviders.map((provider) => ( + setProviderFilter(provider)} + > + {getRampProviderLabel(provider)} + + ))} + + ) : null} +
+ ) : ( + + )} + +
+ {csvError ?

{csvError}

: null}
@@ -927,6 +987,7 @@ export function CounterpartyDetailWorkspace({ {activeTab === "transactions" ? ( diff --git a/apps/sdp-web/src/app/dashboard/payments/transactions/transactions-workspace.tsx b/apps/sdp-web/src/app/dashboard/payments/transactions/transactions-workspace.tsx index aeea84bb2..aff551061 100644 --- a/apps/sdp-web/src/app/dashboard/payments/transactions/transactions-workspace.tsx +++ b/apps/sdp-web/src/app/dashboard/payments/transactions/transactions-workspace.tsx @@ -1,6 +1,6 @@ "use client"; -import { FilterIcon, SearchIcon, XIcon } from "lucide-react"; +import { DownloadIcon, FilterIcon, LoaderCircleIcon, SearchIcon, XIcon } from "lucide-react"; import { useRouter } from "next/navigation"; import { createContext, @@ -24,6 +24,7 @@ import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { useDashboardWorkspace } from "@/contexts/dashboard-workspace-context"; import type { MessageKey } from "@/i18n/messages"; import { useTranslations } from "@/i18n/provider"; +import { downloadResponseBlob } from "@/lib/download"; import { RAMP_PROVIDER_OPTIONS } from "@/lib/ramps"; import { useDebounce } from "@/lib/use-debounce"; import { cn } from "@/lib/utils"; @@ -141,6 +142,11 @@ function buildTransactionsHref(filters: TransactionFilters): string { return `/dashboard/payments/transactions${query ? `?${query}` : ""}`; } +function buildTransactionsExportHref(filters: TransactionFilters): string { + const query = serializeTransactionFilters({ ...filters, page: 1 }).toString(); + return `/api/dashboard/payments/transactions/export${query ? `?${query}` : ""}`; +} + function SelectFilter({ value, label, @@ -337,6 +343,8 @@ export function TransactionsWorkspace({ const [displayFilters, setDisplayFilters] = useState(filters); const [searchValue, setSearchValue] = useState(filters.search ?? ""); const [assetValue, setAssetValue] = useState(filters.asset ?? ""); + const [csvDownloading, setCsvDownloading] = useState(false); + const [csvError, setCsvError] = useState(null); const [isPending, startTransition] = useTransition(); const advancedFilterCount = countActiveTransactionFilters(displayFilters); const hasAdvancedFilter = Boolean( @@ -483,6 +491,34 @@ export function TransactionsWorkspace({ }; const sortValue = `${displayFilters.sortBy}:${displayFilters.sortDirection}`; + const downloadCsv = async () => { + if (csvDownloading) return; + setCsvDownloading(true); + setCsvError(null); + + try { + const response = await fetch(buildTransactionsExportHref(displayFilters)); + if (!response.ok) { + const body = (await response.json().catch(() => ({}))) as { + error?: { message?: string }; + }; + throw new Error( + body.error?.message ?? t("DashboardPayments.transactions.downloadCsvFailed") + ); + } + + await downloadResponseBlob(response, "sdp-transactions.csv"); + } catch (error) { + setCsvError( + error instanceof Error + ? error.message + : t("DashboardPayments.transactions.downloadCsvFailed") + ); + } finally { + setCsvDownloading(false); + } + }; + return (
-
+
updateSearchValue(event.target.value)} @@ -567,7 +603,21 @@ export function TransactionsWorkspace({ ) : null} +
+ {csvError ?

{csvError}

: null} {advancedFilterCount > 0 ? (
diff --git a/apps/sdp-web/src/lib/download.ts b/apps/sdp-web/src/lib/download.ts new file mode 100644 index 000000000..6e8c07b9e --- /dev/null +++ b/apps/sdp-web/src/lib/download.ts @@ -0,0 +1,25 @@ +export function filenameFromContentDisposition( + disposition: string | null, + fallback: string +): string { + return /filename="([^"]+)"/.exec(disposition ?? "")?.[1] ?? fallback; +} + +export async function downloadResponseBlob(response: Response, fallbackFilename: string) { + const blob = await response.blob(); + const href = URL.createObjectURL(blob); + const link = document.createElement("a"); + + try { + link.href = href; + link.download = filenameFromContentDisposition( + response.headers.get("Content-Disposition"), + fallbackFilename + ); + document.body.append(link); + link.click(); + } finally { + link.remove(); + URL.revokeObjectURL(href); + } +}