diff --git a/backend/src/invoices/invoices.controller.ts b/backend/src/invoices/invoices.controller.ts index 1732ce4f..5f3e19f4 100644 --- a/backend/src/invoices/invoices.controller.ts +++ b/backend/src/invoices/invoices.controller.ts @@ -21,6 +21,7 @@ import { ImportSummaryDto } from "./dto/import-result.dto"; import { Invoice } from "./entities/invoice.entity"; import { InvoiceStatus } from "@prisma/client"; import { Auth, CurrentUser } from "../auth/guard/auth.guard"; +import { Response } from "express"; import { User } from "../users/user.entity"; import { PrismaService } from "../prisma/prisma.service"; import { @@ -73,6 +74,43 @@ export class InvoicesController { return result.items; } + /** + * Export the current filtered invoice working set as CSV. + * The filters intentionally mirror the web invoice list. + */ + @Auth() + @Get("export") + async exportCsv( + @CurrentUser() user: User, + @Res({ passthrough: true }) response: Response, + @Query("status") status?: string, + @Query("asset") asset?: string, + @Query("dueDate") dueDate?: string, + @Query("q") q?: string, + ): Promise { + const result = await this.prisma.runWithMerchantScope( + user.merchantId, + () => + this.invoicesService.exportCsv(user.merchantId, { + status, + asset, + dueDate, + q, + }), + ); + + const date = new Date().toISOString().slice(0, 10); + response.setHeader("Content-Type", "text/csv; charset=utf-8"); + response.setHeader( + "Content-Disposition", + `attachment; filename="invoices-${date}.csv"`, + ); + response.setHeader("Content-Length", result.buffer.length); + response.setHeader("X-Exported-Rows", String(result.count)); + + return result.buffer; + } + /** * Search invoices by client name, email, or memo for the authenticated merchant * @returns Array of matching invoices ordered by relevance diff --git a/backend/src/invoices/invoices.service.spec.ts b/backend/src/invoices/invoices.service.spec.ts index 90887dc5..2e8073ec 100644 --- a/backend/src/invoices/invoices.service.spec.ts +++ b/backend/src/invoices/invoices.service.spec.ts @@ -318,6 +318,66 @@ describe("InvoicesService", () => { }); }); + describe("exportCsv", () => { + it("exports the requested invoice fields as CSV for the merchant", async () => { + const prisma = (service as any).prisma; + prisma.invoice.count.mockResolvedValue(1); + prisma.invoice.findMany.mockResolvedValue([ + { + id: "invoice-a-1", + merchantId: MERCHANT_A, + invoiceNumber: "INV-A-001", + clientName: "Acme Corp", + amount: 100, + assetCode: "XLM", + status: "pending", + dueDate: new Date("2026-08-30T00:00:00.000Z"), + }, + ]); + + const result = await service.exportCsv(MERCHANT_A, { + status: "pending", + asset: "XLM", + q: "Acme", + }); + + const csv = result.buffer.toString("utf8"); + expect(csv).toContain( + "Invoice Number,Customer,Amount,Asset,Status,Due Date", + ); + expect(csv).toContain( + '"INV-A-001","Acme Corp","100","XLM","pending","2026-08-30"', + ); + expect(result.count).toBe(1); + expect(prisma.invoice.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + merchantId: MERCHANT_A, + status: "pending", + assetCode: { equals: "XLM", mode: "insensitive" }, + }), + orderBy: { createdAt: "desc" }, + }), + ); + }); + + it("rejects unsupported due-date filters", async () => { + await expect( + service.exportCsv(MERCHANT_A, { dueDate: "tomorrow" }), + ).rejects.toThrow(BadRequestException); + }); + + it("rejects exports larger than the safety limit", async () => { + const prisma = (service as any).prisma; + prisma.invoice.count.mockResolvedValue(10001); + + await expect(service.exportCsv(MERCHANT_A, {})).rejects.toThrow( + /exports are limited to 10000 rows/, + ); + expect(prisma.invoice.findMany).not.toHaveBeenCalled(); + }); + }); + describe("searchInvoices", () => { it("should return invoices scoped to the merchant", async () => { const results = await service.searchInvoices(MERCHANT_A, "Acme", 25); diff --git a/backend/src/invoices/invoices.service.ts b/backend/src/invoices/invoices.service.ts index 2c17f55e..282a6e33 100644 --- a/backend/src/invoices/invoices.service.ts +++ b/backend/src/invoices/invoices.service.ts @@ -145,6 +145,155 @@ export class InvoicesService implements OnModuleInit { }; } + /** + * Export the merchant's filtered invoice working set as CSV. + * + * The export intentionally applies the same filters as the web invoice + * list on the server so pagination does not truncate the downloaded set. + * A hard row limit prevents an unbounded in-memory response. + */ + async exportCsv( + merchantId: string, + filters: { + status?: string; + asset?: string; + dueDate?: string; + q?: string; + }, + ): Promise<{ buffer: Buffer; count: number }> { + const where: Record = { merchantId }; + const search = filters.q?.trim(); + + if (search) { + where["AND"] = [ + { + OR: [ + { invoiceNumber: { contains: search, mode: "insensitive" } }, + { clientName: { contains: search, mode: "insensitive" } }, + { clientEmail: { contains: search, mode: "insensitive" } }, + { id: { contains: search, mode: "insensitive" } }, + ], + }, + ]; + } + + const status = filters.status?.trim(); + if (status && status !== "all") { + where["status"] = status; + } + + const asset = filters.asset?.trim(); + if (asset && asset !== "all") { + where["assetCode"] = { equals: asset, mode: "insensitive" }; + } + + const dueDate = filters.dueDate?.trim(); + if (dueDate && dueDate !== "all") { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + switch (dueDate) { + case "no_due_date": + where["dueDate"] = null; + break; + case "has_due_date": + where["dueDate"] = { not: null }; + break; + case "today": { + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + where["dueDate"] = { gte: today, lt: tomorrow }; + break; + } + case "this_week": { + const endOfWeek = new Date(today); + endOfWeek.setDate(endOfWeek.getDate() + 7); + where["dueDate"] = { gte: today, lte: endOfWeek }; + break; + } + case "this_month": { + const endOfMonth = new Date(today); + endOfMonth.setDate(endOfMonth.getDate() + 30); + where["dueDate"] = { gte: today, lte: endOfMonth }; + break; + } + case "overdue": { + const andFilters = (where["AND"] as unknown[]) ?? []; + andFilters.push({ + OR: [ + { status: "overdue" }, + { + dueDate: { lt: today }, + status: { notIn: ["paid", "cancelled"] }, + }, + ], + }); + where["AND"] = andFilters; + break; + } + default: + throw new BadRequestException( + `Unsupported dueDate filter: ${dueDate}`, + ); + } + } + + const MAX_EXPORT_ROWS = 10000; + const count = await this.prisma.invoice.count({ where }); + + if (count > MAX_EXPORT_ROWS) { + throw new BadRequestException( + `This export contains ${count} invoices, but exports are limited to ${MAX_EXPORT_ROWS} rows. Narrow the filters and try again.`, + ); + } + + const invoices = await this.prisma.invoice.findMany({ + where, + orderBy: { createdAt: "desc" }, + take: MAX_EXPORT_ROWS, + }); + + const headers = [ + "Invoice Number", + "Customer", + "Amount", + "Asset", + "Status", + "Due Date", + ]; + + const escapeCsv = (value: unknown): string => { + const text = value == null ? "" : String(value); + // Prevent spreadsheet formula injection while retaining readable values. + const safeText = + typeof value === "string" && /^[=+\-@]/.test(text) + ? `'${text}` + : text; + return `"${safeText.replace(/"/g, '""')}"`; + }; + + const rows = invoices.map((invoice) => + [ + invoice.invoiceNumber, + invoice.clientName, + invoice.amount, + invoice.assetCode, + invoice.status, + invoice.dueDate?.toISOString().slice(0, 10), + ] + .map(escapeCsv) + .join(","), + ); + + const csv = + [headers.join(","), ...rows].join("\r\n") + "\r\n"; + + return { + buffer: Buffer.from(`\uFEFF${csv}`, "utf8"), + count: invoices.length, + }; + } + /** * Search invoices by merchant-scoped term using full-text and trigram similarity * @param userId - Authenticated merchant id diff --git a/web/app/invoices/page.tsx b/web/app/invoices/page.tsx index dd24c2b5..865d67aa 100644 --- a/web/app/invoices/page.tsx +++ b/web/app/invoices/page.tsx @@ -179,6 +179,8 @@ function InvoicesContent() { const [customQueries, setCustomQueries] = useState([]); const [isSaving, setIsSaving] = useState(false); + const [isExporting, setIsExporting] = useState(false); + const [exportError, setExportError] = useState(null); const [saveName, setSaveName] = useState(""); const pageSize = 20; @@ -478,6 +480,61 @@ function InvoicesContent() { localStorage.setItem("invoisio_saved_queries", JSON.stringify(updated)); }; + const handleExport = async () => { + setIsExporting(true); + setExportError(null); + + try { + const params = new URLSearchParams(); + if (statusFilter !== "all") params.set("status", statusFilter); + if (assetFilter !== "all") params.set("asset", assetFilter); + if (dueDateFilter !== "all") params.set("dueDate", dueDateFilter); + if (searchQuery.trim()) params.set("q", searchQuery.trim()); + + const response = await apiClient.get( + `/invoices/export${params.toString() ? `?${params.toString()}` : ""}`, + { responseType: "blob" }, + ); + + const blob = new Blob([response.data], { type: "text/csv;charset=utf-8" }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `invoices-${new Date().toISOString().slice(0, 10)}.csv`; + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + } catch (error) { + let message = extractApiErrorMessage(error); + + if ( + error && + typeof error === "object" && + "response" in error && + (error as { response?: { data?: unknown } }).response?.data instanceof + Blob + ) { + try { + const blob = (error as { response: { data: Blob } }).response.data; + const text = await blob.text(); + const parsed = JSON.parse(text) as { message?: string | string[] }; + if (Array.isArray(parsed.message)) { + message = parsed.message.join(", "); + } else if (typeof parsed.message === "string") { + message = parsed.message; + } + } catch { + // Keep the generic API error when the response is not JSON. + } + } + + setExportError(message); + } finally { + setIsExporting(false); + } + }; + const handleDuplicateInvoice = async ( invoiceId: string, e: React.MouseEvent, diff --git a/web/app/page.tsx b/web/app/page.tsx index abd82087..dfa2a1b6 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -1,3 +1,4 @@ +import { extractApiErrorMessage } from "@/lib/api-client"; import type { ReactNode } from 'react'; import Link from 'next/link'; import { diff --git a/web/lib/api-errors.ts b/web/lib/api-errors.ts new file mode 100644 index 00000000..58dd29b3 --- /dev/null +++ b/web/lib/api-errors.ts @@ -0,0 +1,21 @@ +export function extractApiErrorMessage(error: unknown): string { + if (!error) return "An error occurred"; + + if (error instanceof Error) { + return error.message; + } + + if (typeof error === "object" && "response" in error) { + const apiError = error as { response?: { data?: { message?: string | string[] } } }; + const message = apiError.response?.data?.message; + + if (Array.isArray(message)) { + return message.join(", "); + } + if (typeof message === "string") { + return message; + } + } + + return "An error occurred. Please try again."; +} \ No newline at end of file diff --git a/web/package-lock.json b/web/package-lock.json index 27620583..99f8398b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1489,6 +1489,72 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",