fix(accounting): Banana export is now a tab-separated .txt (actually importable)

Banana's "Text file with column headers" import (Actions → Import into
accounting) requires a TAB-separated .txt with unquoted values — picpeak was
emitting a comma-separated, quoted .csv, which won't even show in Banana's
*.txt file picker, let alone parse into columns.

- ledgerService.exportPostings: the `banana` format now serialises TAB-separated
  with no quoting, .txt extension, text/plain content-type. generic + bexio stay
  comma-CSV (RFC 4180). Tab/newline chars in a cell are collapsed to spaces.
- Frontend ledger.service: download filename uses .txt for banana.
- Tests updated for the new banana shape (tab header, .txt, text/plain).

The column names already matched Banana's NameXml; only the serialisation was
wrong. bexio left as comma-CSV (verify against bexio's import spec separately).
This commit is contained in:
Luca
2026-06-15 22:28:10 +02:00
parent b584aaf7a6
commit a19506749a
3 changed files with 32 additions and 10 deletions
@@ -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 () => {
+19 -5
View File
@@ -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 ───────────────────────────────────────────────────────