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:
@@ -195,11 +195,16 @@ describe('exportPostings', () => {
|
|||||||
expect(filename).toMatch(/_generic\.csv$/);
|
expect(filename).toMatch(/_generic\.csv$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('banana format uses Banana column names', async () => {
|
it('banana format is a TAB-separated .txt with Banana column names', async () => {
|
||||||
const { content, filename } = await ledgerService.exportPostings({ ...period, format: 'banana' });
|
const { content, filename, contentType } = await ledgerService.exportPostings({ ...period, format: 'banana' });
|
||||||
const header = content.split('\r\n')[0];
|
const header = content.split('\r\n')[0];
|
||||||
expect(header).toBe('"Date","Doc","Description","AccountDebit","AccountCredit","Amount","VatCode"');
|
// Banana's "Text file with column headers" import wants TAB-separated,
|
||||||
expect(filename).toMatch(/_banana\.csv$/);
|
// 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 () => {
|
it('bexio format includes tax_code + currency', async () => {
|
||||||
|
|||||||
@@ -407,8 +407,10 @@ async function exportPostings({ from, to, currency, format = 'generic' } = {}) {
|
|||||||
let headers; let rowOf;
|
let headers; let rowOf;
|
||||||
|
|
||||||
if (fmt === 'banana') {
|
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.
|
// 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'];
|
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];
|
rowOf = (p) => [dateOnly(p.date), p.docNumber, p.description, p.debitAccount, p.creditAccount, minorToDecimal(p.grossMinor), p.vatCode];
|
||||||
} else if (fmt === 'bexio') {
|
} 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)];
|
p.vatCode, cur, minorToDecimal(p.grossMinor), minorToDecimal(p.netMinor), minorToDecimal(p.vatMinor)];
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = [headers.map(csvEscape).join(',')];
|
// Banana's "Text file with column headers" import (banana.ch doc node 9947)
|
||||||
for (const p of postings) lines.push(rowOf(p).map(csvEscape).join(','));
|
// 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 content = lines.join(eol) + eol;
|
||||||
const filename = `journal_${period.from}_to_${period.to}_${cur}_${fmt}.csv`;
|
const ext = isBanana ? 'txt' : 'csv';
|
||||||
return { content, filename, contentType: 'text/csv; charset=utf-8', count: postings.length };
|
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 ───────────────────────────────────────────────────────
|
// ── small util ───────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -92,7 +92,10 @@ export const ledgerService = {
|
|||||||
const usp = new URLSearchParams({ from: params.from, to: params.to, currency: params.currency, format: params.format });
|
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 res = await api.get(`/admin/ledger/export?${usp.toString()}`, { responseType: 'blob' });
|
||||||
const url = URL.createObjectURL(res.data);
|
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 };
|
return { url, filename };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user