diff --git a/backend/__tests__/services/ledgerService.test.js b/backend/__tests__/services/ledgerService.test.js index 061588b2..486d8ac1 100644 --- a/backend/__tests__/services/ledgerService.test.js +++ b/backend/__tests__/services/ledgerService.test.js @@ -195,11 +195,16 @@ describe('exportPostings', () => { expect(filename).toMatch(/_generic\.csv$/); }); - it('banana format uses Banana column names', async () => { - const { content, filename } = await ledgerService.exportPostings({ ...period, format: 'banana' }); + it('banana format is a TAB-separated .txt with Banana column names', async () => { + const { content, filename, contentType } = await ledgerService.exportPostings({ ...period, format: 'banana' }); const header = content.split('\r\n')[0]; - expect(header).toBe('"Date","Doc","Description","AccountDebit","AccountCredit","Amount","VatCode"'); - expect(filename).toMatch(/_banana\.csv$/); + // Banana's "Text file with column headers" import wants TAB-separated, + // unquoted values in a .txt — not a comma CSV. + expect(header).toBe('Date\tDoc\tDescription\tAccountDebit\tAccountCredit\tAmount\tVatCode'); + expect(content.split('\r\n')[1]).toContain('\t'); + expect(content).not.toContain('"'); + expect(filename).toMatch(/_banana\.txt$/); + expect(contentType).toMatch(/text\/plain/); }); it('bexio format includes tax_code + currency', async () => { diff --git a/backend/src/services/ledgerService.js b/backend/src/services/ledgerService.js index 363c7ff8..3db9e3af 100644 --- a/backend/src/services/ledgerService.js +++ b/backend/src/services/ledgerService.js @@ -407,8 +407,10 @@ async function exportPostings({ from, to, currency, format = 'generic' } = {}) { let headers; let rowOf; if (fmt === 'banana') { - // Banana "Conti doppia" import: Date, Doc, Description, AccountDebit, + // Banana "Conti doppia" import (Actions → Import into accounting → "Text + // file with column headers"): Date, Doc, Description, AccountDebit, // AccountCredit, Amount, VatCode. Amount = gross; VatCode expands VAT. + // Serialised TAB-separated + .txt below (Banana's required shape). headers = ['Date', 'Doc', 'Description', 'AccountDebit', 'AccountCredit', 'Amount', 'VatCode']; rowOf = (p) => [dateOnly(p.date), p.docNumber, p.description, p.debitAccount, p.creditAccount, minorToDecimal(p.grossMinor), p.vatCode]; } else if (fmt === 'bexio') { @@ -425,11 +427,23 @@ async function exportPostings({ from, to, currency, format = 'generic' } = {}) { p.vatCode, cur, minorToDecimal(p.grossMinor), minorToDecimal(p.netMinor), minorToDecimal(p.vatMinor)]; } - const lines = [headers.map(csvEscape).join(',')]; - for (const p of postings) lines.push(rowOf(p).map(csvEscape).join(',')); + // Banana's "Text file with column headers" import (banana.ch doc node 9947) + // requires a TAB-separated .txt with UNQUOTED values — a comma .csv won't even + // appear in its *.txt file picker. generic / bexio stay comma-CSV (RFC 4180). + const isBanana = fmt === 'banana'; + const sep = isBanana ? '\t' : ','; + // Tab layout: strip any tab/newline from a cell so it can't split the row; + // CSV cells go through the RFC-4180 quoter instead. + const fmtCell = isBanana + ? (v) => String(v == null ? '' : v).replace(/[\t\r\n]+/g, ' ') + : csvEscape; + const lines = [headers.map(fmtCell).join(sep)]; + for (const p of postings) lines.push(rowOf(p).map(fmtCell).join(sep)); const content = lines.join(eol) + eol; - const filename = `journal_${period.from}_to_${period.to}_${cur}_${fmt}.csv`; - return { content, filename, contentType: 'text/csv; charset=utf-8', count: postings.length }; + const ext = isBanana ? 'txt' : 'csv'; + const filename = `journal_${period.from}_to_${period.to}_${cur}_${fmt}.${ext}`; + const contentType = isBanana ? 'text/plain; charset=utf-8' : 'text/csv; charset=utf-8'; + return { content, filename, contentType, count: postings.length }; } // ── small util ─────────────────────────────────────────────────────── diff --git a/frontend/src/services/ledger.service.ts b/frontend/src/services/ledger.service.ts index c325af14..f28f0733 100644 --- a/frontend/src/services/ledger.service.ts +++ b/frontend/src/services/ledger.service.ts @@ -92,7 +92,10 @@ export const ledgerService = { const usp = new URLSearchParams({ from: params.from, to: params.to, currency: params.currency, format: params.format }); const res = await api.get(`/admin/ledger/export?${usp.toString()}`, { responseType: 'blob' }); const url = URL.createObjectURL(res.data); - const filename = `journal_${params.from}_to_${params.to}_${params.currency}_${params.format}.csv`; + // Banana wants a tab-separated .txt (its "Text file with column headers" + // import); generic / bexio stay .csv. Matches the backend's extension. + const ext = params.format === 'banana' ? 'txt' : 'csv'; + const filename = `journal_${params.from}_to_${params.to}_${params.currency}_${params.format}.${ext}`; return { url, filename }; }, };