From 0a7dc1cf5da17a5bef204f6680844c8ab2b44269 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Mon, 15 Jun 2026 23:53:32 +0200 Subject: [PATCH] feat(accounting): snapshot vat_code on quotes/invoices + export prefers it (foundation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of the VAT-consolidation: migration 130 adds a nullable vat_code snapshot column to quotes + invoices, and the Treuhänder export now prefers the invoice's snapshotted code over the (mutable) rate→code map, so a historical invoice's VatCode never changes when codes are re-mapped. Schema-drift guarded; behaviour-neutral until the editors start writing the snapshot (next slices). Part of: VAT registry → Settings→Accounting, invoice VAT dropdown, registration/ reclaim toggle. --- .../core/130_add_vat_code_snapshot.js | 36 +++++++++++++++++++ backend/src/services/ledgerService.js | 7 +++- 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 backend/migrations/core/130_add_vat_code_snapshot.js diff --git a/backend/migrations/core/130_add_vat_code_snapshot.js b/backend/migrations/core/130_add_vat_code_snapshot.js new file mode 100644 index 00000000..b52d5529 --- /dev/null +++ b/backend/migrations/core/130_add_vat_code_snapshot.js @@ -0,0 +1,36 @@ +/** + * Migration: snapshot the chosen VAT code on quotes + invoices. + * + * The invoice/quote editors now pick an output VAT code from the central + * vat_codes registry (Settings → Accounting) instead of free-typing a rate. + * We snapshot the CODE STRING (e.g. "UN81") on the document at create time — + * alongside the existing vat_rate — so the Treuhänder/accounting export emits + * exactly the code the document was issued with, immutably. Editing or deleting + * a vat_codes row later never changes a historical document's export code. + * + * quotes.vat_code nullable string (snapshot) + * invoices.vat_code nullable string (snapshot) + * + * Legacy rows stay null; the export falls back to the rate→code map for those. + * Idempotent: columns guarded. + */ + +exports.up = async function (knex) { + for (const tbl of ['quotes', 'invoices']) { + if ((await knex.schema.hasTable(tbl)) && !(await knex.schema.hasColumn(tbl, 'vat_code'))) { + await knex.schema.alterTable(tbl, (table) => { + table.string('vat_code', 16); + }); + } + } +}; + +exports.down = async function (knex) { + for (const tbl of ['quotes', 'invoices']) { + if ((await knex.schema.hasTable(tbl)) && (await knex.schema.hasColumn(tbl, 'vat_code'))) { + await knex.schema.alterTable(tbl, (table) => { + table.dropColumn('vat_code'); + }); + } + } +}; diff --git a/backend/src/services/ledgerService.js b/backend/src/services/ledgerService.js index 5e4dfa05..35f0a560 100644 --- a/backend/src/services/ledgerService.js +++ b/backend/src/services/ledgerService.js @@ -254,6 +254,8 @@ async function buildPostings({ from, to, currency } = {}) { const postings = []; // 1) Revenue invoices → Dr Debitoren / Cr Ertrag (gross, output VAT code). + // Migration 130 — prefer the snapshotted vat_code; guard for pre-130 DBs. + const hasInvVatCode = await db.schema.hasColumn('invoices', 'vat_code'); const invoices = await db('invoices') .leftJoin('customer_accounts', 'invoices.customer_account_id', 'customer_accounts.id') .leftJoin('events', 'invoices.event_id', 'events.id') @@ -263,6 +265,7 @@ async function buildPostings({ from, to, currency } = {}) { .orderBy('invoices.issue_date', 'asc') .select( 'invoices.id', 'invoices.invoice_number', 'invoices.issue_date', 'invoices.vat_rate', + ...(hasInvVatCode ? ['invoices.vat_code'] : []), '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', @@ -273,7 +276,9 @@ async function buildPostings({ from, to, currency } = {}) { ); for (const inv of invoices) { const revAcct = cfg.settings.defaultRevenue; - const vatCode = cfg.outputVatMap[rateKey(inv.vat_rate)] || ''; + // Prefer the snapshot taken at issue time; legacy rows fall back to the + // (mutable) rate→code map. + const vatCode = inv.vat_code || cfg.outputVatMap[rateKey(inv.vat_rate)] || ''; const label = buildCustomerLabel(inv); postings.push({ date: inv.issue_date,