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;