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).
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* 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$/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user