Files
picpeak/backend/__tests__/services/ledgerService.test.js
T
Luca 03cc250b47 feat(accounting): Layer A backend — chart of accounts, VAT codes, Treuhänder export
Prepares picpeak to feed a Treuhänder's double-entry software once a user
crosses the CHF ~500k threshold (LI PGR Art. 1045), without becoming an ERP.

- migration 129: ledger_accounts (seeded Swiss/LI KMU-Kontenrahmen) +
  vat_codes (CH/LI MWST 8.1/2.6/3.8/0 + reverse charge), expense_categories
  gains ledger_account_id, app_settings default-account + VAT-map seeds
- ledgerService: full CRUD for accounts + VAT codes + mappings; buildPostings()
  turns revenue invoices + incoming invoices + expenses into accrual
  Buchungssätze (Dr/Cr + VAT code); generic/banana/bexio CSV export
- routes /api/admin/ledger/* (accounting master gated; export also requires
  taxReport); 12 unit tests (posting engine + formatters)

Accrual basis only — payment/bank postings are Layer B. Output is a guideline
(Treuhänder caveat on the UI).
2026-06-11 21:37:48 +02:00

217 lines
9.3 KiB
JavaScript

/**
* Tests for ledgerService (Accounting Layer A).
*
* Two layers:
* 1. Pure helpers (rateKey, csvEscape, minorToDecimal).
* 2. buildPostings + exportPostings — db chain + appSettings mocked so we can
* feed canned invoices/inbound/expenses and assert the Buchungssätze +
* the per-tool CSV shapes.
*/
// ----- canned data per table ------------------------------------------
let accountsRows = [];
let vatRows = [];
let invoiceRows = [];
let inboundRows = [];
let expenseRows = [];
function makeChain(rows) {
const c = {
_rows: rows,
then(onR, onJ) { return Promise.resolve(this._rows).then(onR, onJ); },
leftJoin() { return this; },
where() { return this; },
whereNot() { return this; },
whereIn() { return this; },
whereNotIn() { return this; },
whereBetween() { return this; },
whereRaw() { return this; },
orderBy() { return this; },
orderByRaw() { return this; },
modify(cb) { if (typeof cb === 'function') cb(this); return this; },
select() { return Promise.resolve(this._rows); },
first() { return Promise.resolve(this._rows[0]); },
};
return c;
}
const mockDbFn = jest.fn((table) => {
switch (table) {
case 'ledger_accounts': return makeChain(accountsRows);
case 'vat_codes': return makeChain(vatRows);
case 'invoices': return makeChain(invoiceRows);
case 'inbound_documents': return makeChain(inboundRows);
case 'expenses': return makeChain(expenseRows);
default: return makeChain([]);
}
});
mockDbFn.raw = (s) => s;
mockDbFn.schema = {
hasTable: jest.fn(async () => true),
hasColumn: jest.fn(async () => true),
};
jest.mock('../../src/database/db', () => ({ db: mockDbFn, withRetry: async (fn) => fn() }));
const SETTINGS = {
ledger_account_debitoren: '1100',
ledger_account_kreditoren: '2000',
ledger_account_default_revenue: '3400',
ledger_account_default_expense: '6700',
ledger_account_mileage: '6200',
ledger_account_per_diem: '6640',
ledger_account_rebilled_revenue: '3940',
ledger_vat_map: { domestic: 'VST81', reverse_charge_service: 'BZ', foreign_vat_non_reclaimable: 'VST00', import_goods: 'VST81' },
ledger_output_vat_map: { '8.1': 'UN81', '2.6': 'UN26', '3.8': 'UN38', '0': 'UN00' },
};
jest.mock('../../src/utils/appSettings', () => ({
getAppSetting: jest.fn(async (key, def) => (key in SETTINGS ? SETTINGS[key] : def)),
}));
const ledgerService = require('../../src/services/ledgerService');
const { rateKey, csvEscape, minorToDecimal } = ledgerService._internal;
beforeEach(() => {
accountsRows = [
{ id: 1, number: '1100', name: 'Debitoren', type: 'asset' },
{ id: 2, number: '3400', name: 'Dienstleistungsertrag', type: 'revenue' },
{ id: 3, number: '2000', name: 'Kreditoren', type: 'liability' },
{ id: 4, number: '6570', name: 'Informatikaufwand', type: 'expense' },
{ id: 5, number: '6200', name: 'Fahrzeugaufwand', type: 'expense' },
{ id: 6, number: '6700', name: 'Sonstiger Betriebsaufwand', type: 'expense' },
];
vatRows = [{ id: 9, code: 'UN81', rate: 8.1, direction: 'output', account_id: null }];
invoiceRows = [];
inboundRows = [];
expenseRows = [];
});
// ----- pure helpers ----------------------------------------------------
describe('rateKey', () => {
it('normalises rate to the output-map key', () => {
expect(rateKey(8.1)).toBe('8.1');
expect(rateKey(8.10)).toBe('8.1');
expect(rateKey('2.60')).toBe('2.6');
expect(rateKey(0)).toBe('0');
expect(rateKey(null)).toBe('0');
});
});
describe('csvEscape / minorToDecimal', () => {
it('quotes + doubles inner quotes', () => {
expect(csvEscape('a,b')).toBe('"a,b"');
expect(csvEscape('he said "hi"')).toBe('"he said ""hi"""');
expect(csvEscape(null)).toBe('""');
});
it('renders minor units as 2dp', () => {
expect(minorToDecimal(10810)).toBe('108.10');
expect(minorToDecimal(0)).toBe('0.00');
expect(minorToDecimal(null)).toBe('0.00');
});
});
// ----- buildPostings ---------------------------------------------------
describe('buildPostings', () => {
const period = { from: '2026-01-01', to: '2026-03-31', currency: 'CHF' };
it('books a revenue invoice as Dr Debitoren / Cr Ertrag with the output VAT code', async () => {
invoiceRows = [{
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-10', vat_rate: 8.1,
net_amount_minor: 10000, vat_amount_minor: 810, total_amount_minor: 10810,
customer_company_name: 'ACME GmbH', event_name: 'Wedding A',
}];
const { postings } = await ledgerService.buildPostings(period);
expect(postings).toHaveLength(1);
expect(postings[0]).toMatchObject({
debitAccount: '1100', debitName: 'Debitoren',
creditAccount: '3400', creditName: 'Dienstleistungsertrag',
grossMinor: 10810, netMinor: 10000, vatMinor: 810,
vatCode: 'UN81', source: 'revenue', eventName: 'Wedding A',
});
});
it('books an incoming invoice as Dr Aufwand(category) / Cr Kreditoren with the input VAT code', async () => {
inboundRows = [{
id: 5, invoice_number: 'L-77', invoice_date: '2026-01-12', created_at: '2026-01-13 09:00:00',
supplier_name: 'Lab AG', tax_treatment: 'domestic',
net_amount_minor: 2000, vat_amount_minor: 162, total_amount_minor: 2162,
event_id: 7, cat_account_id: 4, event_name: 'Wedding A',
}];
const { postings } = await ledgerService.buildPostings(period);
expect(postings).toHaveLength(1);
expect(postings[0]).toMatchObject({
debitAccount: '6570', creditAccount: '2000',
grossMinor: 2162, netMinor: 2000, vatMinor: 162,
vatCode: 'VST81', source: 'incoming', eventName: 'Wedding A',
});
});
it('falls back to the kind default account for a category-less mileage expense', async () => {
expenseRows = [{
id: 9, created_at: '2026-02-01 12:00:00', kind: 'mileage', supplier_name: null, description: 'Drive',
tax_treatment: 'foreign_vat_non_reclaimable', event_id: null,
original_amount_minor: null, chf_amount_minor: 5000,
net_amount_minor: null, vat_amount_minor: null, gross_amount_minor: null, cat_account_id: null,
}];
const { postings } = await ledgerService.buildPostings(period);
expect(postings).toHaveLength(1);
expect(postings[0]).toMatchObject({
debitAccount: '6200', creditAccount: '2000',
grossMinor: 5000, vatMinor: 0,
vatCode: 'VST00', source: 'expense', eventName: '',
});
});
it('sorts the combined journal chronologically across all sources', async () => {
invoiceRows = [{ id: 1, invoice_number: 'R1', issue_date: '2026-02-20', vat_rate: 8.1, net_amount_minor: 100, vat_amount_minor: 8, total_amount_minor: 108, customer_company_name: 'A' }];
inboundRows = [{ id: 5, invoice_number: 'L1', invoice_date: '2026-01-05', created_at: '2026-01-05', supplier_name: 'Lab', tax_treatment: 'domestic', net_amount_minor: 50, vat_amount_minor: 4, total_amount_minor: 54, event_id: null, cat_account_id: null }];
expenseRows = [{ id: 9, created_at: '2026-01-30', kind: 'amount', description: 'x', tax_treatment: 'domestic', event_id: null, chf_amount_minor: 200, net_amount_minor: null, vat_amount_minor: null, gross_amount_minor: null, cat_account_id: null }];
const { postings } = await ledgerService.buildPostings(period);
expect(postings.map((p) => p.source)).toEqual(['incoming', 'expense', 'revenue']);
});
it('requires from/to/currency', async () => {
await expect(ledgerService.buildPostings({})).rejects.toThrow(/from.+to/);
await expect(ledgerService.buildPostings({ from: '2026-01-01', to: '2026-03-31' })).rejects.toThrow(/currency/);
});
});
// ----- exportPostings --------------------------------------------------
describe('exportPostings', () => {
const period = { from: '2026-01-01', to: '2026-03-31', currency: 'CHF' };
beforeEach(() => {
invoiceRows = [{ id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-10', vat_rate: 8.1, net_amount_minor: 10000, vat_amount_minor: 810, total_amount_minor: 10810, customer_company_name: 'ACME' }];
});
it('generic format carries all human-friendly columns', async () => {
const { content, filename, count } = await ledgerService.exportPostings({ ...period, format: 'generic' });
const [header, row] = content.trim().split('\r\n');
expect(count).toBe(1);
expect(header).toContain('DebitAccountName');
expect(header).toContain('NetAmount');
expect(header).toContain('VatCode');
expect(row).toContain('1100');
expect(row).toContain('108.10'); // gross 2dp
expect(filename).toMatch(/_generic\.csv$/);
});
it('banana format uses Banana column names', async () => {
const { content, filename } = 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$/);
});
it('bexio format includes tax_code + currency', async () => {
const { content } = await ledgerService.exportPostings({ ...period, format: 'bexio' });
const header = content.split('\r\n')[0];
expect(header).toContain('tax_code');
expect(header).toContain('currency');
});
it('unknown format falls back to generic', async () => {
const { filename } = await ledgerService.exportPostings({ ...period, format: 'nope' });
expect(filename).toMatch(/_generic\.csv$/);
});
});