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$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Migration 129: Accounting Layer A — chart of accounts + VAT codes + mappings.
|
||||
*
|
||||
* Prepares picpeak to feed a Treuhänder's double-entry software once a user
|
||||
* crosses the CHF ~500k threshold (LI PGR Art. 1045 → full Buchführung). We do
|
||||
* NOT become an ERP here: we attach account + VAT codes to the data we already
|
||||
* capture so a "collective journal" export can be imported into Banana / bexio /
|
||||
* etc. Native double-entry (journal, Bilanz, Erfolgsrechnung) is Layer B.
|
||||
*
|
||||
* - ledger_accounts : chart of accounts (seeded Swiss/LI KMU Kontenrahmen,
|
||||
* admin-editable — full CRUD).
|
||||
* - vat_codes : MWST codes (CH/LI rates 8.1 / 2.6 / 3.8 / 0 + reverse
|
||||
* charge), each linked to its VAT account.
|
||||
* - expense_categories.ledger_account_id : which expense account a category
|
||||
* books to (mapping, editable).
|
||||
* - app_settings (accounting) : system/default account numbers + the
|
||||
* tax_treatment→VAT-code and output-rate→VAT-code maps.
|
||||
*
|
||||
* Everything is hasTable/hasColumn-guarded + idempotent. app_settings rows use
|
||||
* setting_key/value/type ONLY (no created_at/updated_at — see migration 103).
|
||||
* Legal/financial defaults are EXAMPLES — every surface must point the user at
|
||||
* a Treuhänder.
|
||||
*/
|
||||
|
||||
// Swiss/LI KMU-Kontenrahmen (condensed for a services/photography SME).
|
||||
const SEED_ACCOUNTS = [
|
||||
// Aktiven
|
||||
{ number: '1000', name: 'Kasse', type: 'asset' },
|
||||
{ number: '1020', name: 'Bank', type: 'asset' },
|
||||
{ number: '1100', name: 'Forderungen aus Lieferungen und Leistungen (Debitoren)', type: 'asset' },
|
||||
{ number: '1170', name: 'Vorsteuer MWST', type: 'asset' },
|
||||
{ number: '1300', name: 'Aktive Rechnungsabgrenzung', type: 'asset' },
|
||||
{ number: '1500', name: 'Mobiliar und Einrichtungen', type: 'asset' },
|
||||
{ number: '1520', name: 'Büromaschinen, Informatik, Kommunikation', type: 'asset' },
|
||||
// Passiven
|
||||
{ number: '2000', name: 'Verbindlichkeiten aus Lieferungen und Leistungen (Kreditoren)', type: 'liability' },
|
||||
{ number: '2200', name: 'Geschuldete MWST (Umsatzsteuer)', type: 'liability' },
|
||||
{ number: '2300', name: 'Passive Rechnungsabgrenzung', type: 'liability' },
|
||||
{ number: '2800', name: 'Eigenkapital', type: 'equity' },
|
||||
// Ertrag
|
||||
{ number: '3000', name: 'Produktionsertrag (Fotografie)', type: 'revenue' },
|
||||
{ number: '3200', name: 'Handelsertrag', type: 'revenue' },
|
||||
{ number: '3400', name: 'Dienstleistungsertrag', type: 'revenue' },
|
||||
{ number: '3940', name: 'Weiterverrechnete Spesen', type: 'revenue' },
|
||||
// Aufwand
|
||||
{ number: '4000', name: 'Materialaufwand', type: 'expense' },
|
||||
{ number: '4400', name: 'Aufwand für bezogene Dienstleistungen', type: 'expense' },
|
||||
{ number: '6000', name: 'Raumaufwand (Miete)', type: 'expense' },
|
||||
{ number: '6100', name: 'Unterhalt und Reparaturen', type: 'expense' },
|
||||
{ number: '6200', name: 'Fahrzeug- und Transportaufwand', type: 'expense' },
|
||||
{ number: '6300', name: 'Sachversicherungen, Abgaben, Gebühren', type: 'expense' },
|
||||
{ number: '6500', name: 'Verwaltungsaufwand', type: 'expense' },
|
||||
{ number: '6510', name: 'Telefon, Internet, Porti', type: 'expense' },
|
||||
{ number: '6570', name: 'Informatikaufwand (Software)', type: 'expense' },
|
||||
{ number: '6600', name: 'Werbeaufwand', type: 'expense' },
|
||||
{ number: '6640', name: 'Reise- und Spesenaufwand', type: 'expense' },
|
||||
{ number: '6700', name: 'Sonstiger Betriebsaufwand', type: 'expense' },
|
||||
{ number: '6800', name: 'Abschreibungen', type: 'expense' },
|
||||
];
|
||||
|
||||
// CH/LI MWST codes. `direction` = output (Umsatzsteuer) | input (Vorsteuer).
|
||||
// `account` is the VAT account number (resolved to an id after the accounts
|
||||
// are seeded). 0%/exempt codes carry no VAT account.
|
||||
const SEED_VAT_CODES = [
|
||||
{ code: 'UN81', name: 'Umsatz Normalsatz 8.1%', rate: 8.1, direction: 'output', account: '2200' },
|
||||
{ code: 'UN26', name: 'Umsatz reduzierter Satz 2.6%', rate: 2.6, direction: 'output', account: '2200' },
|
||||
{ code: 'UN38', name: 'Umsatz Beherbergung 3.8%', rate: 3.8, direction: 'output', account: '2200' },
|
||||
{ code: 'UN00', name: 'Umsatz ohne MWST / befreit', rate: 0, direction: 'output', account: null },
|
||||
{ code: 'VST81', name: 'Vorsteuer 8.1%', rate: 8.1, direction: 'input', account: '1170' },
|
||||
{ code: 'VST26', name: 'Vorsteuer 2.6%', rate: 2.6, direction: 'input', account: '1170' },
|
||||
{ code: 'VST00', name: 'Keine Vorsteuer', rate: 0, direction: 'input', account: null },
|
||||
{ code: 'BZ', name: 'Bezugsteuer (Reverse Charge) 8.1%', rate: 8.1, direction: 'input', account: '1170' },
|
||||
];
|
||||
|
||||
// expense_categories.name → expense account number.
|
||||
const CATEGORY_ACCOUNT_MAP = {
|
||||
'Infrastruktur & Miete': '6000',
|
||||
'Equipment & Hardware': '6700',
|
||||
'Software & Lizenzen': '6570',
|
||||
'Material & Verbrauch': '4000',
|
||||
'Reise & Spesen': '6640',
|
||||
'Werbung & Marketing': '6600',
|
||||
'Dienstleistungen/Fremdleistungen': '4400',
|
||||
'Versicherungen & Gebühren': '6300',
|
||||
'Weiterbildung': '6500',
|
||||
'Sonstiges': '6700',
|
||||
};
|
||||
|
||||
// app_settings (type 'accounting'). Account references stored as NUMBERS
|
||||
// (resilient to re-seeding); the maps are JSON keyed by tax_treatment /
|
||||
// output VAT rate.
|
||||
const SETTINGS = [
|
||||
{ key: 'ledger_account_debitoren', value: '1100' },
|
||||
{ key: 'ledger_account_kreditoren', value: '2000' },
|
||||
{ key: 'ledger_account_bank', value: '1020' },
|
||||
{ key: 'ledger_account_cash', value: '1000' },
|
||||
{ key: 'ledger_account_default_revenue', value: '3400' },
|
||||
{ key: 'ledger_account_default_expense', value: '6700' },
|
||||
{ key: 'ledger_account_mileage', value: '6200' },
|
||||
{ key: 'ledger_account_per_diem', value: '6640' },
|
||||
{ key: 'ledger_account_rebilled_revenue', value: '3940' },
|
||||
{ key: 'ledger_vat_map', value: { domestic: 'VST81', reverse_charge_service: 'BZ', foreign_vat_non_reclaimable: 'VST00', import_goods: 'VST81' } },
|
||||
{ key: 'ledger_output_vat_map', value: { '8.1': 'UN81', '2.6': 'UN26', '3.8': 'UN38', '0': 'UN00' } },
|
||||
];
|
||||
|
||||
exports.up = async function (knex) {
|
||||
// 1) ledger_accounts
|
||||
if (!(await knex.schema.hasTable('ledger_accounts'))) {
|
||||
await knex.schema.createTable('ledger_accounts', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('number', 16).notNullable();
|
||||
table.string('name', 200).notNullable();
|
||||
// asset|liability|equity|revenue|expense
|
||||
table.string('type', 16).notNullable();
|
||||
table.boolean('is_seed').notNullable().defaultTo(false);
|
||||
table.boolean('active').notNullable().defaultTo(true);
|
||||
table.integer('display_order').notNullable().defaultTo(0);
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
table.unique(['number']);
|
||||
table.index(['type']);
|
||||
});
|
||||
await knex('ledger_accounts').insert(SEED_ACCOUNTS.map((a, i) => ({
|
||||
number: a.number, name: a.name, type: a.type, is_seed: true, active: true, display_order: (i + 1) * 10,
|
||||
})));
|
||||
}
|
||||
|
||||
// Resolve account number → id for the FK references below.
|
||||
const accountRows = await knex('ledger_accounts').select('id', 'number');
|
||||
const idByNumber = new Map(accountRows.map((r) => [r.number, r.id]));
|
||||
|
||||
// 2) vat_codes
|
||||
if (!(await knex.schema.hasTable('vat_codes'))) {
|
||||
await knex.schema.createTable('vat_codes', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('code', 16).notNullable();
|
||||
table.string('name', 200).notNullable();
|
||||
table.decimal('rate', 5, 2).notNullable().defaultTo(0);
|
||||
table.string('direction', 8).notNullable(); // output|input
|
||||
table.integer('account_id').unsigned().references('id').inTable('ledger_accounts').onDelete('SET NULL');
|
||||
table.boolean('is_seed').notNullable().defaultTo(false);
|
||||
table.boolean('active').notNullable().defaultTo(true);
|
||||
table.integer('display_order').notNullable().defaultTo(0);
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
table.unique(['code']);
|
||||
table.index(['direction']);
|
||||
});
|
||||
await knex('vat_codes').insert(SEED_VAT_CODES.map((v, i) => ({
|
||||
code: v.code, name: v.name, rate: v.rate, direction: v.direction,
|
||||
account_id: v.account ? (idByNumber.get(v.account) || null) : null,
|
||||
is_seed: true, active: true, display_order: (i + 1) * 10,
|
||||
})));
|
||||
}
|
||||
|
||||
// 3) expense_categories.ledger_account_id (mapping) + seed defaults
|
||||
if (await knex.schema.hasTable('expense_categories')) {
|
||||
if (!(await knex.schema.hasColumn('expense_categories', 'ledger_account_id'))) {
|
||||
await knex.schema.alterTable('expense_categories', (table) => {
|
||||
table.integer('ledger_account_id').unsigned().references('id').inTable('ledger_accounts').onDelete('SET NULL');
|
||||
});
|
||||
}
|
||||
// Seed the category→account mapping for the seeded categories only when
|
||||
// still unset (don't clobber an admin's choice).
|
||||
const cats = await knex('expense_categories').select('id', 'name', 'ledger_account_id');
|
||||
for (const c of cats) {
|
||||
const accNum = CATEGORY_ACCOUNT_MAP[c.name];
|
||||
if (accNum && c.ledger_account_id == null && idByNumber.get(accNum)) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await knex('expense_categories').where({ id: c.id }).update({ ledger_account_id: idByNumber.get(accNum) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4) app_settings defaults (setting_key/value/type only)
|
||||
if (await knex.schema.hasTable('app_settings')) {
|
||||
for (const s of SETTINGS) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const row = await knex('app_settings').where({ setting_key: s.key }).first();
|
||||
if (!row) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await knex('app_settings').insert({
|
||||
setting_key: s.key,
|
||||
setting_value: JSON.stringify(s.value),
|
||||
setting_type: 'accounting',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
if (await knex.schema.hasTable('expense_categories') && await knex.schema.hasColumn('expense_categories', 'ledger_account_id')) {
|
||||
await knex.schema.alterTable('expense_categories', (table) => { table.dropColumn('ledger_account_id'); });
|
||||
}
|
||||
await knex.schema.dropTableIfExists('vat_codes');
|
||||
await knex.schema.dropTableIfExists('ledger_accounts');
|
||||
if (await knex.schema.hasTable('app_settings')) {
|
||||
await knex('app_settings').whereIn('setting_key', SETTINGS.map((s) => s.key)).del();
|
||||
}
|
||||
};
|
||||
@@ -707,6 +707,7 @@ app.use('/api/admin/calendar', require('./src/routes/adminCalendar'));
|
||||
app.use('/api/admin/deals', require('./src/routes/adminDeals'));
|
||||
app.use('/api/admin/tax-report', require('./src/routes/adminTaxReport'));
|
||||
app.use('/api/admin/expenses', require('./src/routes/adminExpenses'));
|
||||
app.use('/api/admin/ledger', require('./src/routes/adminLedger'));
|
||||
app.use('/api/admin/system-health', require('./src/routes/adminSystemHealth'));
|
||||
app.use('/api/admin/dev', require('./src/routes/adminDev'));
|
||||
app.use('/api/public/quotes', require('./src/routes/publicQuotes'));
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Admin → Ledger (Accounting Layer A) routes. Mounted at /api/admin/ledger.
|
||||
*
|
||||
* /accounts CRUD chart of accounts (Swiss/LI KMU-Kontenrahmen)
|
||||
* /vat-codes CRUD MWST codes
|
||||
* /mappings GET/PATCH category→account + default-account/VAT settings
|
||||
* /export GET Treuhänder collective-journal CSV (generic|banana|bexio)
|
||||
*
|
||||
* Gated by the `accounting` master flag; export additionally requires the
|
||||
* `taxReport` sub-flag (it's the export umbrella). Uses the `accounting.*`
|
||||
* permissions. Output is a GUIDELINE — the UI carries the Treuhänder caveat.
|
||||
*/
|
||||
const express = require('express');
|
||||
const { body, param, query } = require('express-validator');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||||
const { db } = require('../database/db');
|
||||
const ledgerService = require('../services/ledgerService');
|
||||
|
||||
const router = express.Router();
|
||||
const toInt = (v) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n : undefined; };
|
||||
|
||||
function requireFlag(key, code) {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
const row = await db('feature_flags').where({ key }).first();
|
||||
const enabled = row && (row.value === true || row.value === 1 || row.value === '1');
|
||||
if (!enabled) return res.status(403).json({ error: `${key} feature is disabled`, code });
|
||||
return next();
|
||||
} catch (err) { return next(err); }
|
||||
};
|
||||
}
|
||||
const requireAccounting = requireFlag('accounting', 'ACCOUNTING_DISABLED');
|
||||
const requireTaxReport = requireFlag('taxReport', 'TAX_REPORT_DISABLED');
|
||||
|
||||
router.use(adminAuth);
|
||||
router.use(requireAccounting);
|
||||
|
||||
// ── chart of accounts ────────────────────────────────────────────────
|
||||
router.get('/accounts', requirePermission('accounting.view'), handleAsync(async (_req, res) =>
|
||||
successResponse(res, { items: await ledgerService.listAccounts() })));
|
||||
|
||||
router.post('/accounts', requirePermission('accounting.manage'),
|
||||
[body('number').isString().isLength({ min: 1, max: 16 }), body('name').isString().isLength({ min: 1, max: 200 }),
|
||||
body('type').isIn(ledgerService.ACCOUNT_TYPES)],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
return successResponse(res, { account: await ledgerService.createAccount(req.body) }, 201, 'Account created');
|
||||
}));
|
||||
|
||||
router.patch('/accounts/:id', requirePermission('accounting.manage'),
|
||||
[param('id').isInt({ min: 1 }), body('type').optional().isIn(ledgerService.ACCOUNT_TYPES)],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
return successResponse(res, { account: await ledgerService.updateAccount(toInt(req.params.id), req.body) });
|
||||
}));
|
||||
|
||||
router.delete('/accounts/:id', requirePermission('accounting.manage'),
|
||||
[param('id').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
return successResponse(res, await ledgerService.deleteAccount(toInt(req.params.id)));
|
||||
}));
|
||||
|
||||
// ── VAT codes ────────────────────────────────────────────────────────
|
||||
router.get('/vat-codes', requirePermission('accounting.view'), handleAsync(async (_req, res) =>
|
||||
successResponse(res, { items: await ledgerService.listVatCodes() })));
|
||||
|
||||
router.post('/vat-codes', requirePermission('accounting.manage'),
|
||||
[body('code').isString().isLength({ min: 1, max: 16 }), body('name').isString().isLength({ min: 1, max: 200 }),
|
||||
body('rate').optional().isFloat({ min: 0 }), body('direction').isIn(ledgerService.VAT_DIRECTIONS),
|
||||
body('accountId').optional({ nullable: true }).isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
return successResponse(res, { vatCode: await ledgerService.createVatCode(req.body) }, 201, 'VAT code created');
|
||||
}));
|
||||
|
||||
router.patch('/vat-codes/:id', requirePermission('accounting.manage'),
|
||||
[param('id').isInt({ min: 1 }), body('direction').optional().isIn(ledgerService.VAT_DIRECTIONS),
|
||||
body('rate').optional().isFloat({ min: 0 }), body('accountId').optional({ nullable: true }).isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
return successResponse(res, { vatCode: await ledgerService.updateVatCode(toInt(req.params.id), req.body) });
|
||||
}));
|
||||
|
||||
router.delete('/vat-codes/:id', requirePermission('accounting.manage'),
|
||||
[param('id').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
return successResponse(res, await ledgerService.deleteVatCode(toInt(req.params.id)));
|
||||
}));
|
||||
|
||||
// ── mappings (category→account + default accounts / VAT maps) ─────────
|
||||
router.get('/mappings', requirePermission('accounting.view'), handleAsync(async (_req, res) =>
|
||||
successResponse(res, await ledgerService.getMappings())));
|
||||
|
||||
router.patch('/mappings/category/:id', requirePermission('accounting.manage'),
|
||||
[param('id').isInt({ min: 1 }), body('ledgerAccountId').optional({ nullable: true }).isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
return successResponse(res, { category: await ledgerService.setCategoryAccount(toInt(req.params.id), req.body.ledgerAccountId ?? null) });
|
||||
}));
|
||||
|
||||
router.patch('/mappings/settings', requirePermission('accounting.manage'), handleAsync(async (req, res) => {
|
||||
return successResponse(res, await ledgerService.updateSettings(req.body || {}));
|
||||
}));
|
||||
|
||||
// ── Treuhänder export ────────────────────────────────────────────────
|
||||
router.get('/export', requireTaxReport, requirePermission('bills.view'),
|
||||
[query('from').matches(/^\d{4}-\d{2}-\d{2}$/), query('to').matches(/^\d{4}-\d{2}-\d{2}$/),
|
||||
query('currency').matches(/^[A-Za-z]{3}$/), query('format').optional().isIn(ledgerService.EXPORT_FORMATS)],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const { content, filename, contentType } = await ledgerService.exportPostings({
|
||||
from: req.query.from, to: req.query.to,
|
||||
currency: String(req.query.currency).toUpperCase(),
|
||||
format: req.query.format || 'generic',
|
||||
});
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
return res.send(content);
|
||||
}));
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,445 @@
|
||||
/**
|
||||
* ledgerService — Accounting Layer A: chart of accounts + VAT codes + a
|
||||
* Treuhänder export.
|
||||
*
|
||||
* picpeak is NOT a double-entry ledger (that's Layer B). This service:
|
||||
* 1. CRUD for `ledger_accounts` (Swiss/LI KMU-Kontenrahmen) + `vat_codes`,
|
||||
* plus the category→account and tax_treatment→VAT-code mappings.
|
||||
* 2. buildPostings(): turns the data we already capture (revenue invoices,
|
||||
* incoming supplier invoices, internal expenses) into balanced
|
||||
* "Buchungssätze" — accrual-dated, single-row Soll/Haben entries with a
|
||||
* VAT code the target software expands.
|
||||
* 3. Export formatters (generic / Banana / bexio) so a Treuhänder can import
|
||||
* the collective journal.
|
||||
*
|
||||
* Accrual basis only — payment/bank postings are Layer B (bank reconciliation).
|
||||
* Legal/financial output is a GUIDELINE: every surface must point the user at a
|
||||
* Treuhänder ([[feedback_legal_financial_examples_only]]).
|
||||
*/
|
||||
|
||||
const { db, withRetry } = require('../database/db');
|
||||
const { getAppSetting } = require('../utils/appSettings');
|
||||
const { buildCustomerLabel } = require('./taxReportService')._internal;
|
||||
const { ensureInt } = require('../utils/numericHelpers');
|
||||
|
||||
const ACCOUNT_TYPES = ['asset', 'liability', 'equity', 'revenue', 'expense'];
|
||||
const VAT_DIRECTIONS = ['output', 'input'];
|
||||
|
||||
// Statuses we book. Mirrors taxReportService: cancelled originals are excluded
|
||||
// (the storno reissue, a negative-total row, carries the reversal).
|
||||
const REVENUE_STATUSES = ['sent', 'paid', 'overdue', 'pending_delivery'];
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────
|
||||
function rateKey(rate) {
|
||||
// Normalise 8.10 → '8.1', 0 → '0' so it matches the seeded output-VAT map.
|
||||
const n = Number(rate);
|
||||
if (!Number.isFinite(n)) return '0';
|
||||
return String(Number(n.toFixed(2)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve all the config the posting engine needs in one shot: account
|
||||
* lookup maps + VAT-code lookup + the default-account / VAT-mapping settings.
|
||||
*/
|
||||
async function getConfig() {
|
||||
const [accounts, vatCodes] = await Promise.all([
|
||||
db('ledger_accounts').select('id', 'number', 'name', 'type', 'active'),
|
||||
db('vat_codes').select('id', 'code', 'name', 'rate', 'direction', 'account_id', 'active'),
|
||||
]);
|
||||
const accountByNumber = new Map(accounts.map((a) => [a.number, a]));
|
||||
const accountById = new Map(accounts.map((a) => [a.id, a]));
|
||||
|
||||
const [
|
||||
debitoren, kreditoren, defaultRevenue, defaultExpense, mileage, perDiem, rebilled,
|
||||
vatMap, outputVatMap,
|
||||
] = await Promise.all([
|
||||
getAppSetting('ledger_account_debitoren', '1100'),
|
||||
getAppSetting('ledger_account_kreditoren', '2000'),
|
||||
getAppSetting('ledger_account_default_revenue', '3400'),
|
||||
getAppSetting('ledger_account_default_expense', '6700'),
|
||||
getAppSetting('ledger_account_mileage', '6200'),
|
||||
getAppSetting('ledger_account_per_diem', '6640'),
|
||||
getAppSetting('ledger_account_rebilled_revenue', '3940'),
|
||||
getAppSetting('ledger_vat_map', {}),
|
||||
getAppSetting('ledger_output_vat_map', {}),
|
||||
]);
|
||||
|
||||
return {
|
||||
accounts, vatCodes, accountByNumber, accountById,
|
||||
settings: {
|
||||
debitoren, kreditoren, defaultRevenue, defaultExpense, mileage, perDiem, rebilled,
|
||||
},
|
||||
vatMap: vatMap || {},
|
||||
outputVatMap: outputVatMap || {},
|
||||
};
|
||||
}
|
||||
|
||||
// ── CRUD: accounts ───────────────────────────────────────────────────
|
||||
async function listAccounts() {
|
||||
return db('ledger_accounts').orderBy('number', 'asc').select('*');
|
||||
}
|
||||
|
||||
async function createAccount({ number, name, type }) {
|
||||
if (!number || !name) throw httpError(400, 'number and name are required', 'VALIDATION');
|
||||
if (!ACCOUNT_TYPES.includes(type)) throw httpError(400, 'invalid account type', 'VALIDATION');
|
||||
const exists = await db('ledger_accounts').where({ number }).first();
|
||||
if (exists) throw httpError(409, 'an account with this number already exists', 'DUPLICATE');
|
||||
const [row] = await db('ledger_accounts')
|
||||
.insert({ number, name, type, is_seed: false, active: true })
|
||||
.returning('*');
|
||||
return row || db('ledger_accounts').where({ number }).first();
|
||||
}
|
||||
|
||||
async function updateAccount(id, { number, name, type, active }) {
|
||||
const patch = { updated_at: new Date() };
|
||||
if (number !== undefined) patch.number = number;
|
||||
if (name !== undefined) patch.name = name;
|
||||
if (type !== undefined) {
|
||||
if (!ACCOUNT_TYPES.includes(type)) throw httpError(400, 'invalid account type', 'VALIDATION');
|
||||
patch.type = type;
|
||||
}
|
||||
if (active !== undefined) patch.active = !!active;
|
||||
if (patch.number) {
|
||||
const clash = await db('ledger_accounts').where({ number: patch.number }).whereNot({ id }).first();
|
||||
if (clash) throw httpError(409, 'an account with this number already exists', 'DUPLICATE');
|
||||
}
|
||||
await db('ledger_accounts').where({ id }).update(patch);
|
||||
return db('ledger_accounts').where({ id }).first();
|
||||
}
|
||||
|
||||
/** Hard-delete only when nothing references the account; otherwise tell the
|
||||
* caller to deactivate instead (keeps mappings + exports stable). */
|
||||
async function deleteAccount(id) {
|
||||
const acct = await db('ledger_accounts').where({ id }).first();
|
||||
if (!acct) throw httpError(404, 'account not found', 'NOT_FOUND');
|
||||
const refs = await accountReferences(acct);
|
||||
if (refs.length) throw httpError(409, `account is in use (${refs.join(', ')}) — deactivate it instead`, 'IN_USE');
|
||||
await db('ledger_accounts').where({ id }).del();
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
async function accountReferences(acct) {
|
||||
const refs = [];
|
||||
const vat = await db('vat_codes').where({ account_id: acct.id }).first();
|
||||
if (vat) refs.push('VAT code');
|
||||
if (await db.schema.hasColumn('expense_categories', 'ledger_account_id')) {
|
||||
const cat = await db('expense_categories').where({ ledger_account_id: acct.id }).first();
|
||||
if (cat) refs.push('expense category');
|
||||
}
|
||||
// Default-account settings reference accounts by NUMBER.
|
||||
const settingKeys = ['ledger_account_debitoren', 'ledger_account_kreditoren', 'ledger_account_bank',
|
||||
'ledger_account_cash', 'ledger_account_default_revenue', 'ledger_account_default_expense',
|
||||
'ledger_account_mileage', 'ledger_account_per_diem', 'ledger_account_rebilled_revenue'];
|
||||
const settingRows = await db('app_settings').whereIn('setting_key', settingKeys).select('setting_value');
|
||||
if (settingRows.some((r) => safeParse(r.setting_value) === acct.number)) refs.push('default-account setting');
|
||||
return refs;
|
||||
}
|
||||
|
||||
// ── CRUD: VAT codes ──────────────────────────────────────────────────
|
||||
async function listVatCodes() {
|
||||
return db('vat_codes').orderBy('display_order', 'asc').select('*');
|
||||
}
|
||||
|
||||
async function createVatCode({ code, name, rate, direction, accountId }) {
|
||||
if (!code || !name) throw httpError(400, 'code and name are required', 'VALIDATION');
|
||||
if (!VAT_DIRECTIONS.includes(direction)) throw httpError(400, 'invalid direction', 'VALIDATION');
|
||||
const exists = await db('vat_codes').where({ code }).first();
|
||||
if (exists) throw httpError(409, 'a VAT code with this code already exists', 'DUPLICATE');
|
||||
const [row] = await db('vat_codes')
|
||||
.insert({ code, name, rate: Number(rate) || 0, direction, account_id: accountId || null, is_seed: false, active: true })
|
||||
.returning('*');
|
||||
return row || db('vat_codes').where({ code }).first();
|
||||
}
|
||||
|
||||
async function updateVatCode(id, { code, name, rate, direction, accountId, active }) {
|
||||
const patch = { updated_at: new Date() };
|
||||
if (code !== undefined) patch.code = code;
|
||||
if (name !== undefined) patch.name = name;
|
||||
if (rate !== undefined) patch.rate = Number(rate) || 0;
|
||||
if (direction !== undefined) {
|
||||
if (!VAT_DIRECTIONS.includes(direction)) throw httpError(400, 'invalid direction', 'VALIDATION');
|
||||
patch.direction = direction;
|
||||
}
|
||||
if (accountId !== undefined) patch.account_id = accountId || null;
|
||||
if (active !== undefined) patch.active = !!active;
|
||||
if (patch.code) {
|
||||
const clash = await db('vat_codes').where({ code: patch.code }).whereNot({ id }).first();
|
||||
if (clash) throw httpError(409, 'a VAT code with this code already exists', 'DUPLICATE');
|
||||
}
|
||||
await db('vat_codes').where({ id }).update(patch);
|
||||
return db('vat_codes').where({ id }).first();
|
||||
}
|
||||
|
||||
async function deleteVatCode(id) {
|
||||
const vat = await db('vat_codes').where({ id }).first();
|
||||
if (!vat) throw httpError(404, 'VAT code not found', 'NOT_FOUND');
|
||||
// Referenced by the tax_treatment / output-rate maps?
|
||||
const [vatMap, outputVatMap] = await Promise.all([
|
||||
getAppSetting('ledger_vat_map', {}), getAppSetting('ledger_output_vat_map', {}),
|
||||
]);
|
||||
const used = Object.values(vatMap || {}).includes(vat.code) || Object.values(outputVatMap || {}).includes(vat.code);
|
||||
if (used) throw httpError(409, 'VAT code is referenced by a mapping — change the mapping first', 'IN_USE');
|
||||
await db('vat_codes').where({ id }).del();
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
// ── mappings (categories + settings) ─────────────────────────────────
|
||||
async function getMappings() {
|
||||
const hasCol = await db.schema.hasColumn('expense_categories', 'ledger_account_id');
|
||||
const categories = await db('expense_categories')
|
||||
.orderBy('display_order', 'asc')
|
||||
.select('id', 'name', 'color', hasCol ? 'ledger_account_id' : db.raw('NULL as ledger_account_id'));
|
||||
const settingKeys = ['ledger_account_debitoren', 'ledger_account_kreditoren', 'ledger_account_bank',
|
||||
'ledger_account_cash', 'ledger_account_default_revenue', 'ledger_account_default_expense',
|
||||
'ledger_account_mileage', 'ledger_account_per_diem', 'ledger_account_rebilled_revenue',
|
||||
'ledger_vat_map', 'ledger_output_vat_map'];
|
||||
const rows = await db('app_settings').whereIn('setting_key', settingKeys).select('setting_key', 'setting_value');
|
||||
const settings = {};
|
||||
for (const r of rows) settings[r.setting_key] = safeParse(r.setting_value);
|
||||
return { categories, settings };
|
||||
}
|
||||
|
||||
async function setCategoryAccount(categoryId, ledgerAccountId) {
|
||||
if (!(await db.schema.hasColumn('expense_categories', 'ledger_account_id'))) {
|
||||
throw httpError(409, 'category→account mapping column missing', 'SCHEMA');
|
||||
}
|
||||
await db('expense_categories').where({ id: categoryId }).update({ ledger_account_id: ledgerAccountId || null });
|
||||
return db('expense_categories').where({ id: categoryId }).first();
|
||||
}
|
||||
|
||||
/** Update the ledger_* app_settings (default accounts + VAT maps). Only
|
||||
* whitelisted keys; values stored JSON-stringified (matching the store). */
|
||||
async function updateSettings(patch) {
|
||||
const allowed = new Set(['ledger_account_debitoren', 'ledger_account_kreditoren', 'ledger_account_bank',
|
||||
'ledger_account_cash', 'ledger_account_default_revenue', 'ledger_account_default_expense',
|
||||
'ledger_account_mileage', 'ledger_account_per_diem', 'ledger_account_rebilled_revenue',
|
||||
'ledger_vat_map', 'ledger_output_vat_map']);
|
||||
const updated = [];
|
||||
for (const [key, value] of Object.entries(patch || {})) {
|
||||
if (!allowed.has(key)) continue;
|
||||
const existing = await db('app_settings').where({ setting_key: key }).first();
|
||||
if (existing) {
|
||||
await db('app_settings').where({ setting_key: key }).update({ setting_value: JSON.stringify(value) });
|
||||
} else {
|
||||
await db('app_settings').insert({ setting_key: key, setting_value: JSON.stringify(value), setting_type: 'accounting' });
|
||||
}
|
||||
updated.push(key);
|
||||
}
|
||||
return { updated };
|
||||
}
|
||||
|
||||
// ── posting engine ───────────────────────────────────────────────────
|
||||
/**
|
||||
* Build the accrual collective journal for [from, to] in `cur`.
|
||||
*
|
||||
* Returns { postings, currency, period }. Each posting is a single
|
||||
* Buchungssatz:
|
||||
* { date, docNumber, description, debitAccount, debitName, creditAccount,
|
||||
* creditName, grossMinor, netMinor, vatMinor, vatCode, vatRate, source,
|
||||
* eventName }
|
||||
* Amount is GROSS (the VAT code lets the target software expand net+VAT).
|
||||
* `netMinor`/`vatMinor` are included for tooling that imports net amounts.
|
||||
*/
|
||||
async function buildPostings({ from, to, currency } = {}) {
|
||||
if (!from || !to) throw httpError(400, '`from` and `to` are required (YYYY-MM-DD)', 'VALIDATION');
|
||||
if (!currency) throw httpError(400, '`currency` is required', 'VALIDATION');
|
||||
const cur = String(currency).toUpperCase();
|
||||
|
||||
return withRetry(async () => {
|
||||
const cfg = await getConfig();
|
||||
const nameOf = (number) => cfg.accountByNumber.get(number)?.name || '';
|
||||
const postings = [];
|
||||
|
||||
// 1) Revenue invoices → Dr Debitoren / Cr Ertrag (gross, output VAT code).
|
||||
const invoices = await db('invoices')
|
||||
.leftJoin('customer_accounts', 'invoices.customer_account_id', 'customer_accounts.id')
|
||||
.leftJoin('events', 'invoices.event_id', 'events.id')
|
||||
.whereBetween('invoices.issue_date', [from, to])
|
||||
.where('invoices.currency', cur)
|
||||
.whereIn('invoices.status', REVENUE_STATUSES)
|
||||
.orderBy('invoices.issue_date', 'asc')
|
||||
.select(
|
||||
'invoices.id', 'invoices.invoice_number', 'invoices.issue_date', 'invoices.vat_rate',
|
||||
'invoices.net_amount_minor', 'invoices.vat_amount_minor', 'invoices.total_amount_minor',
|
||||
'customer_accounts.company_name as customer_company_name',
|
||||
'customer_accounts.first_name as customer_first_name',
|
||||
'customer_accounts.last_name as customer_last_name',
|
||||
'customer_accounts.display_name as customer_display_name',
|
||||
'customer_accounts.email as customer_email',
|
||||
db.raw('COALESCE(invoices.event_name, events.event_name) AS event_name'),
|
||||
);
|
||||
for (const inv of invoices) {
|
||||
const revAcct = cfg.settings.defaultRevenue;
|
||||
const vatCode = cfg.outputVatMap[rateKey(inv.vat_rate)] || '';
|
||||
const label = buildCustomerLabel(inv);
|
||||
postings.push({
|
||||
date: inv.issue_date,
|
||||
docNumber: inv.invoice_number || '',
|
||||
description: [inv.invoice_number, label].filter(Boolean).join(' · '),
|
||||
debitAccount: cfg.settings.debitoren, debitName: nameOf(cfg.settings.debitoren),
|
||||
creditAccount: revAcct, creditName: nameOf(revAcct),
|
||||
grossMinor: ensureInt(inv.total_amount_minor),
|
||||
netMinor: ensureInt(inv.net_amount_minor),
|
||||
vatMinor: ensureInt(inv.vat_amount_minor),
|
||||
vatCode, vatRate: Number(inv.vat_rate) || 0,
|
||||
source: 'revenue', eventName: inv.event_name || '',
|
||||
});
|
||||
}
|
||||
|
||||
// 2) Incoming supplier invoices → Dr Aufwand / Cr Kreditoren (input VAT).
|
||||
if (await db.schema.hasTable('inbound_documents')) {
|
||||
const hasCatCol = await db.schema.hasColumn('expense_categories', 'ledger_account_id');
|
||||
const inbound = await db('inbound_documents')
|
||||
.leftJoin('events', 'inbound_documents.event_id', 'events.id')
|
||||
.modify((q) => {
|
||||
if (hasCatCol) q.leftJoin('expense_categories', 'inbound_documents.category_id', 'expense_categories.id');
|
||||
})
|
||||
.whereRaw('date(COALESCE(inbound_documents.invoice_date, inbound_documents.created_at)) BETWEEN ? AND ?', [from, to])
|
||||
.where('inbound_documents.currency', cur)
|
||||
.whereNotIn('inbound_documents.status', ['declined', 'duplicate'])
|
||||
.orderByRaw('COALESCE(inbound_documents.invoice_date, inbound_documents.created_at) asc')
|
||||
.select(
|
||||
'inbound_documents.id', 'inbound_documents.invoice_number', 'inbound_documents.invoice_date',
|
||||
'inbound_documents.created_at', 'inbound_documents.supplier_name', 'inbound_documents.tax_treatment',
|
||||
'inbound_documents.net_amount_minor', 'inbound_documents.vat_amount_minor', 'inbound_documents.total_amount_minor',
|
||||
'inbound_documents.event_id',
|
||||
hasCatCol ? 'expense_categories.ledger_account_id as cat_account_id' : db.raw('NULL as cat_account_id'),
|
||||
'events.event_name as event_name',
|
||||
);
|
||||
for (const d of inbound) {
|
||||
const acctNumber = cfg.accountById.get(d.cat_account_id)?.number || cfg.settings.defaultExpense;
|
||||
const vatCode = cfg.vatMap[d.tax_treatment || 'domestic'] || '';
|
||||
const gross = ensureInt(d.total_amount_minor) || (ensureInt(d.net_amount_minor) + ensureInt(d.vat_amount_minor));
|
||||
postings.push({
|
||||
date: d.invoice_date || d.created_at,
|
||||
docNumber: d.invoice_number || '',
|
||||
description: [d.supplier_name, d.invoice_number].filter(Boolean).join(' · '),
|
||||
debitAccount: acctNumber, debitName: nameOf(acctNumber),
|
||||
creditAccount: cfg.settings.kreditoren, creditName: nameOf(cfg.settings.kreditoren),
|
||||
grossMinor: gross,
|
||||
netMinor: ensureInt(d.net_amount_minor) || (gross - ensureInt(d.vat_amount_minor)),
|
||||
vatMinor: ensureInt(d.vat_amount_minor),
|
||||
vatCode, vatRate: 0,
|
||||
source: 'incoming', eventName: d.event_id ? (d.event_name || '') : '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Internal expenses → Dr Aufwand / Cr Kreditoren (input VAT).
|
||||
if (await db.schema.hasTable('expenses')) {
|
||||
const hasCatCol = await db.schema.hasColumn('expense_categories', 'ledger_account_id');
|
||||
const expenses = await db('expenses')
|
||||
.leftJoin('events', 'expenses.event_id', 'events.id')
|
||||
.modify((q) => {
|
||||
if (hasCatCol) q.leftJoin('expense_categories', 'expenses.category_id', 'expense_categories.id');
|
||||
})
|
||||
.whereRaw('date(expenses.created_at) BETWEEN ? AND ?', [from, to])
|
||||
.whereNot('expenses.status', 'declined')
|
||||
.whereNotIn('expenses.disposition', ['duplikat', 'abgelehnt'])
|
||||
.modify((q) => { if (cur !== 'CHF') q.where('expenses.original_currency', cur); })
|
||||
.orderBy('expenses.created_at', 'asc')
|
||||
.select(
|
||||
'expenses.id', 'expenses.created_at', 'expenses.kind', 'expenses.supplier_name', 'expenses.description',
|
||||
'expenses.tax_treatment', 'expenses.event_id',
|
||||
'expenses.original_amount_minor', 'expenses.chf_amount_minor',
|
||||
'expenses.net_amount_minor', 'expenses.vat_amount_minor', 'expenses.gross_amount_minor',
|
||||
hasCatCol ? 'expense_categories.ledger_account_id as cat_account_id' : db.raw('NULL as cat_account_id'),
|
||||
'events.event_name as event_name',
|
||||
);
|
||||
const isChf = cur === 'CHF';
|
||||
for (const e of expenses) {
|
||||
// Account: category mapping → kind default (mileage/per-diem) → default expense.
|
||||
let acctNumber = cfg.accountById.get(e.cat_account_id)?.number;
|
||||
if (!acctNumber && e.kind === 'mileage') acctNumber = cfg.settings.mileage;
|
||||
if (!acctNumber && e.kind === 'per_diem') acctNumber = cfg.settings.perDiem;
|
||||
if (!acctNumber) acctNumber = cfg.settings.defaultExpense;
|
||||
const vatCode = cfg.vatMap[e.tax_treatment || 'domestic'] || '';
|
||||
const base = isChf ? ensureInt(e.chf_amount_minor) : ensureInt(e.original_amount_minor);
|
||||
const gross = ensureInt(e.gross_amount_minor) || ((ensureInt(e.net_amount_minor) || ensureInt(e.vat_amount_minor)) ? ensureInt(e.net_amount_minor) + ensureInt(e.vat_amount_minor) : base);
|
||||
postings.push({
|
||||
date: e.created_at,
|
||||
docNumber: `EXP-${e.id}`,
|
||||
description: e.description || e.supplier_name || `Expense #${e.id}`,
|
||||
debitAccount: acctNumber, debitName: nameOf(acctNumber),
|
||||
creditAccount: cfg.settings.kreditoren, creditName: nameOf(cfg.settings.kreditoren),
|
||||
grossMinor: gross,
|
||||
netMinor: ensureInt(e.net_amount_minor) || (gross - ensureInt(e.vat_amount_minor)),
|
||||
vatMinor: ensureInt(e.vat_amount_minor),
|
||||
vatCode, vatRate: 0,
|
||||
source: 'expense', eventName: e.event_id ? (e.event_name || '') : '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
postings.sort((a, b) => String(a.date || '').localeCompare(String(b.date || '')));
|
||||
return { postings, currency: cur, period: { from, to } };
|
||||
});
|
||||
}
|
||||
|
||||
// ── export formatters ────────────────────────────────────────────────
|
||||
function csvEscape(cell) {
|
||||
const s = cell === null || cell === undefined ? '' : String(cell);
|
||||
return `"${s.replace(/"/g, '""')}"`;
|
||||
}
|
||||
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'];
|
||||
|
||||
/**
|
||||
* Render the collective journal in the requested format. Returns
|
||||
* { content, filename, contentType }. All are single-row Soll/Haben
|
||||
* ("two-account") layouts with a VAT-code column — the universal Swiss
|
||||
* import shape Banana + bexio both accept.
|
||||
*/
|
||||
async function exportPostings({ from, to, currency, format = 'generic' } = {}) {
|
||||
const fmt = EXPORT_FORMATS.includes(format) ? format : 'generic';
|
||||
const { postings, currency: cur, period } = await buildPostings({ from, to, currency });
|
||||
const eol = '\r\n';
|
||||
let headers; let rowOf;
|
||||
|
||||
if (fmt === 'banana') {
|
||||
// Banana "Conti doppia" import: Date, Doc, Description, AccountDebit,
|
||||
// AccountCredit, Amount, VatCode. Amount = gross; VatCode expands VAT.
|
||||
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 === 'bexio') {
|
||||
// bexio manual-entry import.
|
||||
headers = ['date', 'reference_nr', 'description', 'debit_account', 'credit_account', 'amount', 'tax_code', 'currency'];
|
||||
rowOf = (p) => [dateOnly(p.date), p.docNumber, p.description, p.debitAccount, p.creditAccount, minorToDecimal(p.grossMinor), p.vatCode, cur];
|
||||
} else {
|
||||
// Generic — every column a human or any tool could want.
|
||||
headers = ['Date', 'DocNumber', 'Description', 'Source', 'Event',
|
||||
'DebitAccount', 'DebitAccountName', 'CreditAccount', 'CreditAccountName',
|
||||
'VatCode', 'Currency', 'GrossAmount', 'NetAmount', 'VatAmount'];
|
||||
rowOf = (p) => [dateOnly(p.date), p.docNumber, p.description, p.source, p.eventName,
|
||||
p.debitAccount, p.debitName, p.creditAccount, p.creditName,
|
||||
p.vatCode, cur, minorToDecimal(p.grossMinor), minorToDecimal(p.netMinor), minorToDecimal(p.vatMinor)];
|
||||
}
|
||||
|
||||
const lines = [headers.map(csvEscape).join(',')];
|
||||
for (const p of postings) lines.push(rowOf(p).map(csvEscape).join(','));
|
||||
const content = lines.join(eol) + eol;
|
||||
const filename = `journal_${period.from}_to_${period.to}_${cur}_${fmt}.csv`;
|
||||
return { content, filename, contentType: 'text/csv; charset=utf-8', count: postings.length };
|
||||
}
|
||||
|
||||
// ── small util ───────────────────────────────────────────────────────
|
||||
function httpError(status, message, code) {
|
||||
const err = new Error(message);
|
||||
err.status = status; err.statusCode = status; err.code = code;
|
||||
return err;
|
||||
}
|
||||
function safeParse(v) {
|
||||
if (v == null) return null;
|
||||
try { return JSON.parse(v); } catch (_) { return v; }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ACCOUNT_TYPES, VAT_DIRECTIONS, EXPORT_FORMATS,
|
||||
listAccounts, createAccount, updateAccount, deleteAccount,
|
||||
listVatCodes, createVatCode, updateVatCode, deleteVatCode,
|
||||
getMappings, setCategoryAccount, updateSettings,
|
||||
getConfig, buildPostings, exportPostings,
|
||||
_internal: { rateKey, csvEscape, minorToDecimal },
|
||||
};
|
||||
Reference in New Issue
Block a user