feat(accounting): add a Banana "Income & Expense" (cash-book) export format

The Banana export assumed a double-entry file; a user importing into an Income
& Expense (Einnahmen-Ausgaben) file got "AccountDebit/AccountCredit/Amount/
VatCode column not found", since those columns only exist in double-entry.

Add a second Banana format alongside the double-entry one:
- ledgerService: new `banana_ie` format → Banana I&E columns Date, Doc,
  Description, Income, Expenses, ContraAccount (the income/expense account),
  VatCode (banana.ch doc 9946). Revenue → gross in Income + revenue account;
  cost → gross in Expenses + expense account. Same tab-separated .txt shape.
- Frontend: ExportFormat + dropdown gain `banana_ie`; .txt extension covers
  both Banana variants. Labels relabelled: "Banana — double-entry" and
  "Banana — income & expense" (de equivalents). Hint de-"double-entry"-fied.
- Test added for the I&E format.

Pairs with the prior UTF-8 BOM fix (the "·" mojibake). Tests + build green.
This commit is contained in:
Luca
2026-06-15 22:48:06 +02:00
parent 74144da45f
commit 445d6d7b6d
6 changed files with 51 additions and 17 deletions
@@ -207,6 +207,20 @@ describe('exportPostings', () => {
expect(contentType).toMatch(/text\/plain/);
});
it('banana_ie format is Income & Expense columns, tab-separated .txt', async () => {
const { content, filename, contentType } = await ledgerService.exportPostings({ ...period, format: 'banana_ie' });
const [header, row] = content.trim().split('\r\n');
expect(header).toBe('Date\tDoc\tDescription\tIncome\tExpenses\tContraAccount\tVatCode');
// The mock period holds one revenue posting (gross 108.10) → Income filled,
// Expenses empty, ContraAccount = the revenue account.
const cells = row.split('\t');
expect(cells[3]).toBe('108.10'); // Income
expect(cells[4]).toBe(''); // Expenses
expect(cells[5]).not.toBe(''); // ContraAccount (revenue account)
expect(filename).toMatch(/_banana_ie\.txt$/);
expect(contentType).toMatch(/text\/plain/);
});
it('bexio format includes tax_code + currency', async () => {
const { content } = await ledgerService.exportPostings({ ...period, format: 'bexio' });
const header = content.split('\r\n')[0];
+26 -8
View File
@@ -392,7 +392,7 @@ function csvEscape(cell) {
function minorToDecimal(m) { return ((Number(m) || 0) / 100).toFixed(2); }
function dateOnly(d) { return String(d || '').slice(0, 10); }
const EXPORT_FORMATS = ['generic', 'banana', 'bexio'];
const EXPORT_FORMATS = ['generic', 'banana', 'banana_ie', 'bexio'];
/**
* Render the collective journal in the requested format. Returns
@@ -413,6 +413,23 @@ async function exportPostings({ from, to, currency, format = 'generic' } = {}) {
// 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 === 'banana_ie') {
// Banana Income & Expense (cash-book / Einnahmen-Ausgaben) import — for a
// file that is NOT double-entry. Columns: Date, Doc, Description, Income,
// Expenses, ContraAccount (the income/expense account — banana.ch doc 9946),
// VatCode. Revenue → gross in Income + the revenue account; cost → gross in
// Expenses + the expense account. Amount is gross; VatCode expands the VAT.
// Same TAB-separated .txt shape as double-entry.
headers = ['Date', 'Doc', 'Description', 'Income', 'Expenses', 'ContraAccount', 'VatCode'];
rowOf = (p) => {
const isRevenue = p.source === 'revenue';
const amount = minorToDecimal(p.grossMinor);
// Contra = the P&L account: revenue account (credit side) for income,
// expense account (debit side) for costs.
const contra = isRevenue ? p.creditAccount : p.debitAccount;
return [dateOnly(p.date), p.docNumber, p.description,
isRevenue ? amount : '', isRevenue ? '' : amount, contra, p.vatCode];
};
} else if (fmt === 'bexio') {
// bexio manual-entry import.
headers = ['date', 'reference_nr', 'description', 'debit_account', 'credit_account', 'amount', 'tax_code', 'currency'];
@@ -427,22 +444,23 @@ async function exportPostings({ from, to, currency, format = 'generic' } = {}) {
p.vatCode, cur, minorToDecimal(p.grossMinor), minorToDecimal(p.netMinor), minorToDecimal(p.vatMinor)];
}
// Banana's "Text file with column headers" import (banana.ch doc node 9947)
// Banana's "Text file with column headers" import (banana.ch doc 9946/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' : ',';
// appear in its *.txt file picker. Both Banana variants use it; generic /
// bexio stay comma-CSV (RFC 4180).
const isTab = fmt === 'banana' || fmt === 'banana_ie';
const sep = isTab ? '\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
const fmtCell = isTab
? (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 ext = isBanana ? 'txt' : 'csv';
const ext = isTab ? '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';
const contentType = isTab ? 'text/plain; charset=utf-8' : 'text/csv; charset=utf-8';
return { content, filename, contentType, count: postings.length };
}
+3 -2
View File
@@ -3915,7 +3915,8 @@
"intro": "Lade das Sammeljournal (Erträge + Kosten als periodengerechte Buchungssätze mit Konto- und MWST-Codes) zum Import in die Buchhaltungssoftware deines Treuhänders herunter.",
"format": "Zielsoftware",
"format_generic": "Generisch (alle Spalten)",
"format_banana": "Banana Buchhaltung",
"format_banana": "Banana — doppelte Buchhaltung",
"format_banana_ie": "Banana — Einnahmen/Ausgaben",
"format_bexio": "bexio",
"download": "CSV herunterladen",
"failed": "Export fehlgeschlagen.",
@@ -3938,7 +3939,7 @@
},
"exportPdf": "PDF exportieren",
"ledgerExport": "Treuhänder-Export",
"ledgerExportHint": "Doppelte Buchungssätze für Ihren Treuhänder, abgebildet über Ihren Kontenplan.",
"ledgerExportHint": "Buchungssätze für Ihren Treuhänder, abgebildet über Ihren Kontenplan.",
"ledgerExportConfigure": "Einrichten →",
"export": {
"reportTitle": "Bericht",
+3 -2
View File
@@ -3915,7 +3915,8 @@
"intro": "Download the collective journal (revenue + costs as accrual postings with account and VAT codes) for import into your Treuhänders accounting software.",
"format": "Target tool",
"format_generic": "Generic (all columns)",
"format_banana": "Banana Accounting",
"format_banana": "Banana — double-entry",
"format_banana_ie": "Banana — income & expense",
"format_bexio": "bexio",
"download": "Download CSV",
"failed": "Export failed.",
@@ -3938,7 +3939,7 @@
},
"exportPdf": "Export PDF",
"ledgerExport": "Accountant export",
"ledgerExportHint": "Double-entry postings for your accountant, mapped via your Chart of accounts.",
"ledgerExportHint": "Bookkeeping entries for your accountant, mapped via your Chart of accounts.",
"ledgerExportConfigure": "Configure →",
"export": {
"reportTitle": "Report",
@@ -32,7 +32,7 @@ import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import { toast } from 'react-toastify';
const LEDGER_FORMATS: ExportFormat[] = ['generic', 'banana', 'bexio'];
const LEDGER_FORMATS: ExportFormat[] = ['generic', 'banana', 'banana_ie', 'bexio'];
type PeriodPreset = 'thisYear' | 'lastYear' | 'thisQuarter' | 'lastQuarter' | 'custom';
+4 -4
View File
@@ -8,7 +8,7 @@ import { api } from '../config/api';
export type AccountType = 'asset' | 'liability' | 'equity' | 'revenue' | 'expense';
export type VatDirection = 'output' | 'input';
export type ExportFormat = 'generic' | 'banana' | 'bexio';
export type ExportFormat = 'generic' | 'banana' | 'banana_ie' | 'bexio';
export interface LedgerAccount {
id: number;
@@ -92,9 +92,9 @@ 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);
// 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';
// Both Banana variants want a tab-separated .txt (its "Text file with column
// headers" import); generic / bexio stay .csv. Matches the backend extension.
const ext = (params.format === 'banana' || params.format === 'banana_ie') ? 'txt' : 'csv';
const filename = `journal_${params.from}_to_${params.to}_${params.currency}_${params.format}.${ext}`;
return { url, filename };
},