From 100beb99740074d1dff6c6acd901e5873340c029 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 12 May 2026 22:49:20 +0200 Subject: [PATCH] feat(deps): papaparse for expense CSV export Replaces the hand-rolled `[fields].map(v => \`"\${v}"\`).join(',')` pattern in expense-export.tsx with papaparse's Papa.unparse. The previous version didn't handle: - commas inside fields (would split rows mid-record) - newlines inside fields (would terminate rows early) - BOM for Excel-friendly encoding - numeric/null normalization Papa.unparse handles all of those + accepts a keyed-object row shape that lets us define column order and get matching headers for free. Verified: tsc clean, vitest 1315/1315. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 2 + pnpm-lock.yaml | 18 +++++++++ src/lib/services/expense-export.tsx | 61 +++++++++++++++-------------- 3 files changed, 51 insertions(+), 30 deletions(-) diff --git a/package.json b/package.json index 45aef274..efd4ade3 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ "nodemailer": "^8.0.7", "openai": "^6.37.0", "p-limit": "^7.3.0", + "papaparse": "^5.5.3", "pdf-lib": "^1.17.1", "pdfkit": "^0.18.0", "pino": "^10.3.1", @@ -130,6 +131,7 @@ "@types/mailparser": "^3.4.6", "@types/node": "^20.19.0", "@types/nodemailer": "^8.0.0", + "@types/papaparse": "^5.5.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a132121..198b4495 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -190,6 +190,9 @@ importers: p-limit: specifier: ^7.3.0 version: 7.3.0 + papaparse: + specifier: ^5.5.3 + version: 5.5.3 pdf-lib: specifier: ^1.17.1 version: 1.17.1 @@ -311,6 +314,9 @@ importers: '@types/nodemailer': specifier: ^8.0.0 version: 8.0.0 + '@types/papaparse': + specifier: ^5.5.2 + version: 5.5.2 '@types/react': specifier: ^19.2.14 version: 19.2.14 @@ -3147,6 +3153,9 @@ packages: '@types/nodemailer@8.0.0': resolution: {integrity: sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==} + '@types/papaparse@5.5.2': + resolution: {integrity: sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==} + '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} @@ -5776,6 +5785,9 @@ packages: pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + papaparse@5.5.3: + resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -9797,6 +9809,10 @@ snapshots: dependencies: '@types/node': 20.19.41 + '@types/papaparse@5.5.2': + dependencies: + '@types/node': 20.19.41 + '@types/parse-json@4.0.2': {} '@types/pdfkit@0.17.6': @@ -12516,6 +12532,8 @@ snapshots: pako@1.0.11: {} + papaparse@5.5.3: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 diff --git a/src/lib/services/expense-export.tsx b/src/lib/services/expense-export.tsx index d12d32b8..953074a4 100644 --- a/src/lib/services/expense-export.tsx +++ b/src/lib/services/expense-export.tsx @@ -1,3 +1,4 @@ +import Papa from 'papaparse'; import { eq, and, gte, lte, isNull, or, ilike } from 'drizzle-orm'; import { db } from '@/lib/db'; @@ -58,36 +59,36 @@ async function fetchAllExpenses(portId: string, query: ListExpensesInput) { export async function exportCsv(portId: string, query: ListExpensesInput): Promise { const rows = await fetchAllExpenses(portId, query); - const headers = [ - 'Date', - 'Establishment', - 'Category', - 'Amount', - 'Currency', - 'Amount USD', - 'Payment Status', - 'Payment Method', - 'Description', - ]; - - const csvRows = rows.map((r) => { - const date = r.expenseDate ? new Date(r.expenseDate).toISOString().split('T')[0] : ''; - return [ - date, - r.establishmentName ?? '', - r.category ?? '', - r.amount, - r.currency, - r.amountUsd ?? 'N/A', - r.paymentStatus ?? '', - r.paymentMethod ?? '', - (r.description ?? '').replace(/"/g, '""'), - ] - .map((v) => `"${v}"`) - .join(','); - }); - - return [headers.join(','), ...csvRows].join('\n'); + // papaparse handles all the CSV edge cases (commas in fields, embedded + // quotes, newlines, BOM) that the hand-rolled escape-and-quote version + // missed. Keyed objects let us define column order via `columns` and + // get matching headers for free. + return Papa.unparse( + rows.map((r) => ({ + Date: r.expenseDate ? new Date(r.expenseDate).toISOString().split('T')[0] : '', + Establishment: r.establishmentName ?? '', + Category: r.category ?? '', + Amount: r.amount, + Currency: r.currency, + 'Amount USD': r.amountUsd ?? 'N/A', + 'Payment Status': r.paymentStatus ?? '', + 'Payment Method': r.paymentMethod ?? '', + Description: r.description ?? '', + })), + { + columns: [ + 'Date', + 'Establishment', + 'Category', + 'Amount', + 'Currency', + 'Amount USD', + 'Payment Status', + 'Payment Method', + 'Description', + ], + }, + ); } /**