Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions backend/src/invoices/invoices.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<Buffer> {
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
Expand Down
60 changes: 60 additions & 0 deletions backend/src/invoices/invoices.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
149 changes: 149 additions & 0 deletions backend/src/invoices/invoices.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,155 @@
};
}

/**
* 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<string, unknown> = { 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);

Check failure on line 266 in backend/src/invoices/invoices.service.ts

View workflow job for this annotation

GitHub Actions / Lint, Test and Build (20.x)

'value' will use Object's default stringification format ('[object Object]') when stringified
// 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
Expand Down
57 changes: 57 additions & 0 deletions web/app/invoices/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,8 @@

const [customQueries, setCustomQueries] = useState<SavedQuery[]>([]);
const [isSaving, setIsSaving] = useState(false);
const [isExporting, setIsExporting] = useState(false);

Check warning on line 182 in web/app/invoices/page.tsx

View workflow job for this annotation

GitHub Actions / build

'isExporting' is assigned a value but never used
const [exportError, setExportError] = useState<string | null>(null);

Check warning on line 183 in web/app/invoices/page.tsx

View workflow job for this annotation

GitHub Actions / build

'exportError' is assigned a value but never used
const [saveName, setSaveName] = useState("");

const pageSize = 20;
Expand Down Expand Up @@ -478,6 +480,61 @@
localStorage.setItem("invoisio_saved_queries", JSON.stringify(updated));
};

const handleExport = async () => {

Check warning on line 483 in web/app/invoices/page.tsx

View workflow job for this annotation

GitHub Actions / build

'handleExport' is assigned a value but never used
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,
Expand Down
1 change: 1 addition & 0 deletions web/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { extractApiErrorMessage } from "@/lib/api-client";

Check warning on line 1 in web/app/page.tsx

View workflow job for this annotation

GitHub Actions / build

'extractApiErrorMessage' is defined but never used
import type { ReactNode } from 'react';
import Link from 'next/link';
import {
Expand Down
21 changes: 21 additions & 0 deletions web/lib/api-errors.ts
Original file line number Diff line number Diff line change
@@ -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.";
}
Loading
Loading