From 0983217183d7e7c8fa370bc3cdc3b485e167a715 Mon Sep 17 00:00:00 2001 From: eyanus Date: Sun, 23 Aug 2026 06:34:45 +0100 Subject: [PATCH 1/2] feat: add filtered invoice CSV export --- backend/src/invoices/invoices.controller.ts | 39 +++++ backend/src/invoices/invoices.service.spec.ts | 60 +++++++ backend/src/invoices/invoices.service.ts | 149 ++++++++++++++++++ web/app/invoices/page.tsx | 80 +++++++++- web/package-lock.json | 66 ++++++++ 5 files changed, 393 insertions(+), 1 deletion(-) diff --git a/backend/src/invoices/invoices.controller.ts b/backend/src/invoices/invoices.controller.ts index 9062013b..864f75b1 100644 --- a/backend/src/invoices/invoices.controller.ts +++ b/backend/src/invoices/invoices.controller.ts @@ -9,6 +9,7 @@ import { UseInterceptors, UploadedFile, BadRequestException, + Res, } from "@nestjs/common"; import { FileInterceptor } from "@nestjs/platform-express"; import { memoryStorage } from "multer"; @@ -20,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 { @@ -65,6 +67,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 bbca0276..316dc59c 100644 --- a/backend/src/invoices/invoices.service.spec.ts +++ b/backend/src/invoices/invoices.service.spec.ts @@ -268,6 +268,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 user", async () => { const results = await service.searchInvoices(USER_A, "Acme", 25); diff --git a/backend/src/invoices/invoices.service.ts b/backend/src/invoices/invoices.service.ts index 71e5bb66..10386fea 100644 --- a/backend/src/invoices/invoices.service.ts +++ b/backend/src/invoices/invoices.service.ts @@ -139,6 +139,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 d426d89a..9c5226ec 100644 --- a/web/app/invoices/page.tsx +++ b/web/app/invoices/page.tsx @@ -5,7 +5,7 @@ import Link from "next/link"; import { useRouter, useSearchParams, usePathname } from "next/navigation"; import { useInfiniteQuery } from "@tanstack/react-query"; import { Copy } from "lucide-react"; -import { apiClient } from "@/lib/api-client"; +import { apiClient, extractApiErrorMessage } from "@/lib/api-client"; import { WalletAuthControls } from "@/components/wallet-auth-controls"; import { RequireAuth } from "@/components/require-auth"; @@ -138,6 +138,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; @@ -340,6 +342,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, @@ -401,6 +458,15 @@ function InvoicesContent() { > Refresh + + {exportError && ( +
+

+ CSV export failed: {exportError} +

+
+ )} + {/* Error State */} {error && (
=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", From 6b129f259a8af1e80b92ff769804432a6b3ccc9d Mon Sep 17 00:00:00 2001 From: eyanus Date: Thu, 27 Aug 2026 11:41:15 +0100 Subject: [PATCH 2/2] feat/invoiso-csv-export --- web/app/page.tsx | 299 ++++++++++++++++++++++++++++-------------- web/lib/api-errors.ts | 21 +++ 2 files changed, 224 insertions(+), 96 deletions(-) create mode 100644 web/lib/api-errors.ts diff --git a/web/app/page.tsx b/web/app/page.tsx index f842bba2..dfa2a1b6 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -1,113 +1,220 @@ -'use client'; - +import { extractApiErrorMessage } from "@/lib/api-client"; +import type { ReactNode } from 'react'; import Link from 'next/link'; -import { RequireAuth } from '@/components/require-auth'; -import { ActivationChecklist } from '@/components/activation-checklist'; -import { WalletAuthControls } from '@/components/wallet-auth-controls'; -import { useMerchantChecklist } from '@/hooks/use-merchant-checklist'; -import { useEffect } from 'react'; +import { + QrCode, + FileText, + Wallet, + Zap, + Shield, + Globe, + ArrowRight, + CheckCircle2, + Receipt, +} from 'lucide-react'; + +/* ------------------------------------------------------------------ */ +/* Small reusable pieces */ +/* ------------------------------------------------------------------ */ + +function SectionBadge({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +function CtaButton({ + href, + children, + variant = 'primary', +}: { + href: string; + children: ReactNode; + variant?: 'primary' | 'secondary'; +}) { + const base = + 'inline-flex items-center gap-2 rounded-md px-6 py-3 text-sm font-semibold transition-colors'; + const styles = + variant === 'primary' + ? 'bg-blue-600 text-white hover:bg-blue-700' + : 'border border-gray-300 text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-800'; + return ( + + {children} + + ); +} + +/* ------------------------------------------------------------------ */ +/* Data */ +/* ------------------------------------------------------------------ */ -function DashboardContent() { - const { checklist, isLoading, progress, isCompleted, syncChecklist } = - useMerchantChecklist(); +const features = [ + { + icon: QrCode, + title: 'Instant QR Payments', + description: + 'Generate payment QR codes in seconds. Customers scan with any Stellar-compatible wallet and pay instantly β€” no manual address entry.', + }, + { + icon: FileText, + title: 'Professional Invoices', + description: + 'Create branded invoices with automatic payment tracking. Send them to clients via link or email and get paid on-chain.', + }, + { + icon: Wallet, + title: 'Wallet-Native Auth', + description: + 'No passwords, no email verification loops. Connect your Freighter wallet, sign a challenge, and you are in.', + }, + { + icon: Zap, + title: 'Multi-Asset Support', + description: + 'Accept XLM, USDC, and any Stellar asset your business needs. Switch between assets with a single toggle.', + }, + { + icon: Shield, + title: 'On-Chain Transparency', + description: + 'Every payment is recorded on the Stellar ledger. Reconcile accounts with a single click β€” no more spreadsheets.', + }, + { + icon: Globe, + title: 'Built for Global Commerce', + description: + 'Stellar settles cross-border payments in ~5 seconds. Invoice in any currency, get paid anywhere in the world.', + }, +]; - // Refresh server-derived completion (e.g. invoices created elsewhere) once on mount. - useEffect(() => { - syncChecklist(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); +const workflowSteps = [ + { + step: '01', + icon: Wallet, + title: 'Connect Your Wallet', + description: 'Install the Freighter browser extension and sign in with one click.', + }, + { + step: '02', + icon: Receipt, + title: 'Create an Invoice', + description: 'Enter the amount, choose an asset, add a memo β€” your payment request is ready.', + }, + { + step: '03', + icon: QrCode, + title: 'Share or Display QR', + description: 'Show the QR code at your counter or send the invoice link to your client.', + }, + { + step: '04', + icon: CheckCircle2, + title: 'Get Paid & Track', + description: 'Payments settle on-chain instantly. Monitor status in real time from your dashboard.', + }, +]; - if (isLoading && !checklist) { - return ( -
-
-
-
- ); - } +const stats = [ + { value: '~5 s', label: 'Settlement time' }, + { value: '< $0.001', label: 'Per transaction' }, + { value: '170+', label: 'Countries supported' }, +]; +export default function LandingPage() { return ( -
-
-
-

Dashboard

-

- {isCompleted - ? 'Your merchant account is active. Create and track invoices.' - : 'Finish setting up your account to start getting paid.'} +

+ {/* Hero */} +
+
+ Privacy-first invoicing on Stellar +

+ Get paid in crypto, the easy way +

+

+ Invoisio turns your Stellar wallet into a full invoicing desk. Create + invoices, share QR codes, and watch payments settle on-chain in seconds.

+
+ + Get started + + + View dashboard + +
+
+ {stats.map((s) => ( +
+

+ {s.value} +

+

{s.label}

+
+ ))} +
-
- - - Settings - - - + New Invoice - -
-
+ - {!isCompleted ? ( -
- - + ))}
- ) : ( -
-
-
-

- πŸŽ‰ You’re all set! -

-

- Your activation is complete. Create your first invoice to start - getting paid on Stellar. -

+ + + {/* Workflow */} +
+

How it works

+
+ {workflowSteps.map((w) => ( +
+ + {w.step} + + +

{w.title}

+

{w.description}

- - Go to Invoices - + ))} +
+
+ + {/* CTA */} +
+
+

Ready to send your first invoice?

+

+ Connect your wallet and start accepting Stellar payments in minutes. +

+
+ + Launch app +
- )} -
- ); -} + -export default function Home() { - return ( - -
- -
-
+ +
); } 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