// Shared CSV mechanics for exporting marketplace settlement data into lexoffice's // manual electronic-Kontoauszug (bank statement) CSV import — used by the Amazon // and eBay settlement pages. Format confirmed against lexoffice's own // reference/banking-lexoffice/Bankimport-Vorlage.csv template + their CSV-import // help article: semicolon-delimited, DD.MM.YYYY dates (other formats are // rejected), German-comma amounts, no currency symbol, no quoting (the real // template has none), and — critically — a cell of exactly 0,00 causes an import // error, so zero-amount rows must never be emitted. export interface LexofficeCsvRow { bookingDate: string | Date | null | undefined // Buchungstag valueDate?: string | Date | null // Valuta — defaults to bookingDate when omitted payerRecipient: string // Auftraggeber/Zahlungsempfänger recipientPayer: string // Empfänger/Zahlungspflichtiger purpose: string // Vorgang/Verwendungszweck amount: number // Betrag (signed EUR) extra?: string // Zusatzinfo (optional) } export const LEXOFFICE_CSV_HEADER = [ 'Buchungstag', 'Valuta', 'Auftraggeber/Zahlungsempfänger', 'Empfänger/Zahlungspflichtiger', 'Vorgang/Verwendungszweck', 'Betrag', 'Zusatzinfo (optional)' ] // -> 'DD.MM.YYYY' or ''. Built manually (not toLocaleDateString('de-DE'), which // omits leading zeros — "24.9.2024" instead of "24.09.2024" — violating // lexoffice's strict format requirement). export const formatLexofficeDate = (value: string | Date | null | undefined): string => { if (!value) return '' const d = value instanceof Date ? value : new Date(value) if (isNaN(d.getTime())) return '' const dd = String(d.getDate()).padStart(2, '0') const mm = String(d.getMonth() + 1).padStart(2, '0') const yyyy = d.getFullYear() return `${dd}.${mm}.${yyyy}` } // -> German comma, 2dp, e.g. '28,20' / '-15,50'. export const formatLexofficeAmount = (value: number): string => { const rounded = Math.round((Number(value) || 0) * 100) / 100 return rounded.toFixed(2).replace('.', ',') } // Strips characters that would break the semicolon-delimited row (the real // template uses no field quoting, so this — not quoting — is what keeps a row // well-formed; every field we emit is our own construction, never raw external // free text, so stripping is sufficient). export const sanitizeLexofficeField = (value: string): string => { return String(value ?? '').replace(/[;\r\n]+/g, ' ').trim() } // Header + CRLF-joined rows (matches the real template's own CRLF line endings). // The single place that enforces lexoffice's "no 0,00 cells" rule: any row whose // amount rounds to exactly 0 is silently dropped — callers never need to // duplicate that check themselves. export const buildLexofficeCsv = (rows: LexofficeCsvRow[]): string => { const lines = [LEXOFFICE_CSV_HEADER.join(';')] for (const row of rows) { const amount = Math.round((Number(row.amount) || 0) * 100) / 100 if (!amount) continue lines.push([ formatLexofficeDate(row.bookingDate), formatLexofficeDate(row.valueDate ?? row.bookingDate), sanitizeLexofficeField(row.payerRecipient), sanitizeLexofficeField(row.recipientPayer), sanitizeLexofficeField(row.purpose), formatLexofficeAmount(amount), row.extra ? sanitizeLexofficeField(row.extra) : '' ].join(';')) } return lines.join('\r\n') } // Builds the CSV and triggers a browser download. UTF-8 with a BOM is used // rather than matching the template's own ISO-8859-1 byte encoding — lexoffice's // import wizard lets the user pick the encoding at import time, and UTF-8+BOM is // simple and robust from browser JS (no manual Latin-1 byte packing needed). export const downloadLexofficeCsv = ( rows: LexofficeCsvRow[], filename: string ): { rowCount: number; skippedZero: number } => { const eligible = rows.filter(r => Math.round((Number(r.amount) || 0) * 100) !== 0) const csvContent = buildLexofficeCsv(rows) const blob = new Blob(['' + csvContent], { type: 'text/csv;charset=utf-8;' }) const link = document.createElement('a') link.href = URL.createObjectURL(blob) link.download = filename link.click() URL.revokeObjectURL(link.href) return { rowCount: eligible.length, skippedZero: rows.length - eligible.length } }