From c59df52d407fec86168aa7a2087c1a29c7660ab8 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:24:53 +0200 Subject: [PATCH] feat(accounting): split Incoming invoices vs Expenses - flags, schema, settings (stage 1) Foundation for separating external supplier invoices from internal expenses, per design review. This stage is additive + buildable; the service/route/UI data rework follows in stage 2. - Migration 126: incoming invoices own their payable on inbound_documents (supplier_paid/at/method/ref + disposition + tax_treatment + booking event_id + category_id + re-bill markup/linkage); expenses gain kind (amount/mileage/ per_diem) + quantity + snapshotted rate_minor. Additive, hasColumn-guarded. - Migration 127: seed `expenses` feature flag (default off) + accounting app_settings (accounting_km_rate_minor=70, accounting_per_diem_rate_minor=0, accounting_require_proof=false). - Backend: `expenses` added to feature-flag known/defaults/dependency (forced off when the accounting master is off); new PUT /admin/settings/accounting (read via the generic GET /:type). - Frontend: `expenses` flag (type + context + dependency); Features tab gets an Expenses sub-card; the Expenses sub-nav + route now gate on `expenses` (not incomingInvoices); AccountingIndex prefers inbox -> expenses -> tax. - i18n: settings.features.expenses.* (EN + DE). Verified: node -c; migration 124->126->127 harness (new columns, flag, settings + idempotency); en/de JSON valid; npm run build green. --- ...6_accounting_split_incoming_vs_expenses.js | 68 +++++++++++++++++++ ...d_expenses_flag_and_accounting_settings.js | 52 ++++++++++++++ backend/src/routes/adminFeatureFlags.js | 11 ++- backend/src/routes/adminSettings.js | 38 +++++++++++ frontend/src/App.tsx | 2 + .../src/components/admin/AccountingLayout.tsx | 3 +- frontend/src/contexts/FeatureFlagsContext.tsx | 8 ++- .../features/settings/tabs/FeaturesTab.tsx | 20 ++++++ frontend/src/i18n/locales/de.json | 6 ++ frontend/src/i18n/locales/en.json | 6 ++ frontend/src/services/featureFlags.service.ts | 9 ++- 11 files changed, 214 insertions(+), 9 deletions(-) create mode 100644 backend/migrations/core/126_accounting_split_incoming_vs_expenses.js create mode 100644 backend/migrations/core/127_seed_expenses_flag_and_accounting_settings.js diff --git a/backend/migrations/core/126_accounting_split_incoming_vs_expenses.js b/backend/migrations/core/126_accounting_split_incoming_vs_expenses.js new file mode 100644 index 00000000..87ca88ff --- /dev/null +++ b/backend/migrations/core/126_accounting_split_incoming_vs_expenses.js @@ -0,0 +1,68 @@ +/** + * Migration 126: split Incoming invoices (external) from Expenses (internal). + * + * Incoming invoices now own their payable + disposition + re-bill on the + * `inbound_documents` row itself (no derived `expenses` row), so a supplier + * invoice lives ONLY in the inbox/incoming-invoices surface. The `expenses` + * table becomes internal-only (mileage / per-diem / cash with proof). + * + * Both can be booked to an event (event_id) or to the company (event_id NULL). + * + * Additive + hasColumn-guarded so it runs forward cleanly on dev (122-125 are + * already deployed there — no in-place edits). + */ +async function addColumn(knex, table, column, builder) { + // eslint-disable-next-line no-await-in-loop + if (!(await knex.schema.hasColumn(table, column))) { + await knex.schema.alterTable(table, builder); + } +} + +exports.up = async function (knex) { + if (await knex.schema.hasTable('inbound_documents')) { + // Disposition + classification (now stored on the document itself). + await addColumn(knex, 'inbound_documents', 'disposition', (t) => t.string('disposition', 24)); + await addColumn(knex, 'inbound_documents', 'tax_treatment', (t) => t.string('tax_treatment', 32)); + await addColumn(knex, 'inbound_documents', 'category_id', (t) => t.integer('category_id').unsigned()); + // Booking target: event_id NULL = booked to the company. + await addColumn(knex, 'inbound_documents', 'event_id', (t) => t.integer('event_id').unsigned()); + // Re-bill (Weiterverrechnung) linkage + markup. + await addColumn(knex, 'inbound_documents', 'markup_type', (t) => t.string('markup_type', 8)); + await addColumn(knex, 'inbound_documents', 'markup_percent', (t) => t.decimal('markup_percent', 5, 2)); + await addColumn(knex, 'inbound_documents', 'markup_flat_minor', (t) => t.integer('markup_flat_minor')); + await addColumn(knex, 'inbound_documents', 'billed_invoice_id', (t) => t.integer('billed_invoice_id').unsigned()); + await addColumn(knex, 'inbound_documents', 'billed_invoice_line_item_id', (t) => t.integer('billed_invoice_line_item_id').unsigned()); + // Supplier payment (the payable is paid HERE, on the incoming invoice). + await addColumn(knex, 'inbound_documents', 'supplier_paid', (t) => t.boolean('supplier_paid').notNullable().defaultTo(false)); + await addColumn(knex, 'inbound_documents', 'supplier_paid_at', (t) => t.timestamp('supplier_paid_at')); + await addColumn(knex, 'inbound_documents', 'supplier_payment_method', (t) => t.string('supplier_payment_method', 16)); + await addColumn(knex, 'inbound_documents', 'supplier_payment_ref', (t) => t.string('supplier_payment_ref', 140)); + } + + if (await knex.schema.hasTable('expenses')) { + // Internal-expense kind + quantity-driven amount (mileage / per-diem). + await addColumn(knex, 'expenses', 'kind', (t) => t.string('kind', 16).notNullable().defaultTo('amount')); // amount|mileage|per_diem + await addColumn(knex, 'expenses', 'quantity', (t) => t.decimal('quantity', 10, 2)); // km count or number of days + await addColumn(knex, 'expenses', 'rate_minor', (t) => t.integer('rate_minor')); // snapshotted km/day rate + } +}; + +exports.down = async function (knex) { + const dropCols = async (table, cols) => { + if (!(await knex.schema.hasTable(table))) return; + for (const col of cols) { + // eslint-disable-next-line no-await-in-loop + if (await knex.schema.hasColumn(table, col)) { + // eslint-disable-next-line no-await-in-loop + await knex.schema.alterTable(table, (t) => t.dropColumn(col)); + } + } + }; + await dropCols('inbound_documents', [ + 'disposition', 'tax_treatment', 'category_id', 'event_id', + 'markup_type', 'markup_percent', 'markup_flat_minor', + 'billed_invoice_id', 'billed_invoice_line_item_id', + 'supplier_paid', 'supplier_paid_at', 'supplier_payment_method', 'supplier_payment_ref', + ]); + await dropCols('expenses', ['kind', 'quantity', 'rate_minor']); +}; diff --git a/backend/migrations/core/127_seed_expenses_flag_and_accounting_settings.js b/backend/migrations/core/127_seed_expenses_flag_and_accounting_settings.js new file mode 100644 index 00000000..3931acc4 --- /dev/null +++ b/backend/migrations/core/127_seed_expenses_flag_and_accounting_settings.js @@ -0,0 +1,52 @@ +/** + * Migration 127: seed the `expenses` feature flag + the Accounting settings. + * + * - `expenses` feature flag (default OFF) — separate sub-toggle from + * `incomingInvoices` under the Accounting master. + * - app_settings (setting_type='accounting'): + * accounting_km_rate_minor default 70 (CHF 0.70 / km — VERIFY with + * your Treuhänder, guideline only) + * accounting_per_diem_rate_minor default 0 (admin sets a daily rate) + * accounting_require_proof default false (require a proof file on + * internal expenses) + * + * Idempotent: inserts only when missing. + */ +const ACCOUNTING_SETTINGS = [ + { key: 'accounting_km_rate_minor', value: 70 }, + { key: 'accounting_per_diem_rate_minor', value: 0 }, + { key: 'accounting_require_proof', value: false }, +]; + +exports.up = async function (knex) { + if (await knex.schema.hasTable('feature_flags')) { + const existing = await knex('feature_flags').where({ key: 'expenses' }).first(); + if (!existing) await knex('feature_flags').insert({ key: 'expenses', value: false }); + } + + if (await knex.schema.hasTable('app_settings')) { + for (const s of ACCOUNTING_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', + created_at: new Date(), + updated_at: new Date(), + }); + } + } + } +}; + +exports.down = async function (knex) { + if (await knex.schema.hasTable('feature_flags')) { + await knex('feature_flags').where({ key: 'expenses' }).del(); + } + if (await knex.schema.hasTable('app_settings')) { + await knex('app_settings').whereIn('setting_key', ACCOUNTING_SETTINGS.map((s) => s.key)).del(); + } +}; diff --git a/backend/src/routes/adminFeatureFlags.js b/backend/src/routes/adminFeatureFlags.js index a17c4183..03508a18 100644 --- a/backend/src/routes/adminFeatureFlags.js +++ b/backend/src/routes/adminFeatureFlags.js @@ -66,10 +66,13 @@ const KNOWN_FLAGS = [ // supplier invoices, expenses + re-bill, and the tax report (which // relocates here from CRM when this is on). Strictly opt-in. 'accounting', - // Incoming invoices (migration 124) — supplier-invoice capture + - // expenses + re-bill. Accounting sub-feature; forced off when the - // `accounting` master is off. + // Incoming invoices (migration 124) — external supplier-invoice capture + + // re-bill. Accounting sub-feature; forced off when the `accounting` master + // is off. 'incomingInvoices', + // Expenses (migration 127) — internal expenses (mileage / per-diem / cash). + // Separate Accounting sub-feature; forced off when `accounting` is off. + 'expenses', ]; // Spec defaults for any flag missing from the DB (e.g. a row added by a @@ -94,6 +97,7 @@ const DEFAULT_FLAGS = { contracts: false, accounting: false, incomingInvoices: false, + expenses: false, }; async function readAllFlags() { @@ -120,6 +124,7 @@ function applyDependencyRules(flags) { if (out.accounting === false) { out.taxReport = false; out.incomingInvoices = false; + out.expenses = false; } // Clients parent flag is DERIVED from its children. Admins don't // toggle it directly in the Features tab — they enable a specific diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index 66898e16..674c7835 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -234,6 +234,44 @@ router.put('/customer-surface', adminAuth, requirePermission('settings.edit'), a } }); +// Accounting settings (km rate, per-diem rate, require-proof). Read via the +// generic GET /:type ('accounting'); this is the typed write. Rates are +// integer minor units; verify legal/tax guidance with a Treuhaender. +router.put('/accounting', adminAuth, requirePermission('settings.edit'), async (req, res) => { + try { + const updates = []; + const setInt = (key) => { + if (Object.prototype.hasOwnProperty.call(req.body, key)) { + const n = Math.max(0, Math.round(Number(req.body[key]) || 0)); + updates.push({ setting_key: key, setting_value: JSON.stringify(n), setting_type: 'accounting' }); + } + }; + setInt('accounting_km_rate_minor'); + setInt('accounting_per_diem_rate_minor'); + if (Object.prototype.hasOwnProperty.call(req.body, 'accounting_require_proof')) { + updates.push({ + setting_key: 'accounting_require_proof', + setting_value: JSON.stringify(!!req.body.accounting_require_proof), + setting_type: 'accounting', + }); + } + for (const u of updates) { + const existing = await db('app_settings').where('setting_key', u.setting_key).first(); + if (existing) { + await db('app_settings').where('setting_key', u.setting_key).update({ + setting_value: u.setting_value, setting_type: u.setting_type, updated_at: new Date(), + }); + } else { + await db('app_settings').insert({ ...u, created_at: new Date(), updated_at: new Date() }); + } + } + res.json({ message: 'Accounting settings updated', updated: updates.map((u) => u.setting_key) }); + } catch (error) { + console.error('Accounting settings save error:', error); + res.status(500).json({ error: 'Failed to save accounting settings' }); + } +}); + // Get settings by type router.get('/:type', adminAuth, requirePermission('settings.view'), async (req, res) => { try { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 51330423..59b7c5eb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -272,6 +272,8 @@ function App() { }> }> } /> + + }> } /> }> diff --git a/frontend/src/components/admin/AccountingLayout.tsx b/frontend/src/components/admin/AccountingLayout.tsx index d919ddd6..996c84ec 100644 --- a/frontend/src/components/admin/AccountingLayout.tsx +++ b/frontend/src/components/admin/AccountingLayout.tsx @@ -41,7 +41,7 @@ export const AccountingLayout: React.FC = () => { to: '/admin/accounting/expenses', label: t('accounting.subnav.expenses', 'Expenses'), icon: Wallet, - featureFlag: 'incomingInvoices', + featureFlag: 'expenses', }, { key: 'tax-report', @@ -159,6 +159,7 @@ export const AccountingLayout: React.FC = () => { export const AccountingIndex: React.FC = () => { const { flags } = useFeatureFlags(); if (flags.incomingInvoices) return ; + if (flags.expenses) return ; if (flags.taxReport) return ; return null; }; diff --git a/frontend/src/contexts/FeatureFlagsContext.tsx b/frontend/src/contexts/FeatureFlagsContext.tsx index 2899b543..7cdc74fb 100644 --- a/frontend/src/contexts/FeatureFlagsContext.tsx +++ b/frontend/src/contexts/FeatureFlagsContext.tsx @@ -48,9 +48,12 @@ export const DEFAULT_FLAGS: FeatureFlags = { // Accounting (migration 122). Top-level MASTER for the Accounting // section (separate from CRM). Sub-features below require it. accounting: false, - // Incoming invoices (migration 124) — supplier-invoice capture + - // expenses + re-bill. Accounting sub-feature; requires `accounting`. + // Incoming invoices (migration 124) — external supplier-invoice capture + + // re-bill. Accounting sub-feature; requires `accounting`. incomingInvoices: false, + // Expenses (migration 127) — internal expenses (mileage / per-diem / cash). + // Separate Accounting sub-feature; requires `accounting`. + expenses: false, }; export const FEATURE_FLAGS_QUERY_KEY = ['feature-flags'] as const; @@ -87,6 +90,7 @@ function applyDependencyRules(flags: FeatureFlags): FeatureFlags { if (out.accounting === false) { out.taxReport = false; out.incomingInvoices = false; + out.expenses = false; } // Clients parent flag is DERIVED from its children. Admins don't // toggle it directly — enabling any CRM-area sub-feature diff --git a/frontend/src/features/settings/tabs/FeaturesTab.tsx b/frontend/src/features/settings/tabs/FeaturesTab.tsx index d7ef030b..e9845e4a 100644 --- a/frontend/src/features/settings/tabs/FeaturesTab.tsx +++ b/frontend/src/features/settings/tabs/FeaturesTab.tsx @@ -18,6 +18,7 @@ import { Calculator, Landmark, ScanLine, + Wallet, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { Button, Card } from '../../../components/common'; @@ -331,6 +332,25 @@ export const FeaturesTab: React.FC = () => { 'Enable Accounting first — Incoming invoices live in the Accounting section.', ) : undefined} /> + + setFlag('expenses', next)} + disabled={!staged.accounting} + lockedReason={!staged.accounting ? t( + 'settings.features.expenses.requiresAccounting', + 'Enable Accounting first — Expenses live in the Accounting section.', + ) : undefined} + /> {/* Insights & Access */} diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 847a90d7..c17ad474 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1650,6 +1650,12 @@ "sidebar": "Eingang", "requiresAccounting": "Bitte zuerst Buchhaltung aktivieren — Eingangsrechnungen liegen im Buchhaltungsbereich." }, + "expenses": { + "title": "Aufwände", + "description": "Interne Aufwände (Kilometer, Spesenpauschale, Barbelege), gebucht auf ein Event oder die Firma, mit optionalem Beleg. Getrennt von Eingangsrechnungen.", + "sidebar": "Aufwände", + "requiresAccounting": "Bitte zuerst Buchhaltung aktivieren — Aufwände liegen im Buchhaltungsbereich." + }, "analytics": { "title": "Statistiken", "description": "Speichernutzung, Galerie-Aufrufe, Download-Zahlen und Statistiken pro Veranstaltung." diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 8856f09f..5e5caea0 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1208,6 +1208,12 @@ "sidebar": "Incoming", "requiresAccounting": "Enable Accounting first — Incoming invoices live in the Accounting section." }, + "expenses": { + "title": "Expenses", + "description": "Internal expenses (mileage, per-diem, cash) booked to an event or the company, with optional proof. Separate from incoming supplier invoices.", + "sidebar": "Expenses", + "requiresAccounting": "Enable Accounting first — Expenses live in the Accounting section." + }, "analytics": { "title": "Analytics", "description": "Storage usage, gallery views, download counts, and per-event stats." diff --git a/frontend/src/services/featureFlags.service.ts b/frontend/src/services/featureFlags.service.ts index a5775138..e2d5a668 100644 --- a/frontend/src/services/featureFlags.service.ts +++ b/frontend/src/services/featureFlags.service.ts @@ -46,9 +46,12 @@ export type FeatureKey = // section (separate from CRM). Its sub-features (tax export, incoming // invoices) require it. Strictly opt-in. | 'accounting' - // Incoming invoices (migration 124) — supplier-invoice capture + - // expenses + re-bill. Accounting sub-feature; requires `accounting`. - | 'incomingInvoices'; + // Incoming invoices (migration 124) — external supplier-invoice capture + + // re-bill. Accounting sub-feature; requires `accounting`. + | 'incomingInvoices' + // Expenses (migration 127) — internal expenses (mileage / per-diem / cash). + // Separate Accounting sub-feature; requires `accounting`. + | 'expenses'; export type FeatureFlags = Record;