diff --git a/backend/__tests__/services/ledgerService.test.js b/backend/__tests__/services/ledgerService.test.js new file mode 100644 index 00000000..061588b2 --- /dev/null +++ b/backend/__tests__/services/ledgerService.test.js @@ -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$/); + }); +}); diff --git a/backend/migrations/core/129_create_ledger_accounts_and_vat_codes.js b/backend/migrations/core/129_create_ledger_accounts_and_vat_codes.js new file mode 100644 index 00000000..4678cb63 --- /dev/null +++ b/backend/migrations/core/129_create_ledger_accounts_and_vat_codes.js @@ -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(); + } +}; diff --git a/backend/server.js b/backend/server.js index 1327d18d..ee4c1b3d 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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')); diff --git a/backend/src/routes/adminLedger.js b/backend/src/routes/adminLedger.js new file mode 100644 index 00000000..f279750a --- /dev/null +++ b/backend/src/routes/adminLedger.js @@ -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; diff --git a/backend/src/services/ledgerService.js b/backend/src/services/ledgerService.js new file mode 100644 index 00000000..cd78d5fa --- /dev/null +++ b/backend/src/services/ledgerService.js @@ -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 }, +};