From c3054928453a23c599894b669b6ac3c623fa4183 Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Thu, 11 Jun 2026 00:04:16 +0200
Subject: [PATCH 01/76] feat(accounting): inbound supplier-invoice capture +
expense re-bill (backend)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
New top-level Accounting area (gated by an `accounting` feature flag, default
OFF, + accounting.view/manage permissions), separate from CRM. Lets an admin
capture a received supplier invoice (upload OR phone/tablet camera), give it a
disposition, and re-bill the cost to a client onto the relevant event's
invoice with a contract-driven markup. Mirrors the billable-hours model.
Backend foundation only — frontend pages (inbox / expenses UI + camera widget)
and the heavy extractors (Tesseract OCR / Swiss-QR decode / isolated rasterise
worker) are follow-ups; extractionService is scaffolded so the upload path is
already wired.
Migrations 122-125 (numbered above the in-flight feat/crm 117-121):
- 122 seed `accounting` flag (default OFF, idempotent)
- 123 seed accounting.view/manage permissions + grant super_admin/admin
- 124 inbound_documents + expenses + expense_categories (+ seed categories)
- 125 contracts Spesen-Zuschlag clause (expense_markup_type/_percent/_flat_minor)
API: /api/admin/expenses — inbound capture/list/confirm/categorize, expense
CRUD, /:id/rebill (event-scoped; markup = expense override -> contract clause
-> 0%; mints an editable scheduled invoice), /:id/supplier-payment, categories.
adminFeatureFlags KNOWN_FLAGS/DEFAULT_FLAGS gain `accounting`.
Conventions: idempotent hasTable/hasColumn-guarded migrations; money in integer
*_minor; QR amount stored separately + untrusted; requirePermission guards;
camelCase API <-> snake_case columns; multer + 15MB cap for PDF/JPEG/PNG.
VAT/tax handling is v1 capture-only — verify with a Treuhaender before relying.
Verified: node -c all files, require-graph smoke test, and a SQLite migration
harness (schema + seeds + idempotency + defaults assert green).
---
.../core/122_seed_accounting_feature_flag.js | 22 +
.../core/123_seed_accounting_permissions.js | 64 +++
...4_create_inbound_documents_and_expenses.js | 147 +++++
.../125_add_contract_expense_surcharge.js | 41 ++
backend/server.js | 1 +
backend/src/routes/adminExpenses.js | 185 +++++++
backend/src/routes/adminFeatureFlags.js | 5 +
.../src/services/expenseCategoriesService.js | 64 +++
backend/src/services/expenseService.js | 516 ++++++++++++++++++
backend/src/services/extractionService.js | 54 ++
docs/accounting-inbound-invoices.md | 65 +++
11 files changed, 1164 insertions(+)
create mode 100644 backend/migrations/core/122_seed_accounting_feature_flag.js
create mode 100644 backend/migrations/core/123_seed_accounting_permissions.js
create mode 100644 backend/migrations/core/124_create_inbound_documents_and_expenses.js
create mode 100644 backend/migrations/core/125_add_contract_expense_surcharge.js
create mode 100644 backend/src/routes/adminExpenses.js
create mode 100644 backend/src/services/expenseCategoriesService.js
create mode 100644 backend/src/services/expenseService.js
create mode 100644 backend/src/services/extractionService.js
create mode 100644 docs/accounting-inbound-invoices.md
diff --git a/backend/migrations/core/122_seed_accounting_feature_flag.js b/backend/migrations/core/122_seed_accounting_feature_flag.js
new file mode 100644
index 00000000..ce416999
--- /dev/null
+++ b/backend/migrations/core/122_seed_accounting_feature_flag.js
@@ -0,0 +1,22 @@
+/**
+ * Migration 122: seed the `accounting` feature flag (default OFF).
+ *
+ * Gates the new top-level Accounting area (inbound supplier invoices,
+ * billable / re-billable expenses, Erfolgsrechnung). Admins opt in under
+ * Settings → Features. Separate from the CRM `bills` flag.
+ *
+ * Idempotent: inserts only when the row is missing (mirrors the
+ * 095_add_customer_portal_flag pattern). 107_crm_consolidated already
+ * shipped its flag set on fresh installs and won't re-run.
+ */
+exports.up = async function (knex) {
+ if (!(await knex.schema.hasTable('feature_flags'))) return;
+ const existing = await knex('feature_flags').where({ key: 'accounting' }).first();
+ if (existing) return;
+ await knex('feature_flags').insert({ key: 'accounting', value: false });
+};
+
+exports.down = async function (knex) {
+ if (!(await knex.schema.hasTable('feature_flags'))) return;
+ await knex('feature_flags').where({ key: 'accounting' }).del();
+};
diff --git a/backend/migrations/core/123_seed_accounting_permissions.js b/backend/migrations/core/123_seed_accounting_permissions.js
new file mode 100644
index 00000000..bdc73bad
--- /dev/null
+++ b/backend/migrations/core/123_seed_accounting_permissions.js
@@ -0,0 +1,64 @@
+/**
+ * Migration 123: seed `accounting.view` / `accounting.manage` permissions
+ * and grant them to the super_admin + admin roles.
+ *
+ * Idempotent: inserts only missing permission names and only missing
+ * (role_id, permission_id) grants (mirrors 107_crm_consolidated Section 13).
+ */
+const NEW_PERMISSIONS = [
+ {
+ name: 'accounting.view',
+ display_name: 'View Accounting',
+ category: 'accounting',
+ description: 'View inbound documents, expenses and accounting reports',
+ },
+ {
+ name: 'accounting.manage',
+ display_name: 'Manage Accounting',
+ category: 'accounting',
+ description: 'Capture inbound documents, categorize expenses and re-bill to clients',
+ },
+];
+
+exports.up = async function (knex) {
+ if (!(await knex.schema.hasTable('permissions'))) return;
+
+ const names = NEW_PERMISSIONS.map((p) => p.name);
+ const existing = await knex('permissions').whereIn('name', names).select('name');
+ const existingSet = new Set(existing.map((r) => r.name));
+ const toInsert = NEW_PERMISSIONS.filter((p) => !existingSet.has(p.name));
+ if (toInsert.length > 0) await knex('permissions').insert(toInsert);
+
+ if (!(await knex.schema.hasTable('roles')) || !(await knex.schema.hasTable('role_permissions'))) {
+ return;
+ }
+ const roles = await knex('roles').whereIn('name', ['super_admin', 'admin']).select('id');
+ const perms = await knex('permissions').whereIn('name', names).select('id');
+ if (!roles.length || !perms.length) return;
+
+ const existingGrants = await knex('role_permissions')
+ .whereIn('role_id', roles.map((r) => r.id))
+ .whereIn('permission_id', perms.map((p) => p.id))
+ .select('role_id', 'permission_id');
+ const grantSet = new Set(existingGrants.map((g) => `${g.role_id}:${g.permission_id}`));
+
+ const toGrant = [];
+ for (const r of roles) {
+ for (const p of perms) {
+ if (!grantSet.has(`${r.id}:${p.id}`)) {
+ toGrant.push({ role_id: r.id, permission_id: p.id });
+ }
+ }
+ }
+ if (toGrant.length > 0) await knex('role_permissions').insert(toGrant);
+};
+
+exports.down = async function (knex) {
+ if (!(await knex.schema.hasTable('permissions'))) return;
+ const names = NEW_PERMISSIONS.map((p) => p.name);
+ const perms = await knex('permissions').whereIn('name', names).select('id');
+ if (perms.length && (await knex.schema.hasTable('role_permissions'))) {
+ await knex('role_permissions').whereIn('permission_id', perms.map((p) => p.id)).del();
+ }
+ await knex('permissions').whereIn('name', names).del();
+};
diff --git a/backend/migrations/core/124_create_inbound_documents_and_expenses.js b/backend/migrations/core/124_create_inbound_documents_and_expenses.js
new file mode 100644
index 00000000..d494e8e3
--- /dev/null
+++ b/backend/migrations/core/124_create_inbound_documents_and_expenses.js
@@ -0,0 +1,147 @@
+/**
+ * Migration 124: Accounting foundation tables.
+ *
+ * - expense_categories : seeded, admin-editable colored labels (feed the
+ * future Erfolgsrechnung).
+ * - inbound_documents : received supplier invoices / receipts (system of
+ * record). Holds best-effort parsed fields plus the
+ * QR-encoded amount SEPARATELY (untrusted, tamper
+ * cross-check — the authoritative total is the
+ * text/line-item value).
+ * - expenses : the booking created when a document gets a
+ * disposition (or a manual expense with no document).
+ *
+ * All money is stored in integer minor units (*_amount_minor). All creates
+ * are hasTable-guarded so partial states + re-runs are safe.
+ */
+const SEED_CATEGORIES = [
+ { name: 'Infrastruktur & Miete', color: 'slate' },
+ { name: 'Equipment & Hardware', color: 'indigo' },
+ { name: 'Software & Lizenzen', color: 'violet' },
+ { name: 'Material & Verbrauch', color: 'amber' },
+ { name: 'Reise & Spesen', color: 'teal' },
+ { name: 'Werbung & Marketing', color: 'rose' },
+ { name: 'Dienstleistungen/Fremdleistungen', color: 'blue' },
+ { name: 'Versicherungen & Gebühren', color: 'gray' },
+ { name: 'Weiterbildung', color: 'green' },
+ { name: 'Sonstiges', color: 'zinc' },
+];
+
+exports.up = async function (knex) {
+ if (!(await knex.schema.hasTable('expense_categories'))) {
+ await knex.schema.createTable('expense_categories', (table) => {
+ table.increments('id').primary();
+ table.string('name', 128).notNullable();
+ table.string('color', 24);
+ table.boolean('is_seed').notNullable().defaultTo(false);
+ table.integer('display_order').notNullable().defaultTo(0);
+ table.timestamp('created_at').defaultTo(knex.fn.now());
+ table.timestamp('updated_at').defaultTo(knex.fn.now());
+ });
+ const rows = SEED_CATEGORIES.map((c, i) => ({
+ name: c.name,
+ color: c.color,
+ is_seed: true,
+ display_order: (i + 1) * 10,
+ }));
+ await knex('expense_categories').insert(rows);
+ }
+
+ if (!(await knex.schema.hasTable('inbound_documents'))) {
+ await knex.schema.createTable('inbound_documents', (table) => {
+ table.increments('id').primary();
+ table.string('source', 16).notNullable().defaultTo('upload'); // upload|camera|email|manual
+ table.string('original_filename', 512);
+ table.string('file_path', 512);
+ table.string('mime_type', 128);
+ table.string('file_sha256', 64);
+ table.string('status', 24).notNullable().defaultTo('unsorted'); // unsorted|categorized|declined|duplicate
+ table.string('parse_status', 16).notNullable().defaultTo('pending'); // pending|parsed|failed|manual
+ table.text('parse_error');
+ table.string('parse_method', 24); // qr|pdf_text|ocr|none
+ // Best-effort parsed fields (assist only — always editable/confirmable):
+ table.string('supplier_name', 255);
+ table.string('invoice_number', 128);
+ table.date('invoice_date');
+ table.date('due_date');
+ table.string('currency', 3);
+ table.integer('net_amount_minor');
+ table.integer('vat_amount_minor');
+ table.integer('total_amount_minor');
+ // QR-encoded amount kept SEPARATE + untrusted (tamper cross-check):
+ table.integer('qr_amount_minor');
+ table.string('iban', 34);
+ table.string('payment_reference', 140);
+ table.text('raw_parsed'); // JSON blob of the raw extraction result
+ table.integer('duplicate_of_id').unsigned()
+ .references('id').inTable('inbound_documents').onDelete('SET NULL');
+ table.integer('created_by_admin_id').unsigned();
+ table.timestamp('created_at').defaultTo(knex.fn.now());
+ table.timestamp('updated_at').defaultTo(knex.fn.now());
+ table.index(['status']);
+ table.index(['file_sha256']);
+ });
+ }
+
+ if (!(await knex.schema.hasTable('expenses'))) {
+ await knex.schema.createTable('expenses', (table) => {
+ table.increments('id').primary();
+ table.integer('inbound_document_id').unsigned()
+ .references('id').inTable('inbound_documents').onDelete('SET NULL');
+ // rebill|durchlaufend|eigener_aufwand|duplikat|abgelehnt
+ table.string('disposition', 24).notNullable();
+ // domestic|reverse_charge_service|foreign_vat_non_reclaimable|import_goods
+ table.string('tax_treatment', 32).notNullable().defaultTo('domestic');
+ // Loose links (no hard FK — kept resilient across SQLite/PG, mirrors the
+ // invoice event snapshot approach); indexed for lookups:
+ table.integer('event_id').unsigned();
+ table.integer('customer_account_id').unsigned();
+ table.string('supplier_name', 255);
+ table.text('description');
+ // FX: capture original + converted base (CHF) amount.
+ table.string('original_currency', 3);
+ table.integer('original_amount_minor');
+ table.integer('chf_amount_minor');
+ table.boolean('fx_locked').notNullable().defaultTo(false);
+ table.string('fx_lock_reason', 32); // bank_reconciled|auto_30d|billed
+ table.integer('net_amount_minor');
+ table.integer('vat_amount_minor');
+ table.integer('gross_amount_minor');
+ // Re-bill markup (Spesen-Zuschlag): expense override else contract clause.
+ table.string('markup_type', 8).notNullable().defaultTo('none'); // none|percent|flat
+ table.decimal('markup_percent', 5, 2);
+ table.integer('markup_flat_minor');
+ table.integer('category_id').unsigned()
+ .references('id').inTable('expense_categories').onDelete('SET NULL');
+ table.text('tags'); // JSON array
+ table.integer('billed_invoice_id').unsigned();
+ table.integer('billed_invoice_line_item_id').unsigned();
+ table.boolean('unbilled_parked').notNullable().defaultTo(false);
+ table.timestamp('billed_at');
+ // Supplier payment (decoupled from categorisation):
+ table.boolean('supplier_paid').notNullable().defaultTo(false);
+ table.timestamp('supplier_paid_at');
+ // bank_transfer|cash|twint|paypal|card|other
+ table.string('payment_method', 16);
+ table.string('payment_reference', 140);
+ table.string('receipt_path', 512);
+ table.text('decline_reason');
+ table.string('status', 16).notNullable().defaultTo('open'); // open|parked|billed|declined
+ table.integer('created_by_admin_id').unsigned();
+ table.timestamp('created_at').defaultTo(knex.fn.now());
+ table.timestamp('updated_at').defaultTo(knex.fn.now());
+ table.index(['disposition']);
+ table.index(['status']);
+ table.index(['event_id']);
+ table.index(['customer_account_id']);
+ table.index(['billed_invoice_id']);
+ table.index(['supplier_paid']);
+ });
+ }
+};
+
+exports.down = async function (knex) {
+ await knex.schema.dropTableIfExists('expenses');
+ await knex.schema.dropTableIfExists('inbound_documents');
+ await knex.schema.dropTableIfExists('expense_categories');
+};
diff --git a/backend/migrations/core/125_add_contract_expense_surcharge.js b/backend/migrations/core/125_add_contract_expense_surcharge.js
new file mode 100644
index 00000000..78666dcf
--- /dev/null
+++ b/backend/migrations/core/125_add_contract_expense_surcharge.js
@@ -0,0 +1,41 @@
+/**
+ * Migration 125: add the Spesen-Zuschlag (expense surcharge) clause to
+ * contracts. Drives the DEFAULT markup applied when an expense is re-billed
+ * to a client on that contract's event (a per-expense override still wins).
+ *
+ * - expense_markup_type : 'none' | 'percent' | 'flat' (default 'none' = 0%)
+ * - expense_markup_percent: decimal(5,2) (used when type='percent')
+ * - expense_markup_flat_minor: integer minor units (used when type='flat')
+ *
+ * Idempotent: each column is hasColumn-guarded so re-runs / partial states
+ * are safe. Default 'none' preserves existing behaviour (at-cost re-bill).
+ */
+exports.up = async function (knex) {
+ if (!(await knex.schema.hasTable('contracts'))) return;
+
+ if (!(await knex.schema.hasColumn('contracts', 'expense_markup_type'))) {
+ await knex.schema.alterTable('contracts', (table) => {
+ table.string('expense_markup_type', 8).notNullable().defaultTo('none');
+ });
+ }
+ if (!(await knex.schema.hasColumn('contracts', 'expense_markup_percent'))) {
+ await knex.schema.alterTable('contracts', (table) => {
+ table.decimal('expense_markup_percent', 5, 2);
+ });
+ }
+ if (!(await knex.schema.hasColumn('contracts', 'expense_markup_flat_minor'))) {
+ await knex.schema.alterTable('contracts', (table) => {
+ table.integer('expense_markup_flat_minor');
+ });
+ }
+};
+
+exports.down = async function (knex) {
+ if (!(await knex.schema.hasTable('contracts'))) return;
+ for (const col of ['expense_markup_type', 'expense_markup_percent', 'expense_markup_flat_minor']) {
+ if (await knex.schema.hasColumn('contracts', col)) {
+ // eslint-disable-next-line no-await-in-loop
+ await knex.schema.alterTable('contracts', (table) => table.dropColumn(col));
+ }
+ }
+};
diff --git a/backend/server.js b/backend/server.js
index bc1427fc..5c823e74 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -706,6 +706,7 @@ app.use('/api/admin/contracts', require('./src/routes/adminContracts'));
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/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/adminExpenses.js b/backend/src/routes/adminExpenses.js
new file mode 100644
index 00000000..3f718e11
--- /dev/null
+++ b/backend/src/routes/adminExpenses.js
@@ -0,0 +1,185 @@
+/**
+ * Admin Accounting routes — inbound supplier invoices + expenses + re-bill.
+ *
+ * Gated by the `accounting` feature flag and `accounting.view` /
+ * `accounting.manage` permissions. camelCase API ↔ camelCase service payloads
+ * (the service maps to snake_case columns). Money is integer minor units.
+ */
+const express = require('express');
+const { body, param, query } = require('express-validator');
+const multer = require('multer');
+const path = require('path');
+const fs = require('fs').promises;
+const { adminAuth } = require('../middleware/auth');
+const { requirePermission } = require('../middleware/permissions');
+const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
+const { getStoragePath } = require('../config/storage');
+const { db } = require('../database/db');
+const expenseService = require('../services/expenseService');
+const expenseCategoriesService = require('../services/expenseCategoriesService');
+
+const router = express.Router();
+
+// Inbound documents accept PDFs AND images (phone/tablet camera capture).
+const inboundStorage = multer.diskStorage({
+ destination: async (_req, _file, cb) => {
+ const year = new Date().getFullYear();
+ const dir = path.join(getStoragePath(), 'business-docs', 'inbound', String(year));
+ await fs.mkdir(dir, { recursive: true });
+ cb(null, dir);
+ },
+ filename: (_req, file, cb) => {
+ const ext = path.extname(file.originalname) || '';
+ cb(null, `inbound-${Date.now()}${ext}`);
+ },
+});
+const INBOUND_MIME = ['application/pdf', 'image/jpeg', 'image/png'];
+const inboundUpload = multer({
+ storage: inboundStorage,
+ limits: { fileSize: 15 * 1024 * 1024 }, // 15 MB — camera photos run large
+ fileFilter: (_req, file, cb) => {
+ if (INBOUND_MIME.includes(file.mimetype)) return cb(null, true);
+ return cb(new Error('Only PDF, JPEG or PNG files are allowed'));
+ },
+});
+
+async function requireAccountingFlag(req, res, next) {
+ try {
+ const row = await db('feature_flags').where({ key: 'accounting' }).first();
+ const enabled = row && (row.value === true || row.value === 1 || row.value === '1');
+ if (!enabled) return res.status(403).json({ error: 'Accounting feature is disabled', code: 'ACCOUNTING_DISABLED' });
+ return next();
+ } catch (err) { return next(err); }
+}
+
+router.use(adminAuth);
+router.use(requireAccountingFlag);
+
+// ── Expense categories (literal path — register BEFORE '/:id') ──────────────
+router.get('/categories', requirePermission('accounting.view'), handleAsync(async (_req, res) => {
+ return successResponse(res, { items: await expenseCategoriesService.list() });
+}));
+
+router.post('/categories', requirePermission('accounting.manage'),
+ [body('name').isString().isLength({ min: 1, max: 128 }), body('color').optional({ nullable: true }).isString()],
+ handleAsync(async (req, res) => {
+ validateRequest(req);
+ const cat = await expenseCategoriesService.create(req.body, req.admin.id);
+ return successResponse(res, { category: cat }, 201, 'Category created');
+ }));
+
+router.patch('/categories/:id', requirePermission('accounting.manage'),
+ [param('id').isInt({ min: 1 })],
+ handleAsync(async (req, res) => {
+ validateRequest(req);
+ const cat = await expenseCategoriesService.update(parseInt(req.params.id, 10), req.body);
+ return successResponse(res, { category: cat });
+ }));
+
+router.delete('/categories/:id', requirePermission('accounting.manage'),
+ [param('id').isInt({ min: 1 })],
+ handleAsync(async (req, res) => {
+ validateRequest(req);
+ return successResponse(res, await expenseCategoriesService.remove(parseInt(req.params.id, 10)));
+ }));
+
+// ── Inbound documents (literal path — register BEFORE '/:id') ───────────────
+router.post('/inbound', requirePermission('accounting.manage'),
+ inboundUpload.single('file'),
+ [body('source').optional().isIn(['upload', 'camera', 'email', 'manual'])],
+ handleAsync(async (req, res) => {
+ validateRequest(req);
+ if (!req.file) return res.status(400).json({ error: 'No file uploaded', code: 'NO_FILE' });
+ const doc = await expenseService.recordInboundDocument({
+ source: req.body.source || 'upload',
+ filePath: req.file.path,
+ originalFilename: req.file.originalname,
+ mimeType: req.file.mimetype,
+ }, req.admin.id);
+ return successResponse(res, { document: doc }, 201, 'Document captured');
+ }));
+
+router.get('/inbound', requirePermission('accounting.view'),
+ [query('status').optional().isString(), query('page').optional().isInt({ min: 1 }), query('pageSize').optional().isInt({ min: 1, max: 100 })],
+ handleAsync(async (req, res) => {
+ validateRequest(req);
+ return successResponse(res, await expenseService.listInbound(req.query));
+ }));
+
+router.get('/inbound/:id', requirePermission('accounting.view'),
+ [param('id').isInt({ min: 1 })],
+ handleAsync(async (req, res) => {
+ validateRequest(req);
+ return successResponse(res, { document: await expenseService.getInbound(parseInt(req.params.id, 10)) });
+ }));
+
+router.patch('/inbound/:id', requirePermission('accounting.manage'),
+ [param('id').isInt({ min: 1 })],
+ handleAsync(async (req, res) => {
+ validateRequest(req);
+ const doc = await expenseService.updateInbound(parseInt(req.params.id, 10), req.body, req.admin.id);
+ return successResponse(res, { document: doc });
+ }));
+
+router.post('/inbound/:id/categorize', requirePermission('accounting.manage'),
+ [param('id').isInt({ min: 1 }), body('disposition').isIn(expenseService.DISPOSITIONS)],
+ handleAsync(async (req, res) => {
+ validateRequest(req);
+ const expense = await expenseService.categorizeInbound(parseInt(req.params.id, 10), req.body, req.admin.id);
+ return successResponse(res, { expense }, 201, 'Expense created');
+ }));
+
+// ── Expenses ────────────────────────────────────────────────────────────────
+router.get('/', requirePermission('accounting.view'),
+ [query('status').optional().isString(), query('disposition').optional().isIn(expenseService.DISPOSITIONS),
+ query('customerAccountId').optional().isInt({ min: 1 }), query('eventId').optional().isInt({ min: 1 }),
+ query('page').optional().isInt({ min: 1 }), query('pageSize').optional().isInt({ min: 1, max: 100 })],
+ handleAsync(async (req, res) => {
+ validateRequest(req);
+ return successResponse(res, await expenseService.listExpenses(req.query));
+ }));
+
+router.post('/', requirePermission('accounting.manage'),
+ [body('disposition').isIn(expenseService.DISPOSITIONS)],
+ handleAsync(async (req, res) => {
+ validateRequest(req);
+ const expense = await expenseService.createManualExpense(req.body, req.admin.id);
+ return successResponse(res, { expense }, 201, 'Expense created');
+ }));
+
+router.get('/:id', requirePermission('accounting.view'),
+ [param('id').isInt({ min: 1 })],
+ handleAsync(async (req, res) => {
+ validateRequest(req);
+ return successResponse(res, { expense: await expenseService.getExpense(parseInt(req.params.id, 10)) });
+ }));
+
+router.patch('/:id', requirePermission('accounting.manage'),
+ [param('id').isInt({ min: 1 })],
+ handleAsync(async (req, res) => {
+ validateRequest(req);
+ const expense = await expenseService.updateExpense(parseInt(req.params.id, 10), req.body, req.admin.id);
+ return successResponse(res, { expense });
+ }));
+
+router.post('/:id/rebill', requirePermission('accounting.manage'),
+ [param('id').isInt({ min: 1 }), body('customerAccountId').isInt({ min: 1 }),
+ body('eventId').optional({ nullable: true }).isInt({ min: 1 }),
+ body('contractId').optional({ nullable: true }).isInt({ min: 1 }),
+ body('markupType').optional().isIn(expenseService.MARKUP_TYPES)],
+ handleAsync(async (req, res) => {
+ validateRequest(req);
+ const result = await expenseService.rebillToEvent(parseInt(req.params.id, 10), req.body, req.admin.id);
+ return successResponse(res, result, 201, 'Expense re-billed');
+ }));
+
+router.post('/:id/supplier-payment', requirePermission('accounting.manage'),
+ [param('id').isInt({ min: 1 }), body('paid').isBoolean(),
+ body('paymentMethod').optional({ nullable: true }).isIn(expenseService.PAYMENT_METHODS)],
+ handleAsync(async (req, res) => {
+ validateRequest(req);
+ const expense = await expenseService.setSupplierPayment(parseInt(req.params.id, 10), req.body, req.admin.id);
+ return successResponse(res, { expense });
+ }));
+
+module.exports = router;
diff --git a/backend/src/routes/adminFeatureFlags.js b/backend/src/routes/adminFeatureFlags.js
index f76b2ceb..81063cce 100644
--- a/backend/src/routes/adminFeatureFlags.js
+++ b/backend/src/routes/adminFeatureFlags.js
@@ -62,6 +62,10 @@ const KNOWN_FLAGS = [
// upload). Seeded block bodies are EXAMPLES ONLY; admins must have a
// lawyer review before sending. See docs/crm-disclaimers.md.
'contracts',
+ // Accounting (migration 122). Top-level Accounting area — inbound
+ // supplier invoices, expenses + re-bill, and the tax report (which
+ // relocates here from CRM when this is on). Strictly opt-in.
+ 'accounting',
];
// Spec defaults for any flag missing from the DB (e.g. a row added by a
@@ -84,6 +88,7 @@ const DEFAULT_FLAGS = {
taxReport: false,
hoursLogging: false,
contracts: false,
+ accounting: false,
};
async function readAllFlags() {
diff --git a/backend/src/services/expenseCategoriesService.js b/backend/src/services/expenseCategoriesService.js
new file mode 100644
index 00000000..262de3a9
--- /dev/null
+++ b/backend/src/services/expenseCategoriesService.js
@@ -0,0 +1,64 @@
+/**
+ * Expense categories (migration 124).
+ *
+ * Seeded colored labels that classify "eigener Aufwand" expenses and feed
+ * the future Erfolgsrechnung. Seed rows can be renamed/recolored but not
+ * deleted (they back the reporting chart of accounts).
+ */
+const { db } = require('../database/db');
+const { AppError } = require('../utils/errors');
+
+async function list() {
+ return db('expense_categories')
+ .orderBy('display_order', 'asc')
+ .orderBy('name', 'asc');
+}
+
+async function getById(id) {
+ const row = await db('expense_categories').where({ id }).first();
+ if (!row) throw new AppError('Expense category not found', 404, 'CATEGORY_NOT_FOUND');
+ return row;
+}
+
+async function create({ name, color, displayOrder }, adminId) {
+ if (!name || !String(name).trim()) {
+ throw new AppError('Category name is required', 400, 'NAME_REQUIRED');
+ }
+ const now = new Date();
+ const row = {
+ name: String(name).trim(),
+ color: color || null,
+ is_seed: false,
+ display_order: Number.isInteger(displayOrder) ? displayOrder : 0,
+ created_at: now,
+ updated_at: now,
+ };
+ const inserted = await db('expense_categories').insert(row).returning('id');
+ const id = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
+ return getById(id);
+}
+
+async function update(id, { name, color, displayOrder }) {
+ const existing = await getById(id);
+ const patch = { updated_at: new Date() };
+ if (name !== undefined) {
+ if (!String(name).trim()) throw new AppError('Category name is required', 400, 'NAME_REQUIRED');
+ patch.name = String(name).trim();
+ }
+ if (color !== undefined) patch.color = color || null;
+ if (displayOrder !== undefined && Number.isInteger(displayOrder)) patch.display_order = displayOrder;
+ await db('expense_categories').where({ id: existing.id }).update(patch);
+ return getById(id);
+}
+
+async function remove(id) {
+ const existing = await getById(id);
+ if (existing.is_seed) {
+ throw new AppError('Seed categories cannot be deleted', 409, 'SEED_CATEGORY_PROTECTED');
+ }
+ // FK on expenses.category_id is ON DELETE SET NULL — orphaned expenses keep working.
+ await db('expense_categories').where({ id: existing.id }).del();
+ return { deleted: true };
+}
+
+module.exports = { list, getById, create, update, remove };
diff --git a/backend/src/services/expenseService.js b/backend/src/services/expenseService.js
new file mode 100644
index 00000000..18de60c8
--- /dev/null
+++ b/backend/src/services/expenseService.js
@@ -0,0 +1,516 @@
+/**
+ * Expense / inbound-document service (migration 124).
+ *
+ * Captures received supplier invoices (upload / camera), lets the admin give
+ * them a disposition, and — for "rebill" (Weiterverrechnung) — folds the cost
+ * onto a client's event invoice as a line item. The re-bill flow mirrors
+ * customerHoursService.billUnbilledEntries: resolve amount + markup, then
+ * createInvoice({ customerAccountId, eventId, lineItems }) and stamp the
+ * source row billed.
+ *
+ * Money is integer minor units throughout. The QR-encoded amount on an
+ * inbound document is stored separately and NEVER used as the authoritative
+ * total. All VAT/tax handling here is v1 (capture-only) and must be reviewed
+ * with a Treuhänder before being relied upon.
+ */
+const crypto = require('crypto');
+const fsp = require('fs').promises;
+const { db, logActivity } = require('../database/db');
+const { AppError } = require('../utils/errors');
+const { hasColumnCached } = require('../utils/schemaCache');
+const logger = require('../utils/logger');
+const invoiceService = require('./invoiceService');
+
+const DISPOSITIONS = ['rebill', 'durchlaufend', 'eigener_aufwand', 'duplikat', 'abgelehnt'];
+const TAX_TREATMENTS = ['domestic', 'reverse_charge_service', 'foreign_vat_non_reclaimable', 'import_goods'];
+const MARKUP_TYPES = ['none', 'percent', 'flat'];
+const PAYMENT_METHODS = ['bank_transfer', 'cash', 'twint', 'paypal', 'card', 'other'];
+
+// Disposition → inbound_documents.status once categorised.
+const DISPOSITION_DOC_STATUS = {
+ rebill: 'categorized',
+ durchlaufend: 'categorized',
+ eigener_aufwand: 'categorized',
+ duplikat: 'duplicate',
+ abgelehnt: 'declined',
+};
+
+function toIsoDate(v) {
+ if (!v) return null;
+ if (v instanceof Date) return v.toISOString().slice(0, 10);
+ return String(v).slice(0, 10); // PG datetime or SQLite bare date both normalise here
+}
+
+function parseTags(raw) {
+ if (!raw) return [];
+ try { const a = JSON.parse(raw); return Array.isArray(a) ? a : []; } catch (_e) { return []; }
+}
+
+function transformInbound(row) {
+ if (!row) return null;
+ return {
+ id: row.id,
+ source: row.source,
+ originalFilename: row.original_filename,
+ mimeType: row.mime_type,
+ status: row.status,
+ parseStatus: row.parse_status,
+ parseMethod: row.parse_method,
+ parseError: row.parse_error,
+ supplierName: row.supplier_name,
+ invoiceNumber: row.invoice_number,
+ invoiceDate: toIsoDate(row.invoice_date),
+ dueDate: toIsoDate(row.due_date),
+ currency: row.currency,
+ netAmountMinor: row.net_amount_minor,
+ vatAmountMinor: row.vat_amount_minor,
+ totalAmountMinor: row.total_amount_minor,
+ qrAmountMinor: row.qr_amount_minor,
+ iban: row.iban,
+ paymentReference: row.payment_reference,
+ duplicateOfId: row.duplicate_of_id,
+ createdAt: row.created_at,
+ updatedAt: row.updated_at,
+ };
+}
+
+function transformExpense(row) {
+ if (!row) return null;
+ return {
+ id: row.id,
+ inboundDocumentId: row.inbound_document_id,
+ disposition: row.disposition,
+ taxTreatment: row.tax_treatment,
+ eventId: row.event_id,
+ customerAccountId: row.customer_account_id,
+ supplierName: row.supplier_name,
+ description: row.description,
+ originalCurrency: row.original_currency,
+ originalAmountMinor: row.original_amount_minor,
+ chfAmountMinor: row.chf_amount_minor,
+ fxLocked: !!row.fx_locked,
+ fxLockReason: row.fx_lock_reason,
+ netAmountMinor: row.net_amount_minor,
+ vatAmountMinor: row.vat_amount_minor,
+ grossAmountMinor: row.gross_amount_minor,
+ markupType: row.markup_type,
+ markupPercent: row.markup_percent != null ? Number(row.markup_percent) : null,
+ markupFlatMinor: row.markup_flat_minor,
+ categoryId: row.category_id,
+ tags: parseTags(row.tags),
+ billedInvoiceId: row.billed_invoice_id,
+ billedInvoiceLineItemId: row.billed_invoice_line_item_id,
+ unbilledParked: !!row.unbilled_parked,
+ billedAt: row.billed_at,
+ supplierPaid: !!row.supplier_paid,
+ supplierPaidAt: row.supplier_paid_at,
+ paymentMethod: row.payment_method,
+ paymentReference: row.payment_reference,
+ declineReason: row.decline_reason,
+ status: row.status,
+ createdAt: row.created_at,
+ updatedAt: row.updated_at,
+ };
+}
+
+function clampPage(page, pageSize) {
+ const p = Math.max(1, parseInt(page, 10) || 1);
+ const ps = Math.min(100, Math.max(1, parseInt(pageSize, 10) || 25));
+ return { p, ps };
+}
+
+async function sha256OfFile(filePath) {
+ const buf = await fsp.readFile(filePath);
+ return crypto.createHash('sha256').update(buf).digest('hex');
+}
+
+// ── Inbound documents ──────────────────────────────────────────────────────
+
+/**
+ * Persist a received document (the system of record) and run best-effort
+ * extraction. Duplicates (same SHA-256) are flagged but still stored.
+ */
+async function recordInboundDocument({ source, filePath, originalFilename, mimeType }, adminId) {
+ let fileSha256 = null;
+ try { fileSha256 = await sha256OfFile(filePath); } catch (e) {
+ logger.warn?.(`expenseService: could not hash ${filePath}: ${e.message}`);
+ }
+
+ let duplicateOfId = null;
+ if (fileSha256) {
+ const dup = await db('inbound_documents').where({ file_sha256: fileSha256 }).first('id');
+ if (dup) duplicateOfId = dup.id;
+ }
+
+ // Best-effort extraction (currently a no-op scaffold — see extractionService).
+ let parse = { parsed: false, method: 'none', fields: {} };
+ try {
+ // eslint-disable-next-line global-require
+ const extractionService = require('./extractionService');
+ parse = await extractionService.extract(filePath, mimeType);
+ } catch (e) {
+ parse = { parsed: false, method: 'none', fields: {}, error: e.message };
+ }
+ const f = parse.fields || {};
+
+ const now = new Date();
+ const row = {
+ source: source || 'upload',
+ original_filename: originalFilename || null,
+ file_path: filePath,
+ mime_type: mimeType || null,
+ file_sha256: fileSha256,
+ status: duplicateOfId ? 'duplicate' : 'unsorted',
+ parse_status: parse.error ? 'failed' : (parse.parsed ? 'parsed' : 'pending'),
+ parse_method: parse.method || 'none',
+ parse_error: parse.error || null,
+ supplier_name: f.supplierName || null,
+ invoice_number: f.invoiceNumber || null,
+ invoice_date: f.invoiceDate || null,
+ due_date: f.dueDate || null,
+ currency: f.currency || null,
+ net_amount_minor: Number.isInteger(f.netAmountMinor) ? f.netAmountMinor : null,
+ vat_amount_minor: Number.isInteger(f.vatAmountMinor) ? f.vatAmountMinor : null,
+ total_amount_minor: Number.isInteger(f.totalAmountMinor) ? f.totalAmountMinor : null,
+ qr_amount_minor: Number.isInteger(f.qrAmountMinor) ? f.qrAmountMinor : null,
+ iban: f.iban || null,
+ payment_reference: f.paymentReference || null,
+ raw_parsed: parse.raw ? JSON.stringify(parse.raw) : null,
+ duplicate_of_id: duplicateOfId,
+ created_by_admin_id: adminId || null,
+ created_at: now,
+ updated_at: now,
+ };
+ const inserted = await db('inbound_documents').insert(row).returning('id');
+ const id = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
+ await logActivity('expense_inbound_captured', { inboundDocumentId: id, source: row.source, duplicate: !!duplicateOfId }, adminId);
+ return getInbound(id);
+}
+
+async function getInbound(id) {
+ const row = await db('inbound_documents').where({ id }).first();
+ if (!row) throw new AppError('Inbound document not found', 404, 'INBOUND_NOT_FOUND');
+ return transformInbound(row);
+}
+
+async function listInbound({ status, page, pageSize } = {}) {
+ const { p, ps } = clampPage(page, pageSize);
+ const base = db('inbound_documents');
+ if (status) base.where({ status });
+ const countRow = await base.clone().count({ count: '*' }).first();
+ const total = parseInt(countRow?.count || 0, 10);
+ const rows = await base.clone()
+ .orderBy('created_at', 'desc')
+ .limit(ps).offset((p - 1) * ps);
+ return {
+ items: rows.map(transformInbound),
+ pagination: { page: p, pageSize: ps, total, totalPages: Math.ceil(total / ps) },
+ };
+}
+
+const INBOUND_EDITABLE = {
+ supplierName: 'supplier_name', invoiceNumber: 'invoice_number', invoiceDate: 'invoice_date',
+ dueDate: 'due_date', currency: 'currency', netAmountMinor: 'net_amount_minor',
+ vatAmountMinor: 'vat_amount_minor', totalAmountMinor: 'total_amount_minor', iban: 'iban',
+ paymentReference: 'payment_reference',
+};
+
+/** Confirm / correct best-effort parsed fields (assist is never blind-trusted). */
+async function updateInbound(id, payload, adminId) {
+ await getInbound(id);
+ const patch = { updated_at: new Date(), parse_status: 'manual' };
+ for (const [camel, snake] of Object.entries(INBOUND_EDITABLE)) {
+ if (payload[camel] !== undefined) patch[snake] = payload[camel] === '' ? null : payload[camel];
+ }
+ await db('inbound_documents').where({ id }).update(patch);
+ await logActivity('expense_inbound_updated', { inboundDocumentId: id }, adminId);
+ return getInbound(id);
+}
+
+// ── Expenses ───────────────────────────────────────────────────────────────
+
+function buildExpenseInsert(payload, adminId) {
+ const now = new Date();
+ const disposition = payload.disposition;
+ if (!DISPOSITIONS.includes(disposition)) {
+ throw new AppError(`disposition must be one of ${DISPOSITIONS.join(', ')}`, 400, 'BAD_DISPOSITION');
+ }
+ const taxTreatment = payload.taxTreatment && TAX_TREATMENTS.includes(payload.taxTreatment)
+ ? payload.taxTreatment : 'domestic';
+ const markupType = payload.markupType && MARKUP_TYPES.includes(payload.markupType)
+ ? payload.markupType : 'none';
+ let status = 'open';
+ if (disposition === 'abgelehnt') status = 'declined';
+ else if (payload.unbilledParked) status = 'parked';
+
+ return {
+ inbound_document_id: payload.inboundDocumentId || null,
+ disposition,
+ tax_treatment: taxTreatment,
+ event_id: payload.eventId || null,
+ customer_account_id: payload.customerAccountId || null,
+ supplier_name: payload.supplierName || null,
+ description: payload.description || null,
+ original_currency: payload.originalCurrency || null,
+ original_amount_minor: Number.isInteger(payload.originalAmountMinor) ? payload.originalAmountMinor : null,
+ chf_amount_minor: Number.isInteger(payload.chfAmountMinor) ? payload.chfAmountMinor : null,
+ fx_locked: !!payload.fxLocked,
+ fx_lock_reason: payload.fxLockReason || null,
+ net_amount_minor: Number.isInteger(payload.netAmountMinor) ? payload.netAmountMinor : null,
+ vat_amount_minor: Number.isInteger(payload.vatAmountMinor) ? payload.vatAmountMinor : null,
+ gross_amount_minor: Number.isInteger(payload.grossAmountMinor) ? payload.grossAmountMinor : null,
+ markup_type: markupType,
+ markup_percent: markupType === 'percent' && payload.markupPercent != null ? payload.markupPercent : null,
+ markup_flat_minor: markupType === 'flat' && Number.isInteger(payload.markupFlatMinor) ? payload.markupFlatMinor : null,
+ category_id: payload.categoryId || null,
+ tags: Array.isArray(payload.tags) ? JSON.stringify(payload.tags) : null,
+ unbilled_parked: !!payload.unbilledParked,
+ decline_reason: disposition === 'abgelehnt' ? (payload.declineReason || null) : null,
+ status,
+ created_by_admin_id: adminId || null,
+ created_at: now,
+ updated_at: now,
+ };
+}
+
+async function getExpense(id) {
+ const row = await db('expenses').where({ id }).first();
+ if (!row) throw new AppError('Expense not found', 404, 'EXPENSE_NOT_FOUND');
+ return transformExpense(row);
+}
+
+async function createManualExpense(payload, adminId) {
+ const row = buildExpenseInsert(payload, adminId);
+ const inserted = await db('expenses').insert(row).returning('id');
+ const id = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
+ await logActivity('expense_created', { expenseId: id, disposition: row.disposition }, adminId);
+ return getExpense(id);
+}
+
+/** Create an expense FROM an inbound document and move the doc out of "Unsortiert". */
+async function categorizeInbound(inboundId, payload, adminId) {
+ const doc = await getInbound(inboundId);
+ return db.transaction(async (trx) => {
+ const row = buildExpenseInsert({
+ // Seed expense fields from the (confirmed) document, payload overrides win.
+ supplierName: doc.supplierName,
+ chfAmountMinor: doc.totalAmountMinor,
+ netAmountMinor: doc.netAmountMinor,
+ vatAmountMinor: doc.vatAmountMinor,
+ grossAmountMinor: doc.totalAmountMinor,
+ originalCurrency: doc.currency,
+ ...payload,
+ inboundDocumentId: inboundId,
+ }, adminId);
+ const inserted = await trx('expenses').insert(row).returning('id');
+ const id = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
+
+ const docStatus = DISPOSITION_DOC_STATUS[row.disposition] || 'categorized';
+ const docPatch = { status: docStatus, updated_at: new Date() };
+ if (row.disposition === 'duplikat' && payload.duplicateOfId) {
+ docPatch.duplicate_of_id = payload.duplicateOfId;
+ }
+ await trx('inbound_documents').where({ id: inboundId }).update(docPatch);
+
+ await logActivity('expense_categorized', { expenseId: id, inboundDocumentId: inboundId, disposition: row.disposition }, adminId);
+ const created = await trx('expenses').where({ id }).first();
+ return transformExpense(created);
+ });
+}
+
+async function listExpenses({ status, disposition, customerAccountId, eventId, page, pageSize } = {}) {
+ const { p, ps } = clampPage(page, pageSize);
+ const base = db('expenses');
+ if (status) base.where({ status });
+ if (disposition) base.where({ disposition });
+ if (customerAccountId) base.where({ customer_account_id: customerAccountId });
+ if (eventId) base.where({ event_id: eventId });
+ const countRow = await base.clone().count({ count: '*' }).first();
+ const total = parseInt(countRow?.count || 0, 10);
+ const rows = await base.clone()
+ .orderBy('created_at', 'desc')
+ .limit(ps).offset((p - 1) * ps);
+ return {
+ items: rows.map(transformExpense),
+ pagination: { page: p, pageSize: ps, total, totalPages: Math.ceil(total / ps) },
+ };
+}
+
+const EXPENSE_EDITABLE = {
+ supplierName: 'supplier_name', description: 'description', taxTreatment: 'tax_treatment',
+ eventId: 'event_id', customerAccountId: 'customer_account_id', categoryId: 'category_id',
+ originalCurrency: 'original_currency', originalAmountMinor: 'original_amount_minor',
+ chfAmountMinor: 'chf_amount_minor', netAmountMinor: 'net_amount_minor',
+ vatAmountMinor: 'vat_amount_minor', grossAmountMinor: 'gross_amount_minor',
+ markupType: 'markup_type', markupPercent: 'markup_percent', markupFlatMinor: 'markup_flat_minor',
+ declineReason: 'decline_reason',
+};
+
+async function updateExpense(id, payload, adminId) {
+ const existing = await getExpense(id);
+ if (existing.billedInvoiceId) {
+ throw new AppError('Expense already billed — edit is locked', 409, 'EXPENSE_LOCKED');
+ }
+ const patch = { updated_at: new Date() };
+ for (const [camel, snake] of Object.entries(EXPENSE_EDITABLE)) {
+ if (payload[camel] !== undefined) patch[snake] = payload[camel] === '' ? null : payload[camel];
+ }
+ if (payload.tags !== undefined) patch.tags = Array.isArray(payload.tags) ? JSON.stringify(payload.tags) : null;
+ // FX lock: once locked, the converted amount can't drift.
+ if (existing.fxLocked && (patch.chf_amount_minor !== undefined)) {
+ throw new AppError('FX amount is locked (bank-reconciled or billed)', 409, 'FX_LOCKED');
+ }
+ await db('expenses').where({ id }).update(patch);
+ await logActivity('expense_updated', { expenseId: id }, adminId);
+ return getExpense(id);
+}
+
+/** Toggle the supplier-payment status (decoupled from categorisation). */
+async function setSupplierPayment(id, { paid, paidAt, paymentMethod, paymentReference }, adminId) {
+ await getExpense(id);
+ if (paymentMethod && !PAYMENT_METHODS.includes(paymentMethod)) {
+ throw new AppError(`paymentMethod must be one of ${PAYMENT_METHODS.join(', ')}`, 400, 'BAD_PAYMENT_METHOD');
+ }
+ const patch = {
+ supplier_paid: !!paid,
+ supplier_paid_at: paid ? (paidAt ? new Date(paidAt) : new Date()) : null,
+ payment_method: paid ? (paymentMethod || null) : null,
+ payment_reference: paid ? (paymentReference || null) : null,
+ updated_at: new Date(),
+ };
+ await db('expenses').where({ id }).update(patch);
+ await logActivity('expense_supplier_payment', { expenseId: id, paid: !!paid }, adminId);
+ return getExpense(id);
+}
+
+/**
+ * Resolve the markup to apply: explicit override → expense's own clause →
+ * the event's contract Spesen-Zuschlag clause → none (0%).
+ * Returns { type, percent, flatMinor }.
+ */
+async function resolveMarkup(expense, override, contractId, trx) {
+ const pick = (type, percent, flatMinor) => ({
+ type: MARKUP_TYPES.includes(type) ? type : 'none',
+ percent: percent != null ? Number(percent) : null,
+ flatMinor: Number.isInteger(flatMinor) ? flatMinor : null,
+ });
+ if (override && override.markupType && override.markupType !== 'none') {
+ return pick(override.markupType, override.markupPercent, override.markupFlatMinor);
+ }
+ if (expense.markupType && expense.markupType !== 'none') {
+ return pick(expense.markupType, expense.markupPercent, expense.markupFlatMinor);
+ }
+ if (contractId && (await hasColumnCached('contracts', 'expense_markup_type'))) {
+ const c = await (trx || db)('contracts').where({ id: contractId })
+ .first('expense_markup_type', 'expense_markup_percent', 'expense_markup_flat_minor');
+ if (c && c.expense_markup_type && c.expense_markup_type !== 'none') {
+ return pick(c.expense_markup_type, c.expense_markup_percent, c.expense_markup_flat_minor);
+ }
+ }
+ return pick('none', null, null);
+}
+
+function computeMarkupMinor(baseMinor, markup) {
+ if (markup.type === 'percent' && markup.percent != null) {
+ return Math.round(baseMinor * Number(markup.percent) / 100);
+ }
+ if (markup.type === 'flat' && Number.isInteger(markup.flatMinor)) {
+ return markup.flatMinor;
+ }
+ return 0;
+}
+
+/**
+ * Re-bill an expense to a client (Weiterverrechnung). Event-scoped: one event
+ * → one customer. Mints an editable scheduled invoice with a single line
+ * (cost + markup), then stamps the expense billed + FX-locked. Mirrors
+ * customerHoursService.billUnbilledEntries.
+ *
+ * For monthly/manual-cadence customers, createInvoice appends the line to the
+ * running draft instead (its accumulator intercept) — same as logged hours.
+ */
+async function rebillToEvent(expenseId, payload, adminId) {
+ const { customerAccountId, eventId, contractId } = payload;
+ if (!customerAccountId) throw new AppError('customerAccountId is required to re-bill', 400, 'CUSTOMER_REQUIRED');
+
+ return db.transaction(async (trx) => {
+ const row = await trx('expenses').where({ id: expenseId }).first();
+ if (!row) throw new AppError('Expense not found', 404, 'EXPENSE_NOT_FOUND');
+ const expense = transformExpense(row);
+ if (expense.billedInvoiceId) throw new AppError('Expense already billed', 409, 'ALREADY_BILLED');
+
+ const baseMinor = expense.chfAmountMinor != null ? expense.chfAmountMinor
+ : (expense.grossAmountMinor != null ? expense.grossAmountMinor : null);
+ if (baseMinor == null) throw new AppError('Expense has no amount to re-bill', 400, 'AMOUNT_REQUIRED');
+
+ const markup = await resolveMarkup(expense, payload, contractId, trx);
+ const markupMinor = computeMarkupMinor(baseMinor, markup);
+ const lineTotalMinor = baseMinor + markupMinor;
+
+ const label = (expense.description || expense.supplierName || 'Weiterverrechnete Auslage');
+ const lineItem = {
+ description: `${label} (Weiterverrechnung)`,
+ quantity: 1,
+ unit_price_minor: lineTotalMinor,
+ discount_percent: 0,
+ line_total_minor: lineTotalMinor,
+ };
+
+ const { invoiceIds } = await invoiceService.createInvoice({
+ customerAccountId,
+ eventId: eventId || expense.eventId || null,
+ lineItems: [lineItem],
+ }, adminId, trx);
+ const invoiceId = Array.isArray(invoiceIds) ? invoiceIds[0] : null;
+ if (!invoiceId) throw new AppError('Failed to create the re-bill invoice', 500, 'REBILL_FAILED');
+
+ // Newest line in this invoice within the transaction is the one we added.
+ const line = await trx('invoice_line_items').where({ invoice_id: invoiceId })
+ .orderBy('id', 'desc').first('id');
+
+ const now = new Date();
+ await trx('expenses').where({ id: expenseId }).update({
+ disposition: 'rebill',
+ status: 'billed',
+ event_id: eventId || expense.eventId || null,
+ customer_account_id: customerAccountId,
+ markup_type: markup.type,
+ markup_percent: markup.type === 'percent' ? markup.percent : null,
+ markup_flat_minor: markup.type === 'flat' ? markup.flatMinor : null,
+ billed_invoice_id: invoiceId,
+ billed_invoice_line_item_id: line ? line.id : null,
+ billed_at: now,
+ fx_locked: true,
+ fx_lock_reason: 'billed',
+ updated_at: now,
+ });
+
+ await logActivity('expense_rebilled', {
+ expenseId, invoiceId, customerAccountId, baseMinor, markupMinor, lineTotalMinor,
+ }, adminId);
+
+ const updated = await trx('expenses').where({ id: expenseId }).first();
+ return { expense: transformExpense(updated), invoiceId };
+ });
+}
+
+module.exports = {
+ // inbound documents
+ recordInboundDocument,
+ getInbound,
+ listInbound,
+ updateInbound,
+ categorizeInbound,
+ // expenses
+ createManualExpense,
+ getExpense,
+ listExpenses,
+ updateExpense,
+ setSupplierPayment,
+ rebillToEvent,
+ // constants (for route validators)
+ DISPOSITIONS,
+ TAX_TREATMENTS,
+ MARKUP_TYPES,
+ PAYMENT_METHODS,
+};
diff --git a/backend/src/services/extractionService.js b/backend/src/services/extractionService.js
new file mode 100644
index 00000000..7c15d0e5
--- /dev/null
+++ b/backend/src/services/extractionService.js
@@ -0,0 +1,54 @@
+/**
+ * Inbound-document field extraction — the assist ladder.
+ *
+ * Lightest-first: Swiss QR-bill decode → digital-PDF text layer → OCR for
+ * true scans. Returns BEST-EFFORT fields only; the admin always confirms
+ * them in the inbox. The QR amount is returned SEPARATELY as `qrAmountMinor`
+ * and must NEVER be treated as the authoritative total (it is the
+ * attacker-controllable "pay this" field) — the authoritative total comes
+ * from the text/line items and the admin's confirmation.
+ *
+ * ─────────────────────────────────────────────────────────────────────────
+ * STATUS: interface + plumbing only. The heavy extractors require infra that
+ * is intentionally deferred to a follow-up:
+ * - Swiss QR decode → a 2D-barcode decoder (zxing/jsQR class) + rasterise.
+ * - PDF text layer → a text extractor (NOT a 3rd PDF lib — see memory
+ * `feedback_pdf_libraries`; revisit the approach).
+ * - OCR → Tesseract installed as an OS package in the Docker
+ * image and shelled out, run inside a NETWORK-ISOLATED
+ * worker (no egress) per the locked design.
+ * Until those land, extract() returns { parsed: false, method: 'none' } and
+ * the document stays in `parse_status='pending'` for manual entry.
+ * ─────────────────────────────────────────────────────────────────────────
+ */
+const logger = require('../utils/logger');
+
+/**
+ * @returns {Promise<{
+ * parsed: boolean,
+ * method: 'qr'|'pdf_text'|'ocr'|'none',
+ * fields: {
+ * supplierName?, invoiceNumber?, invoiceDate?, dueDate?, currency?,
+ * netAmountMinor?, vatAmountMinor?, totalAmountMinor?,
+ * qrAmountMinor?, iban?, paymentReference?
+ * },
+ * raw?: object,
+ * error?: string
+ * }>}
+ */
+async function extract(filePath, mimeType) {
+ try {
+ // 1) Swiss QR-bill (structured) — DEFERRED.
+ // 2) Digital-PDF text layer — DEFERRED.
+ // 3) OCR for scans/photos — DEFERRED.
+ // Plumbing is in place so the upload route can call this best-effort
+ // today and richer extractors can slot in without touching callers.
+ logger.debug?.(`extractionService: no extractor wired yet for ${mimeType || 'unknown'} (${filePath})`);
+ return { parsed: false, method: 'none', fields: {} };
+ } catch (err) {
+ logger.error?.(`extractionService.extract failed: ${err.message}`);
+ return { parsed: false, method: 'none', fields: {}, error: err.message };
+ }
+}
+
+module.exports = { extract };
diff --git a/docs/accounting-inbound-invoices.md b/docs/accounting-inbound-invoices.md
new file mode 100644
index 00000000..8a1625c8
--- /dev/null
+++ b/docs/accounting-inbound-invoices.md
@@ -0,0 +1,65 @@
+# Accounting — Inbound supplier invoices, expenses & re-bill (MVP)
+
+> **Status:** new feature, in development on `feat/accounting-inbound-invoices` (based on `upstream/beta`).
+> **Maintainer scope decision required** before merge — this introduces a new top-level **Accounting** area, separate from CRM (see "Scope decisions" below).
+> **Legal:** every VAT / tax-treatment surface is an *example only* and must be reviewed with a Treuhänder before relying on it. Jurisdiction scope is **Liechtenstein-first** (Swiss/LI rails — QR-bill, LI MWST), not German DATEV/ELSTER.
+
+## Why
+The studio receives supplier invoices/receipts (hotels, equipment, fremdleistungen). Today they live in email/paper and are re-typed. This feature lets an admin **capture an incoming invoice** (upload, or **phone/tablet camera**), have its fields **best-effort extracted**, then give it a **disposition** — most importantly **re-bill it to a client** ("Weiterverrechnung") onto the relevant event's invoice with a contract-driven markup.
+
+This mirrors the existing **billable-hours** model (`customerHoursService`): an item is parked against a customer/event and folded into an invoice as a line item.
+
+## Scope decisions (maintainer)
+1. **New top-level "Accounting" area**, gated behind a new `accounting` feature flag (default OFF) and `accounting.view` / `accounting.manage` permissions — *not* bolted onto CRM. The existing tax-export page is a candidate to move here later (not in this MVP).
+2. **picpeak owns documents + books up to the export boundary**; certified external systems (Treuhänder / Abacus / Bexio) own statutory filing.
+3. **No paperless-ngx sidecar** — picpeak is the system of record; files live under `storage/` and are covered by the existing `backup_paths` walker.
+
+## MVP scope (this branch)
+- **Intake**: file upload **and camera capture** (phone/tablet) → `POST /api/admin/expenses/inbound` (accepts PDF + JPEG/PNG). Stored as the system of record; deduped by SHA-256.
+- **Best-effort extraction** (`extractionService`): ladder of Swiss-QR decode → PDF text layer → OCR. *Scaffolded with the interface in place; the heavy extractors (Tesseract OS package, QR decoder, isolated rasterise worker) are a follow-up — see "Deferred".*
+- **Inbox**: list documents as **„Neu / Unsortiert"**; parsed fields are editable/confirmable (parsing is assist, never blind trust). The **QR-encoded amount is stored separately** and surfaced for tamper cross-check — the **authoritative total is the text/line-item value**.
+- **5 dispositions**: `rebill` (Weiterverrechnen) · `durchlaufend` (Durchlaufender Posten) · `eigener_aufwand` (company expense) · `duplikat` · `abgelehnt` (with reason).
+- **Re-bill flow**: event-scoped (one event → one customer). Markup resolved **expense override → contract `Spesen-Zuschlag` clause → 0%** (percent or flat). Mints an editable **scheduled** invoice (admin can add more lines) — same pattern as `billUnbilledEntries`.
+- **Supplier-payment status** (decoupled from categorisation): „Zu zahlen / Bezahlt" with `payment_method` (unified with the outgoing list incl. **bank_transfer**).
+- **Expense categories**: seeded + admin-editable (colored label) — feed the future Erfolgsrechnung.
+- **`tax_treatment` captured from day 1** (`domestic` default) — stored for the books; reclaim/Bezugsteuer math is future (switches on when `business_profile.vat_id` is set).
+
+## Data model (migrations 122–125)
+Numbered from **122** to avoid colliding with the in-flight `feat/crm-improvements` migrations **117–121** (which are expected to merge first). If this lands before that branch, renumber to 117+.
+
+- **122** — seed `accounting` feature flag (default OFF).
+- **123** — seed `accounting.view` / `accounting.manage` permissions + grant to super_admin/admin.
+- **124** — `inbound_documents`, `expenses`, `expense_categories` (+ seed categories).
+- **125** — `contracts.expense_markup_type|_percent|_flat_minor` (the Spesen-Zuschlag clause).
+
+Key tables (all money in integer minor units, `*_amount_minor`):
+- `inbound_documents` — raw received doc + parsed/confirmable fields + `qr_amount_minor` (separate, untrusted) + `status` (unsorted/categorized/declined/duplicate).
+- `expenses` — the booking: `disposition`, `tax_treatment`, `event_id`, `customer_account_id`, FX (`original_*` + `chf_amount_minor` + `fx_locked`), `markup_type/_percent/_flat_minor`, `category_id`, `billed_invoice_id`, supplier-payment fields, `status`.
+- `expense_categories` — seeded colored labels.
+
+## API (`/api/admin/expenses`, gated by `accounting` flag + `accounting.*`)
+- `POST /inbound` (multipart) — capture an inbound doc (upload/camera).
+- `GET /inbound` — list (filter by status, paginated).
+- `GET /inbound/:id` — one doc.
+- `PATCH /inbound/:id` — confirm/edit parsed fields.
+- `POST /inbound/:id/categorize` — create an expense with a disposition.
+- `POST / ` — create a manual expense (no document).
+- `GET / ` — list expenses (filter by status/disposition/customer/event).
+- `GET /:id` — one expense.
+- `PATCH /:id` — edit (locked once billed).
+- `POST /:id/rebill` — re-bill to a client (event-scoped, contract markup) → scheduled invoice.
+- `POST /:id/supplier-payment` — toggle supplier paid + method.
+- `GET/POST/PATCH/DELETE /categories` — manage expense categories.
+
+## Camera capture (step 3)
+The `POST /inbound` endpoint accepts images, so a **mobile web** widget using
+`` already enables phone/tablet camera capture — **no native app required for v1**. A native document-scanner (edge-detect/dewarp, multi-page) is a later UX upgrade that improves OCR accuracy.
+
+## Deferred (follow-ups)
+- Real extraction: Tesseract OCR (OS package in the Docker image, shell-out — *not* a sidecar), Swiss-QR decoder, **network-isolated rasterise worker** (no egress), CSP-locked image preview, never serve the raw PDF.
+- Email intake (`rechnungen@…` IMAP poll, forwarded-message parsing, message-id dedupe).
+- Bank reconciliation, FX auto-lock backstop (30-day), Erfolgsrechnung, customer-account close guard.
+- Frontend: the Accounting tab UI (inbox, disposition actions, re-bill dialog) + the camera widget.
+
+## Conventions followed
+Idempotent migrations (hasTable/hasColumn-guarded); new flag default OFF; flag reads tolerate `true|1|'1'`; money as integer `*_minor`; `requirePermission` guards; camelCase API ↔ snake_case service; multer + `safePath` containment at every file boundary; localized dates on display; tax/legal surfaces carry a "verify with Treuhänder" disclaimer.
From 30c0007f40594e679f5d997219985107de784cf4 Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Thu, 11 Jun 2026 00:04:40 +0200
Subject: [PATCH 02/76] feat(accounting): Accounting nav section + relocate Tax
report out of CRM
Adds the `accounting` feature flag to the frontend (type, context default) and
a Settings -> Features toggle card. When enabled:
- A new top-level "Accounting" sidebar entry appears (gated by `accounting` +
accounting.view), with an AccountingLayout sub-nav mirroring ClientsLayout.
- The Tax report relocates: it is HIDDEN from the CRM (Clients) sub-nav and
shown under Accounting instead, at /admin/accounting/tax-report. When
accounting is OFF, Tax stays under CRM exactly as before.
Tax visibility still depends on `taxReport` (which depends on `bills`), so the
relocation only changes WHERE the menu item lives, not whether it exists.
Files: featureFlags.service.ts (+'accounting'), FeatureFlagsContext default,
AdminSidebar entry, new AccountingLayout, ClientsLayout filter, App.tsx route,
FeaturesTab card, en/de i18n (navigation.accounting, accounting.*,
settings.features.accounting; DE authored natively).
Verified: `npm run build` green; en/de JSON valid.
---
frontend/src/App.tsx | 15 ++
.../src/components/admin/AccountingLayout.tsx | 141 ++++++++++++++++++
.../src/components/admin/AdminSidebar.tsx | 11 ++
.../src/components/admin/ClientsLayout.tsx | 8 +-
frontend/src/contexts/FeatureFlagsContext.tsx | 4 +
.../features/settings/tabs/FeaturesTab.tsx | 15 ++
frontend/src/i18n/locales/de.json | 18 +++
frontend/src/i18n/locales/en.json | 18 +++
frontend/src/services/featureFlags.service.ts | 7 +-
9 files changed, 235 insertions(+), 2 deletions(-)
create mode 100644 frontend/src/components/admin/AccountingLayout.tsx
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index a4d72da6..e78f7e4c 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -64,6 +64,7 @@ import {
import { CustomerAuthProvider } from './contexts/CustomerAuthContext';
import { AdminLayout, AdminAuthWrapper } from './components/admin';
import { ClientsLayout } from './components/admin/ClientsLayout';
+import { AccountingLayout } from './components/admin/AccountingLayout';
import { RequireFeature } from './components/admin/RequireFeature';
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock, Loading } from './components/common';
import { MaintenanceWrapper } from './components/MaintenanceWrapper';
@@ -260,6 +261,20 @@ function App() {
+ {/* Accounting section (migration 122). Parent gated by
+ the `accounting` flag. Hosts the Tax report — which
+ relocates here from the CRM sub-nav when accounting
+ is on — plus the future inbound-invoice / expenses
+ pages. Each sub-route is independently flagged. */}
+
+ {t('accounting.subtitle', 'Inbound supplier invoices, expenses and reporting.')} +
++ {t('accounting.empty.body', 'Enable the Tax report (or another accounting sub-feature) under Settings → Features to get started.')} +
+{t('accounting.inbox.rebillHint', 'Creates an editable scheduled invoice on the client. VAT/tax handling is v1 — verify with your Treuhänder.')}
+{t('accounting.inbox.captureBody', 'Photograph a paper invoice with your device camera, or upload a PDF / image.')}
+{t('accounting.inbox.empty', 'No documents yet — capture one above.')}
++ {t('accounting.inbox.qrHint', 'Opened at the last page — the Swiss QR-bill usually sits at the bottom.')} +
+ )} +{t('accounting.inbox.rebillHint', 'Creates an editable scheduled invoice on the client. VAT/tax handling is v1 — verify with your Treuhänder.')}