diff --git a/backend/__tests__/services/ledgerService.test.js b/backend/__tests__/services/ledgerService.test.js index 486d8ac1..b282f5a9 100644 --- a/backend/__tests__/services/ledgerService.test.js +++ b/backend/__tests__/services/ledgerService.test.js @@ -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]; diff --git a/backend/src/services/ledgerService.js b/backend/src/services/ledgerService.js index 3db9e3af..6c570fb9 100644 --- a/backend/src/services/ledgerService.js +++ b/backend/src/services/ledgerService.js @@ -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 }; } diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 1513ed0c..63e40d7b 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -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", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 8c025724..7e82eac4 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -3915,7 +3915,8 @@ "intro": "Download the collective journal (revenue + costs as accrual postings with account and VAT codes) for import into your Treuhänder’s 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", diff --git a/frontend/src/pages/admin/clients/TaxReportPage.tsx b/frontend/src/pages/admin/clients/TaxReportPage.tsx index c4733688..ba1edfb3 100644 --- a/frontend/src/pages/admin/clients/TaxReportPage.tsx +++ b/frontend/src/pages/admin/clients/TaxReportPage.tsx @@ -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'; diff --git a/frontend/src/services/ledger.service.ts b/frontend/src/services/ledger.service.ts index f28f0733..328eca83 100644 --- a/frontend/src/services/ledger.service.ts +++ b/frontend/src/services/ledger.service.ts @@ -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 }; },