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. */} + }> + }> + }> + } /> + + } /> + + + {/* Old /admin/customers paths now live under /admin/clients/accounts. Kept indefinitely as redirects so existing bookmarks and email links diff --git a/frontend/src/components/admin/AccountingLayout.tsx b/frontend/src/components/admin/AccountingLayout.tsx new file mode 100644 index 00000000..e098b44b --- /dev/null +++ b/frontend/src/components/admin/AccountingLayout.tsx @@ -0,0 +1,141 @@ +/** + * Accounting section layout (migration 122). + * + * Wraps /admin/accounting/* routes with a Settings-style left sub-nav, + * mirroring ClientsLayout. Today it hosts the Tax report (relocated here + * from CRM when the `accounting` flag is on); the inbound-document inbox and + * expenses pages slot in as additional sub-nav entries when their UIs land. + */ +import React from 'react'; +import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { Landmark, Calculator } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; +import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext'; + +interface NavItem { + key: string; + to: string; + label: string; + icon: LucideIcon; + /** Feature flag that must be ON for this entry to render. */ + featureFlag: FeatureKey; +} + +export const AccountingLayout: React.FC = () => { + const { t } = useTranslation(); + const location = useLocation(); + const navigate = useNavigate(); + const { flags } = useFeatureFlags(); + + const navItems: NavItem[] = [ + { + key: 'tax-report', + to: '/admin/accounting/tax-report', + label: t('accounting.subnav.taxReport', 'Tax'), + icon: Calculator, + featureFlag: 'taxReport', + }, + // Future Accounting sub-features (inbound inbox, expenses) slot in here + // once their pages land, e.g.: + // { key: 'inbox', to: '/admin/accounting/inbox', featureFlag: 'accounting' } + // { key: 'expenses', to: '/admin/accounting/expenses', featureFlag: 'accounting' } + ]; + + const enabledItems = navItems.filter((item) => flags[item.featureFlag]); + + const header = ( +
+

+ {t('accounting.title', 'Accounting')} +

+

+ {t('accounting.subtitle', 'Inbound supplier invoices, expenses and reporting.')} +

+
+ ); + + if (enabledItems.length === 0) { + return ( +
+ {header} +
+ +

+ {t('accounting.empty.title', 'No accounting features enabled')} +

+

+ {t('accounting.empty.body', 'Enable the Tax report (or another accounting sub-feature) under Settings → Features to get started.')} +

+
+
+ ); + } + + return ( +
+ {header} + +
+ {/* Mobile: native select dropdown */} +
+ + +
+ + {/* Desktop: sticky left rail */} + + +
+ +
+
+
+ ); +}; diff --git a/frontend/src/components/admin/AdminSidebar.tsx b/frontend/src/components/admin/AdminSidebar.tsx index 79aefddf..01a4d837 100644 --- a/frontend/src/components/admin/AdminSidebar.tsx +++ b/frontend/src/components/admin/AdminSidebar.tsx @@ -10,6 +10,7 @@ import { X, Users, Briefcase, + Landmark, PanelLeftClose, PanelLeftOpen, } from 'lucide-react'; @@ -96,6 +97,16 @@ const navigation: NavItem[] = [ 'taxReport', 'hoursLogging', 'contracts', 'calendar', ], }, + // Accounting section (migration 122) — inbound supplier invoices, + // expenses + re-bill, and the tax report (which relocates here from + // the CRM sub-nav when `accounting` is on). Gated by the `accounting` + // master flag; the sub-pages inside AccountingLayout are each + // independently feature-gated. + { + nameKey: 'navigation.accounting', href: '/admin/accounting', icon: Landmark, + permission: 'accounting.view', + featureFlag: 'accounting', + }, ]; export const AdminSidebar: React.FC = ({ isOpen, onClose, collapsed = false, onToggleCollapse }) => { diff --git a/frontend/src/components/admin/ClientsLayout.tsx b/frontend/src/components/admin/ClientsLayout.tsx index d3de2e1a..be53b2e4 100644 --- a/frontend/src/components/admin/ClientsLayout.tsx +++ b/frontend/src/components/admin/ClientsLayout.tsx @@ -99,7 +99,13 @@ export const ClientsLayout: React.FC = () => { }, ]; - const enabledItems = navItems.filter((item) => flags[item.featureFlag]); + const enabledItems = navItems.filter((item) => { + if (!flags[item.featureFlag]) return false; + // When the Accounting area is enabled, the tax report relocates out + // of CRM and under Accounting — hide it here so it isn't in both. + if (item.key === 'tax-report' && flags.accounting) return false; + return true; + }); // When the parent `clients` flag is on but no sub-feature is enabled, // there's nothing to render. Settings → Features is one click away diff --git a/frontend/src/contexts/FeatureFlagsContext.tsx b/frontend/src/contexts/FeatureFlagsContext.tsx index b0da55dc..4b80eb53 100644 --- a/frontend/src/contexts/FeatureFlagsContext.tsx +++ b/frontend/src/contexts/FeatureFlagsContext.tsx @@ -45,6 +45,10 @@ export const DEFAULT_FLAGS: FeatureFlags = { // Settings → Features once they've reviewed the seeded block // library with their lawyer. contracts: false, + // Accounting (migration 122). Top-level Accounting area (inbound + // supplier invoices, expenses + re-bill). When ON, the tax report + // moves out of the CRM sub-nav and under Accounting. Strictly opt-in. + accounting: false, }; export const FEATURE_FLAGS_QUERY_KEY = ['feature-flags'] as const; diff --git a/frontend/src/features/settings/tabs/FeaturesTab.tsx b/frontend/src/features/settings/tabs/FeaturesTab.tsx index b5cefdc7..6563af5c 100644 --- a/frontend/src/features/settings/tabs/FeaturesTab.tsx +++ b/frontend/src/features/settings/tabs/FeaturesTab.tsx @@ -16,6 +16,7 @@ import { Briefcase, Wrench, Calculator, + Landmark, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { Button, Card } from '../../../components/common'; @@ -278,6 +279,20 @@ export const FeaturesTab: React.FC = () => { ) : undefined} /> + setFlag('accounting', next)} + /> + ; From 2c351bf0c936a66b5d13ff1609426fee61f3ea74 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Thu, 11 Jun 2026 00:17:44 +0200 Subject: [PATCH 03/76] refactor(accounting): make Accounting a master flag with sub-toggles Replaces the earlier peer-`accounting` flag (which only *conditionally* relocated Tax) with a cleaner top-level master + sub-toggle model, per design discussion: - `accounting` = explicit top-level MASTER (Settings -> Features). Off hides the whole Accounting section. - Sub-toggles, gated under the master: - `taxReport` ("Tax export") moves PERMANENTLY out of CRM. Removed from the Clients sub-nav and from the derived `clients` flag. Now INDEPENDENT of Bills (per decision). Old /admin/clients/tax-report -> redirect to /admin/accounting/tax-report. - `incomingInvoices` (new) gates the supplier-invoice capture / expenses / re-bill feature; the /api/admin/expenses router now checks it. - Dependency rules (backend + frontend): accounting off forces taxReport + incomingInvoices off; taxReport dropped from the clients derivation; the bills->taxReport rule removed. - Preserve visuals: migration 122 rewritten to auto-enable `accounting` on installs that already had Tax on (so the tab doesn't vanish), and to seed `incomingInvoices` off. Verified with a SQLite harness (taxReport on -> accounting on; off -> off). - Settings -> Features: new "Accounting" section with the master card + Tax export + Incoming invoices sub-cards (disabled until the master is on). - i18n: navigation.accounting, accounting.*, settings.features.{accounting, incomingInvoices,taxReport.requiresAccounting}, sections.accounting (EN + DE, DE authored natively); Tax report relabelled "Tax export"/"Steuerexport". Verified: node -c, migration-122 harness, en/de JSON valid, npm run build green. --- .../core/122_seed_accounting_feature_flag.js | 39 +++++--- backend/src/routes/adminExpenses.js | 11 ++- backend/src/routes/adminFeatureFlags.js | 19 +++- frontend/src/App.tsx | 8 +- .../src/components/admin/ClientsLayout.tsx | 19 +--- frontend/src/contexts/FeatureFlagsContext.tsx | 18 +++- .../features/settings/tabs/FeaturesTab.tsx | 91 ++++++++++++------- frontend/src/i18n/locales/de.json | 14 ++- frontend/src/i18n/locales/en.json | 14 ++- frontend/src/services/featureFlags.service.ts | 12 ++- 10 files changed, 157 insertions(+), 88 deletions(-) diff --git a/backend/migrations/core/122_seed_accounting_feature_flag.js b/backend/migrations/core/122_seed_accounting_feature_flag.js index ce416999..94ed6de6 100644 --- a/backend/migrations/core/122_seed_accounting_feature_flag.js +++ b/backend/migrations/core/122_seed_accounting_feature_flag.js @@ -1,22 +1,39 @@ /** - * Migration 122: seed the `accounting` feature flag (default OFF). + * Migration 122: seed the Accounting feature flags. * - * 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. + * - `accounting` : top-level MASTER for the Accounting section + * (separate from CRM). Default OFF — EXCEPT on + * installs that already had the Tax report + * (`taxReport`) enabled: the Tax export relocated + * permanently into Accounting, so we auto-enable the + * master there to preserve the existing menu (per the + * "migrations preserve visual state" rule). Otherwise + * admins opt in under Settings → Features. + * - `incomingInvoices` : Accounting sub-feature (supplier-invoice capture + + * expenses + re-bill). Always default OFF (new). * - * 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. + * Idempotent: each row is inserted only when missing. 107_crm_consolidated + * already shipped its flag set 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 }); + + const existingAccounting = await knex('feature_flags').where({ key: 'accounting' }).first(); + if (!existingAccounting) { + // Preserve visuals: if Tax was already on, light up the Accounting + // master so the relocated Tax export doesn't vanish on upgrade. + const taxRow = await knex('feature_flags').where({ key: 'taxReport' }).first(); + const taxOn = !!(taxRow && (taxRow.value === true || taxRow.value === 1 || taxRow.value === '1')); + await knex('feature_flags').insert({ key: 'accounting', value: taxOn }); + } + + const existingIncoming = await knex('feature_flags').where({ key: 'incomingInvoices' }).first(); + if (!existingIncoming) { + await knex('feature_flags').insert({ key: 'incomingInvoices', value: false }); + } }; exports.down = async function (knex) { if (!(await knex.schema.hasTable('feature_flags'))) return; - await knex('feature_flags').where({ key: 'accounting' }).del(); + await knex('feature_flags').whereIn('key', ['accounting', 'incomingInvoices']).del(); }; diff --git a/backend/src/routes/adminExpenses.js b/backend/src/routes/adminExpenses.js index 3f718e11..04f806af 100644 --- a/backend/src/routes/adminExpenses.js +++ b/backend/src/routes/adminExpenses.js @@ -43,17 +43,20 @@ const inboundUpload = multer({ }, }); -async function requireAccountingFlag(req, res, next) { +// Gated on the `incomingInvoices` sub-feature, which the feature-flag +// dependency rules force OFF whenever the `accounting` master is off — so +// this single check covers both. +async function requireIncomingInvoicesFlag(req, res, next) { try { - const row = await db('feature_flags').where({ key: 'accounting' }).first(); + const row = await db('feature_flags').where({ key: 'incomingInvoices' }).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' }); + if (!enabled) return res.status(403).json({ error: 'Incoming invoices feature is disabled', code: 'INCOMING_INVOICES_DISABLED' }); return next(); } catch (err) { return next(err); } } router.use(adminAuth); -router.use(requireAccountingFlag); +router.use(requireIncomingInvoicesFlag); // ── Expense categories (literal path — register BEFORE '/:id') ────────────── router.get('/categories', requirePermission('accounting.view'), handleAsync(async (_req, res) => { diff --git a/backend/src/routes/adminFeatureFlags.js b/backend/src/routes/adminFeatureFlags.js index 81063cce..a17c4183 100644 --- a/backend/src/routes/adminFeatureFlags.js +++ b/backend/src/routes/adminFeatureFlags.js @@ -66,6 +66,10 @@ 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. + 'incomingInvoices', ]; // Spec defaults for any flag missing from the DB (e.g. a row added by a @@ -89,6 +93,7 @@ const DEFAULT_FLAGS = { hoursLogging: false, contracts: false, accounting: false, + incomingInvoices: false, }; async function readAllFlags() { @@ -109,10 +114,13 @@ function applyDependencyRules(flags) { // Sub-features can't outlive their parents. if (out.quotes === false) out.bills = false; if (out.calendar === false) out.calendarBooking = false; - // Tax report only makes sense when bills are on — turning bills off - // implicitly turns the tax report off too. Admins enabling tax - // report must first enable bills. - if (out.bills === false) out.taxReport = false; + // Accounting is a top-level MASTER; its sub-features can't outlive it. + // Tax export is now independent of Bills — it relocated permanently + // into the Accounting section (its own master gate). + if (out.accounting === false) { + out.taxReport = false; + out.incomingInvoices = false; + } // Clients parent flag is DERIVED from its children. Admins don't // toggle it directly in the Features tab — they enable a specific // sub-feature (Accounts today; Calendar/Quotes/Bills/Messaging @@ -125,9 +133,10 @@ function applyDependencyRules(flags) { || out.crmDevelopment || out.quotes || out.bills - || out.taxReport || out.hoursLogging || out.contracts + // NOTE: taxReport intentionally removed — Tax export moved to the + // Accounting section (its own master), no longer a CRM sub-feature. // Migration 137 — admin calendar lights up the Clients section. // (calendarBooking is gated behind `calendar` so adding the parent // is sufficient.) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e78f7e4c..a15515c2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -244,10 +244,10 @@ function App() { />
- {/* Tax / Steuer report — gated by `taxReport`. */} - }> - } /> - + {/* Tax export moved permanently to the Accounting + section. Keep this path as a redirect so old + bookmarks / links don't 404. */} + } /> {/* Developer tools — gated by `crmDevelopment`. */} }> } /> diff --git a/frontend/src/components/admin/ClientsLayout.tsx b/frontend/src/components/admin/ClientsLayout.tsx index be53b2e4..2aca0ec5 100644 --- a/frontend/src/components/admin/ClientsLayout.tsx +++ b/frontend/src/components/admin/ClientsLayout.tsx @@ -14,7 +14,7 @@ import React from 'react'; import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Briefcase, UserCog, FileText, Receipt, Wrench, Calculator, Clock, ScrollText, Calendar } from 'lucide-react'; +import { Briefcase, UserCog, FileText, Receipt, Wrench, Clock, ScrollText, Calendar } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext'; @@ -81,13 +81,8 @@ export const ClientsLayout: React.FC = () => { icon: Receipt, featureFlag: 'bills', }, - { - key: 'tax-report', - to: '/admin/clients/tax-report', - label: t('clients.subnav.taxReport', 'Tax'), - icon: Calculator, - featureFlag: 'taxReport', - }, + // Tax export moved permanently to the Accounting section (it is no + // longer a CRM sub-feature). See AccountingLayout. // Future sub-features: // { key: 'messaging', ... featureFlag: 'messaging' } { @@ -99,13 +94,7 @@ export const ClientsLayout: React.FC = () => { }, ]; - const enabledItems = navItems.filter((item) => { - if (!flags[item.featureFlag]) return false; - // When the Accounting area is enabled, the tax report relocates out - // of CRM and under Accounting — hide it here so it isn't in both. - if (item.key === 'tax-report' && flags.accounting) return false; - return true; - }); + const enabledItems = navItems.filter((item) => flags[item.featureFlag]); // When the parent `clients` flag is on but no sub-feature is enabled, // there's nothing to render. Settings → Features is one click away diff --git a/frontend/src/contexts/FeatureFlagsContext.tsx b/frontend/src/contexts/FeatureFlagsContext.tsx index 4b80eb53..2899b543 100644 --- a/frontend/src/contexts/FeatureFlagsContext.tsx +++ b/frontend/src/contexts/FeatureFlagsContext.tsx @@ -45,10 +45,12 @@ export const DEFAULT_FLAGS: FeatureFlags = { // Settings → Features once they've reviewed the seeded block // library with their lawyer. contracts: false, - // Accounting (migration 122). Top-level Accounting area (inbound - // supplier invoices, expenses + re-bill). When ON, the tax report - // moves out of the CRM sub-nav and under Accounting. Strictly opt-in. + // 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`. + incomingInvoices: false, }; export const FEATURE_FLAGS_QUERY_KEY = ['feature-flags'] as const; @@ -80,7 +82,12 @@ function applyDependencyRules(flags: FeatureFlags): FeatureFlags { out.galleries = true; // foundation — always on if (out.quotes === false) out.bills = false; // bills depend on quotes if (out.calendar === false) out.calendarBooking = false; // booking depends on calendar - if (out.bills === false) out.taxReport = false; // tax report depends on bills + // Accounting sub-features require the Accounting master. Tax export is + // independent of Bills now — it relocated permanently into Accounting. + if (out.accounting === false) { + out.taxReport = false; + out.incomingInvoices = false; + } // Clients parent flag is DERIVED from its children. Admins don't // toggle it directly — enabling any CRM-area sub-feature // (Accounts today; future Calendar / Quotes / Bills / Messaging) @@ -91,11 +98,12 @@ function applyDependencyRules(flags: FeatureFlags): FeatureFlags { || out.crmDevelopment || out.quotes || out.bills - || out.taxReport || out.hoursLogging || out.contracts // Migration 137 — admin calendar lights up the Clients section. || out.calendar + // NOTE: taxReport is intentionally NOT here anymore — the Tax export + // moved permanently into the Accounting section (its own master). // future siblings: || out.messaging ); return out; diff --git a/frontend/src/features/settings/tabs/FeaturesTab.tsx b/frontend/src/features/settings/tabs/FeaturesTab.tsx index 6563af5c..d7ef030b 100644 --- a/frontend/src/features/settings/tabs/FeaturesTab.tsx +++ b/frontend/src/features/settings/tabs/FeaturesTab.tsx @@ -17,6 +17,7 @@ import { Wrench, Calculator, Landmark, + ScanLine, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { Button, Card } from '../../../components/common'; @@ -260,39 +261,6 @@ export const FeaturesTab: React.FC = () => { onToggle={(next) => setFlag('bills', next)} /> - setFlag('taxReport', next)} - disabled={!staged.bills} - lockedReason={!staged.bills ? t( - 'settings.features.taxReport.requiresBills', - 'Enable Bills first — the tax report reads from your invoices.', - ) : undefined} - /> - - setFlag('accounting', next)} - /> - { /> + {/* Accounting — top-level master + sub-toggles. The Tax export + relocated here permanently out of CRM. Sub-toggles are disabled + until the Accounting master is on. */} +
+ setFlag('accounting', next)} + /> + + setFlag('taxReport', next)} + disabled={!staged.accounting} + lockedReason={!staged.accounting ? t( + 'settings.features.taxReport.requiresAccounting', + 'Enable Accounting first — Tax export lives in the Accounting section.', + ) : undefined} + /> + + setFlag('incomingInvoices', next)} + disabled={!staged.accounting} + lockedReason={!staged.accounting ? t( + 'settings.features.incomingInvoices.requiresAccounting', + 'Enable Accounting first — Incoming invoices live in the Accounting section.', + ) : undefined} + /> +
+ {/* Insights & Access */}
; From 2b5efebaff0ed05b99b84802811ee662911e9468 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Thu, 11 Jun 2026 00:31:05 +0200 Subject: [PATCH 04/76] feat(accounting): incoming-invoices inbox with camera capture + triage/re-bill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Accounting → Incoming invoices frontend on top of the existing /api/admin/expenses backend: - accounting.service.ts: typed client (inbound upload/list/get/update/ categorize, expense list, re-bill, supplier-payment, categories). - AccountingInboxPage: capture a supplier invoice via the device CAMERA () or a PDF/image upload; inbox list with status badges + parsed summary; a triage modal to confirm fields and pick a disposition (re-bill / pass-through / company expense / duplicate / declined). Re-bill uses the customer picker and mints an editable scheduled invoice (chains categorize -> rebill). - AccountingLayout: "Incoming invoices" sub-nav item + AccountingIndex that redirects /admin/accounting to the first enabled sub-feature. - App.tsx: /admin/accounting/inbox route (gated by incomingInvoices). - i18n: accounting.inbox/disposition/markup + subnav.incomingInvoices + common.saving (EN + DE, DE authored natively). Camera capture needs no native app — the mobile web input drives the device camera straight into the upload endpoint. OCR/QR auto-extraction is still a backend follow-up (extractionService is a no-op), so fields are confirmed manually in the triage modal for now. Verified: npm run build green; en/de JSON valid. --- frontend/src/App.tsx | 8 +- .../src/components/admin/AccountingLayout.tsx | 28 +- frontend/src/i18n/locales/de.json | 48 +++ frontend/src/i18n/locales/en.json | 48 +++ .../admin/accounting/AccountingInboxPage.tsx | 319 ++++++++++++++++++ frontend/src/services/accounting.service.ts | 148 ++++++++ 6 files changed, 591 insertions(+), 8 deletions(-) create mode 100644 frontend/src/pages/admin/accounting/AccountingInboxPage.tsx create mode 100644 frontend/src/services/accounting.service.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a15515c2..aee3c7c9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -64,7 +64,8 @@ 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 { AccountingLayout, AccountingIndex } from './components/admin/AccountingLayout'; +import { AccountingInboxPage } from './pages/admin/accounting/AccountingInboxPage'; import { RequireFeature } from './components/admin/RequireFeature'; import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock, Loading } from './components/common'; import { MaintenanceWrapper } from './components/MaintenanceWrapper'; @@ -268,10 +269,13 @@ function App() { pages. Each sub-route is independently flagged. */} }> }> + }> + } /> + }> } /> - } /> + } /> diff --git a/frontend/src/components/admin/AccountingLayout.tsx b/frontend/src/components/admin/AccountingLayout.tsx index e098b44b..81f85214 100644 --- a/frontend/src/components/admin/AccountingLayout.tsx +++ b/frontend/src/components/admin/AccountingLayout.tsx @@ -7,9 +7,9 @@ * expenses pages slot in as additional sub-nav entries when their UIs land. */ import React from 'react'; -import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'; +import { NavLink, Outlet, Navigate, useLocation, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Landmark, Calculator } from 'lucide-react'; +import { Landmark, Calculator, Inbox } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext'; @@ -29,6 +29,13 @@ export const AccountingLayout: React.FC = () => { const { flags } = useFeatureFlags(); const navItems: NavItem[] = [ + { + key: 'inbox', + to: '/admin/accounting/inbox', + label: t('accounting.subnav.incomingInvoices', 'Incoming invoices'), + icon: Inbox, + featureFlag: 'incomingInvoices', + }, { key: 'tax-report', to: '/admin/accounting/tax-report', @@ -36,10 +43,7 @@ export const AccountingLayout: React.FC = () => { icon: Calculator, featureFlag: 'taxReport', }, - // Future Accounting sub-features (inbound inbox, expenses) slot in here - // once their pages land, e.g.: - // { key: 'inbox', to: '/admin/accounting/inbox', featureFlag: 'accounting' } - // { key: 'expenses', to: '/admin/accounting/expenses', featureFlag: 'accounting' } + // Future: expenses ledger, Erfolgsrechnung. ]; const enabledItems = navItems.filter((item) => flags[item.featureFlag]); @@ -139,3 +143,15 @@ export const AccountingLayout: React.FC = () => { ); }; + +/** + * Index redirect for /admin/accounting — send to the first enabled + * sub-feature (Incoming invoices preferred, then Tax export). When none + * are on, render nothing; AccountingLayout shows its empty state. + */ +export const AccountingIndex: React.FC = () => { + const { flags } = useFeatureFlags(); + if (flags.incomingInvoices) return ; + if (flags.taxReport) return ; + return null; +}; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index b2d133f0..23f68055 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -98,6 +98,7 @@ "error": "Fehler", "configureInSettings": "Standards in den Einstellungen anpassen ↗", "save": "Speichern", + "saving": "Speichern…", "cancel": "Abbrechen", "delete": "Löschen", "edit": "Bearbeiten", @@ -3398,7 +3399,54 @@ "body": "Aktiviere die Steuerliste (oder eine andere Buchhaltungs-Unterfunktion) unter Einstellungen → Funktionen, um loszulegen." }, "subnav": { + "incomingInvoices": "Eingangsrechnungen", "taxReport": "Steuer" + }, + "disposition": { + "rebill": "An Kunde weiterverrechnen", + "durchlaufend": "Durchlaufender Posten", + "eigener_aufwand": "Eigener Aufwand", + "duplikat": "Duplikat", + "abgelehnt": "Abgelehnt" + }, + "markup": { + "none": "Keiner / aus Vertrag", + "percent": "Prozent", + "flat": "Pauschal" + }, + "inbox": { + "captureTitle": "Lieferantenrechnung erfassen", + "captureBody": "Fotografiere eine Papierrechnung mit der Gerätekamera oder lade ein PDF / Bild hoch.", + "scanCamera": "Mit Kamera scannen", + "uploadFile": "Datei hochladen", + "capturedToast": "Dokument erfasst.", + "categorizedToast": "Dokument kategorisiert.", + "triageTitle": "Dokument kategorisieren", + "saveCategorize": "Speichern", + "categorize": "Kategorisieren", + "empty": "Noch keine Dokumente — oben eines erfassen.", + "untitled": "Unbenanntes Dokument", + "noAmount": "Betrag nicht erfasst", + "rebillHint": "Erstellt eine bearbeitbare geplante Rechnung beim Kunden. MwSt-/Steuerbehandlung ist v1 — mit Treuhänder prüfen.", + "status": { + "unsorted": "Neu", + "categorized": "Kategorisiert", + "declined": "Abgelehnt", + "duplicate": "Duplikat" + }, + "field": { + "supplier": "Lieferant", + "total": "Total", + "currency": "Währung", + "invoiceDate": "Rechnungsdatum", + "disposition": "Zuordnung", + "category": "Kategorie", + "categoryNone": "— keine —", + "declineReason": "Grund", + "customer": "Kunde", + "eventId": "Event-ID (optional)", + "markup": "Zuschlag" + } } }, "calendar": { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 04438090..06b6f8c4 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -98,6 +98,7 @@ "error": "Error", "configureInSettings": "Configure defaults in Settings ↗", "save": "Save", + "saving": "Saving…", "cancel": "Cancel", "delete": "Delete", "edit": "Edit", @@ -3398,7 +3399,54 @@ "body": "Enable the Tax report (or another accounting sub-feature) under Settings → Features to get started." }, "subnav": { + "incomingInvoices": "Incoming invoices", "taxReport": "Tax" + }, + "disposition": { + "rebill": "Re-bill to client", + "durchlaufend": "Pass-through", + "eigener_aufwand": "Company expense", + "duplikat": "Duplicate", + "abgelehnt": "Declined" + }, + "markup": { + "none": "None / from contract", + "percent": "Percent", + "flat": "Flat" + }, + "inbox": { + "captureTitle": "Capture a supplier invoice", + "captureBody": "Photograph a paper invoice with your device camera, or upload a PDF / image.", + "scanCamera": "Scan with camera", + "uploadFile": "Upload file", + "capturedToast": "Document captured.", + "categorizedToast": "Document categorized.", + "triageTitle": "Categorize document", + "saveCategorize": "Save", + "categorize": "Categorize", + "empty": "No documents yet — capture one above.", + "untitled": "Untitled document", + "noAmount": "amount not entered", + "rebillHint": "Creates an editable scheduled invoice on the client. VAT/tax handling is v1 — verify with your Treuhänder.", + "status": { + "unsorted": "New", + "categorized": "Categorized", + "declined": "Declined", + "duplicate": "Duplicate" + }, + "field": { + "supplier": "Supplier", + "total": "Total", + "currency": "Currency", + "invoiceDate": "Invoice date", + "disposition": "Disposition", + "category": "Category", + "categoryNone": "— none —", + "declineReason": "Reason", + "customer": "Client", + "eventId": "Event ID (optional)", + "markup": "Markup" + } } }, "calendar": { diff --git a/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx b/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx new file mode 100644 index 00000000..562b40bf --- /dev/null +++ b/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx @@ -0,0 +1,319 @@ +/** + * Accounting → Incoming invoices inbox ("Neu / Unsortiert"). + * + * Capture a received supplier invoice via the phone/tablet CAMERA or a file + * upload, then triage it: confirm the best-effort parsed fields and give it a + * disposition (re-bill to a client, pass-through, company expense, duplicate, + * declined). Re-bill mints an editable scheduled invoice on the client's event. + * + * Parsing is assist-only and currently a no-op on the backend (extractionService + * scaffold) — fields are entered/confirmed manually until OCR lands. + */ +import React, { useRef, useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import { Camera, Upload, Inbox, X } from 'lucide-react'; +import { Button, Card, CardContent, Input, LocalizedDateInput, Loading } from '../../../components/common'; +import { DecimalInput } from '../../../components/common/DecimalInput'; +import { CustomerAccountPicker, type SelectedCustomer } from '../../../components/admin/CustomerAccountPicker'; +import { formatMoneyMinor } from '../../../utils/money'; +import { useLocalizedDate } from '../../../hooks/useLocalizedDate'; +import { + accountingService, + type InboundDocument, + type Disposition, + type MarkupType, + type ExpenseCategory, +} from '../../../services/accounting.service'; + +const DISPOSITIONS: Disposition[] = ['rebill', 'durchlaufend', 'eigener_aufwand', 'duplikat', 'abgelehnt']; + +const statusClasses: Record = { + unsorted: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300', + categorized: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300', + declined: 'bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-300', + duplicate: 'bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-300', +}; + +const TriageModal: React.FC<{ + doc: InboundDocument; + categories: ExpenseCategory[]; + onClose: () => void; + onDone: () => void; +}> = ({ doc, categories, onClose, onDone }) => { + const { t } = useTranslation(); + const [supplier, setSupplier] = useState(doc.supplierName || ''); + const [amountMajor, setAmountMajor] = useState(doc.totalAmountMinor != null ? doc.totalAmountMinor / 100 : NaN); + const [currency, setCurrency] = useState(doc.currency || 'CHF'); + const [invoiceDate, setInvoiceDate] = useState(doc.invoiceDate || ''); + const [disposition, setDisposition] = useState('eigener_aufwand'); + const [categoryId, setCategoryId] = useState(undefined); + const [declineReason, setDeclineReason] = useState(''); + const [customer, setCustomer] = useState([]); + const [eventId, setEventId] = useState(''); + const [markupType, setMarkupType] = useState('none'); + const [markupValue, setMarkupValue] = useState(NaN); + + const totalMinor = Number.isFinite(amountMajor) ? Math.round(amountMajor * 100) : null; + + const save = useMutation({ + mutationFn: async () => { + // 1) Confirm the document's fields (assist is never blind-trusted). + await accountingService.updateInbound(doc.id, { + supplierName: supplier || null, + totalAmountMinor: totalMinor, + currency: currency || null, + invoiceDate: invoiceDate || null, + }); + // 2) Create the expense with its disposition. + const expense = await accountingService.categorizeInbound(doc.id, { + disposition, + supplierName: supplier || null, + chfAmountMinor: totalMinor, + grossAmountMinor: totalMinor, + categoryId: disposition === 'eigener_aufwand' ? (categoryId ?? null) : null, + declineReason: disposition === 'abgelehnt' ? (declineReason || null) : null, + eventId: eventId ? Number(eventId) : null, + customerAccountId: disposition === 'rebill' && customer[0] ? customer[0].id : null, + markupType, + markupPercent: markupType === 'percent' && Number.isFinite(markupValue) ? markupValue : null, + markupFlatMinor: markupType === 'flat' && Number.isFinite(markupValue) ? Math.round(markupValue * 100) : null, + }); + // 3) Re-bill mints the client invoice. + if (disposition === 'rebill') { + await accountingService.rebill(expense.id, { + customerAccountId: customer[0].id, + eventId: eventId ? Number(eventId) : null, + markupType, + markupPercent: markupType === 'percent' && Number.isFinite(markupValue) ? markupValue : null, + markupFlatMinor: markupType === 'flat' && Number.isFinite(markupValue) ? Math.round(markupValue * 100) : null, + }); + } + }, + onSuccess: () => { + toast.success(t('accounting.inbox.categorizedToast', 'Document categorized.')); + onDone(); + }, + onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), + }); + + const rebillNeedsCustomer = disposition === 'rebill' && !customer[0]; + + return ( +
+
+
+

+ {t('accounting.inbox.triageTitle', 'Categorize document')} +

+ +
+ +
+
+
+ + setSupplier(e.target.value)} /> +
+
+ + +
+
+ + setCurrency(e.target.value.toUpperCase())} maxLength={3} /> +
+
+ + +
+
+ +
+ + +
+ + {disposition === 'eigener_aufwand' && ( +
+ + +
+ )} + + {disposition === 'abgelehnt' && ( +
+ + setDeclineReason(e.target.value)} /> +
+ )} + + {disposition === 'rebill' && ( +
+
+ + setCustomer(next.slice(-1))} /> +
+
+
+ + setEventId(e.target.value.replace(/[^0-9]/g, ''))} inputMode="numeric" /> +
+
+ + +
+
+ {markupType !== 'none' && ( + + )} +

{t('accounting.inbox.rebillHint', 'Creates an editable scheduled invoice on the client. VAT/tax handling is v1 — verify with your Treuhänder.')}

+
+ )} +
+ +
+ + +
+
+
+ ); +}; + +export const AccountingInboxPage: React.FC = () => { + const { t } = useTranslation(); + const qc = useQueryClient(); + const { format } = useLocalizedDate(); + const cameraRef = useRef(null); + const uploadRef = useRef(null); + const [triageDoc, setTriageDoc] = useState(null); + + const { data, isLoading } = useQuery({ + queryKey: ['accounting-inbound'], + queryFn: () => accountingService.listInbound({ pageSize: 100 }), + }); + const { data: categories } = useQuery({ + queryKey: ['expense-categories'], + queryFn: () => accountingService.listCategories(), + }); + + const upload = useMutation({ + mutationFn: ({ file, source }: { file: File; source: 'upload' | 'camera' }) => accountingService.uploadInbound(file, source), + onSuccess: (doc) => { + toast.success(t('accounting.inbox.capturedToast', 'Document captured.')); + qc.invalidateQueries({ queryKey: ['accounting-inbound'] }); + if (doc.status === 'unsorted') setTriageDoc(doc); // jump straight into triage + }, + onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Upload failed'), + }); + + const onFile = (source: 'upload' | 'camera') => (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) upload.mutate({ file, source }); + e.target.value = ''; + }; + + const items = data?.items ?? []; + + return ( +
+ + + + + +
+

{t('accounting.inbox.captureTitle', 'Capture a supplier invoice')}

+

{t('accounting.inbox.captureBody', 'Photograph a paper invoice with your device camera, or upload a PDF / image.')}

+
+ + +
+
+ + {isLoading ? ( + + ) : items.length === 0 ? ( +
+ +

{t('accounting.inbox.empty', 'No documents yet — capture one above.')}

+
+ ) : ( +
+ {items.map((doc) => ( +
+
+
+ + {t(`accounting.inbox.status.${doc.status}`, doc.status)} + + + {doc.supplierName || doc.originalFilename || t('accounting.inbox.untitled', 'Untitled document')} + + {doc.source === 'camera' && } +
+
+ {doc.totalAmountMinor != null ? formatMoneyMinor(doc.totalAmountMinor, doc.currency || 'CHF') : t('accounting.inbox.noAmount', 'amount not entered')} + {' · '} + {format(doc.createdAt)} +
+
+ {doc.status === 'unsorted' && ( + + )} +
+ ))} +
+ )} + + {triageDoc && ( + setTriageDoc(null)} + onDone={() => { + setTriageDoc(null); + qc.invalidateQueries({ queryKey: ['accounting-inbound'] }); + }} + /> + )} +
+ ); +}; + +export default AccountingInboxPage; diff --git a/frontend/src/services/accounting.service.ts b/frontend/src/services/accounting.service.ts new file mode 100644 index 00000000..c60cb27c --- /dev/null +++ b/frontend/src/services/accounting.service.ts @@ -0,0 +1,148 @@ +import { api } from '../config/api'; + +// Mirrors backend transformInbound / transformExpense (camelCase). +export type InboundStatus = 'unsorted' | 'categorized' | 'declined' | 'duplicate'; +export type Disposition = 'rebill' | 'durchlaufend' | 'eigener_aufwand' | 'duplikat' | 'abgelehnt'; +export type TaxTreatment = 'domestic' | 'reverse_charge_service' | 'foreign_vat_non_reclaimable' | 'import_goods'; +export type MarkupType = 'none' | 'percent' | 'flat'; +export type PaymentMethod = 'bank_transfer' | 'cash' | 'twint' | 'paypal' | 'card' | 'other'; + +export interface InboundDocument { + id: number; + source: 'upload' | 'camera' | 'email' | 'manual'; + originalFilename: string | null; + mimeType: string | null; + status: InboundStatus; + parseStatus: 'pending' | 'parsed' | 'failed' | 'manual'; + parseMethod: string | null; + supplierName: string | null; + invoiceNumber: string | null; + invoiceDate: string | null; + dueDate: string | null; + currency: string | null; + netAmountMinor: number | null; + vatAmountMinor: number | null; + totalAmountMinor: number | null; + qrAmountMinor: number | null; + iban: string | null; + paymentReference: string | null; + duplicateOfId: number | null; + createdAt: string; + updatedAt: string; +} + +export interface Expense { + id: number; + inboundDocumentId: number | null; + disposition: Disposition; + taxTreatment: TaxTreatment; + eventId: number | null; + customerAccountId: number | null; + supplierName: string | null; + description: string | null; + chfAmountMinor: number | null; + grossAmountMinor: number | null; + markupType: MarkupType; + markupPercent: number | null; + markupFlatMinor: number | null; + categoryId: number | null; + billedInvoiceId: number | null; + supplierPaid: boolean; + status: 'open' | 'parked' | 'billed' | 'declined'; + createdAt: string; + updatedAt: string; +} + +export interface ExpenseCategory { + id: number; + name: string; + color: string | null; + is_seed: boolean; + display_order: number; +} + +export interface Paginated { + items: T[]; + pagination: { page: number; pageSize: number; total: number; totalPages: number }; +} + +export interface CategorizePayload { + disposition: Disposition; + supplierName?: string | null; + chfAmountMinor?: number | null; + netAmountMinor?: number | null; + vatAmountMinor?: number | null; + grossAmountMinor?: number | null; + taxTreatment?: TaxTreatment; + categoryId?: number | null; + eventId?: number | null; + customerAccountId?: number | null; + declineReason?: string | null; + duplicateOfId?: number | null; + markupType?: MarkupType; + markupPercent?: number | null; + markupFlatMinor?: number | null; +} + +export interface RebillPayload { + customerAccountId: number; + eventId?: number | null; + contractId?: number | null; + markupType?: MarkupType; + markupPercent?: number | null; + markupFlatMinor?: number | null; +} + +export const accountingService = { + async uploadInbound(file: File, source: 'upload' | 'camera' = 'upload'): Promise { + const form = new FormData(); + form.append('file', file); + form.append('source', source); + const { data } = await api.post('/admin/expenses/inbound', form, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + return data.document; + }, + + async listInbound(params: { status?: InboundStatus; page?: number; pageSize?: number } = {}): Promise> { + const { data } = await api.get('/admin/expenses/inbound', { params }); + return data; + }, + + async getInbound(id: number): Promise { + const { data } = await api.get(`/admin/expenses/inbound/${id}`); + return data.document; + }, + + async updateInbound(id: number, fields: Partial>): Promise { + const { data } = await api.patch(`/admin/expenses/inbound/${id}`, fields); + return data.document; + }, + + async categorizeInbound(id: number, payload: CategorizePayload): Promise { + const { data } = await api.post(`/admin/expenses/inbound/${id}/categorize`, payload); + return data.expense; + }, + + async rebill(expenseId: number, payload: RebillPayload): Promise<{ expense: Expense; invoiceId: number }> { + const { data } = await api.post(`/admin/expenses/${expenseId}/rebill`, payload); + return data; + }, + + async setSupplierPayment(expenseId: number, payload: { paid: boolean; paidAt?: string; paymentMethod?: PaymentMethod; paymentReference?: string }): Promise { + const { data } = await api.post(`/admin/expenses/${expenseId}/supplier-payment`, payload); + return data.expense; + }, + + async listExpenses(params: { status?: string; disposition?: Disposition; customerAccountId?: number; eventId?: number; page?: number; pageSize?: number } = {}): Promise> { + const { data } = await api.get('/admin/expenses', { params }); + return data; + }, + + async listCategories(): Promise { + const { data } = await api.get('/admin/expenses/categories'); + return data.items; + }, +}; From 502fbad5a8cb11e075cb098ad05fd94a78aae396 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Thu, 11 Jun 2026 00:38:37 +0200 Subject: [PATCH 05/76] feat(accounting): PDF/image preview in triage, opened at the QR-bill (no OCR) Instead of OCR, let the admin read the payment slip directly: the triage modal now embeds the captured document and, for PDFs, opens at the LAST page scrolled to the Swiss QR-bill area so IBAN/amount/reference are visible while typing. - backend: capture PDF page count at upload via pdf-lib (new inbound_documents.page_count, added to in-flight migration 124); new GET /api/admin/expenses/inbound/:id/file streams the stored file inline (safePath-guarded, nosniff). Raw-serve is acceptable here (admin views own uploads); the hardened rasterise-in-isolated-worker path stays a follow-up. - frontend: getInboundFileBlob fetches the file with Bearer auth as a blob; the triage modal renders it (iframe for PDF with #page=&view=FitH,300, for camera photos) in a two-column layout next to the form. - i18n: accounting.inbox.previewLoading / qrHint (EN + DE). Verified: node -c, require-graph, migration-124 harness (page_count), npm run build green. --- ...4_create_inbound_documents_and_expenses.js | 1 + backend/src/routes/adminExpenses.js | 21 ++++++++ backend/src/services/expenseService.js | 28 +++++++++-- frontend/src/i18n/locales/de.json | 2 + frontend/src/i18n/locales/en.json | 2 + .../admin/accounting/AccountingInboxPage.tsx | 48 +++++++++++++++++-- frontend/src/services/accounting.service.ts | 6 +++ 7 files changed, 101 insertions(+), 7 deletions(-) diff --git a/backend/migrations/core/124_create_inbound_documents_and_expenses.js b/backend/migrations/core/124_create_inbound_documents_and_expenses.js index d494e8e3..88a600c5 100644 --- a/backend/migrations/core/124_create_inbound_documents_and_expenses.js +++ b/backend/migrations/core/124_create_inbound_documents_and_expenses.js @@ -59,6 +59,7 @@ exports.up = async function (knex) { 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 + table.integer('page_count'); // PDF page count (for "jump to last page / QR") // Best-effort parsed fields (assist only — always editable/confirmable): table.string('supplier_name', 255); table.string('invoice_number', 128); diff --git a/backend/src/routes/adminExpenses.js b/backend/src/routes/adminExpenses.js index 04f806af..18ad3214 100644 --- a/backend/src/routes/adminExpenses.js +++ b/backend/src/routes/adminExpenses.js @@ -14,6 +14,8 @@ const { adminAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers'); const { getStoragePath } = require('../config/storage'); +const { createReadStream } = require('fs'); +const { assertPathInside } = require('../utils/safePath'); const { db } = require('../database/db'); const expenseService = require('../services/expenseService'); const expenseCategoriesService = require('../services/expenseCategoriesService'); @@ -116,6 +118,25 @@ router.get('/inbound/:id', requirePermission('accounting.view'), return successResponse(res, { document: await expenseService.getInbound(parseInt(req.params.id, 10)) }); })); +// Stream the stored file for in-browser preview (PDF / image). Admin-only, +// path-containment guarded. NOTE: this serves the raw file inline — the +// locked design's hardened path (rasterise in a network-isolated worker, never +// serve raw) is a follow-up; acceptable here as the admin views their own +// uploaded documents. +router.get('/inbound/:id/file', requirePermission('accounting.view'), + [param('id').isInt({ min: 1 })], + handleAsync(async (req, res) => { + validateRequest(req); + const row = await db('inbound_documents').where({ id: parseInt(req.params.id, 10) }) + .first('file_path', 'mime_type'); + if (!row || !row.file_path) return res.status(404).json({ error: 'File not found', code: 'NO_FILE' }); + const safe = assertPathInside(row.file_path, [path.join(getStoragePath(), 'business-docs')]); + res.setHeader('Content-Type', row.mime_type || 'application/octet-stream'); + res.setHeader('Content-Disposition', 'inline'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + createReadStream(safe).pipe(res); + })); + router.patch('/inbound/:id', requirePermission('accounting.manage'), [param('id').isInt({ min: 1 })], handleAsync(async (req, res) => { diff --git a/backend/src/services/expenseService.js b/backend/src/services/expenseService.js index 18de60c8..67153bea 100644 --- a/backend/src/services/expenseService.js +++ b/backend/src/services/expenseService.js @@ -15,6 +15,7 @@ */ const crypto = require('crypto'); const fsp = require('fs').promises; +const { PDFDocument } = require('pdf-lib'); const { db, logActivity } = require('../database/db'); const { AppError } = require('../utils/errors'); const { hasColumnCached } = require('../utils/schemaCache'); @@ -57,6 +58,7 @@ function transformInbound(row) { parseStatus: row.parse_status, parseMethod: row.parse_method, parseError: row.parse_error, + pageCount: row.page_count, supplierName: row.supplier_name, invoiceNumber: row.invoice_number, invoiceDate: toIsoDate(row.invoice_date), @@ -119,9 +121,21 @@ function clampPage(page, pageSize) { return { p, ps }; } -async function sha256OfFile(filePath) { +// Read the file once: SHA-256 (for dedup) + PDF page count (for the +// "jump to last page / QR" preview). +async function inspectFile(filePath, mimeType) { const buf = await fsp.readFile(filePath); - return crypto.createHash('sha256').update(buf).digest('hex'); + const sha = crypto.createHash('sha256').update(buf).digest('hex'); + let pageCount = null; + if ((mimeType || '').includes('pdf')) { + try { + const pdf = await PDFDocument.load(buf, { updateMetadata: false }); + pageCount = pdf.getPageCount(); + } catch (e) { + logger.warn?.(`expenseService: could not read PDF page count for ${filePath}: ${e.message}`); + } + } + return { sha, pageCount }; } // ── Inbound documents ────────────────────────────────────────────────────── @@ -132,8 +146,13 @@ async function sha256OfFile(filePath) { */ 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 pageCount = null; + try { + const info = await inspectFile(filePath, mimeType); + fileSha256 = info.sha; + pageCount = info.pageCount; + } catch (e) { + logger.warn?.(`expenseService: could not inspect ${filePath}: ${e.message}`); } let duplicateOfId = null; @@ -164,6 +183,7 @@ async function recordInboundDocument({ source, filePath, originalFilename, mimeT parse_status: parse.error ? 'failed' : (parse.parsed ? 'parsed' : 'pending'), parse_method: parse.method || 'none', parse_error: parse.error || null, + page_count: pageCount, supplier_name: f.supplierName || null, invoice_number: f.invoiceNumber || null, invoice_date: f.invoiceDate || null, diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 23f68055..dbf5d8be 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -3428,6 +3428,8 @@ "untitled": "Unbenanntes Dokument", "noAmount": "Betrag nicht erfasst", "rebillHint": "Erstellt eine bearbeitbare geplante Rechnung beim Kunden. MwSt-/Steuerbehandlung ist v1 — mit Treuhänder prüfen.", + "previewLoading": "Vorschau wird geladen…", + "qrHint": "An der letzten Seite geöffnet — der Schweizer QR-Einzahlschein sitzt meist unten.", "status": { "unsorted": "Neu", "categorized": "Kategorisiert", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 06b6f8c4..829fbe83 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -3428,6 +3428,8 @@ "untitled": "Untitled document", "noAmount": "amount not entered", "rebillHint": "Creates an editable scheduled invoice on the client. VAT/tax handling is v1 — verify with your Treuhänder.", + "previewLoading": "Loading preview…", + "qrHint": "Opened at the last page — the Swiss QR-bill usually sits at the bottom.", "status": { "unsorted": "New", "categorized": "Categorized", diff --git a/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx b/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx index 562b40bf..5088d2f9 100644 --- a/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx +++ b/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx @@ -9,7 +9,7 @@ * Parsing is assist-only and currently a no-op on the backend (extractionService * scaffold) — fields are entered/confirmed manually until OCR lands. */ -import React, { useRef, useState } from 'react'; +import React, { useRef, useState, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; @@ -57,6 +57,25 @@ const TriageModal: React.FC<{ const totalMinor = Number.isFinite(amountMajor) ? Math.round(amountMajor * 100) : null; + // Authenticated file preview: fetch as a blob (Bearer auth) and embed. + // PDFs open at the last page scrolled into the QR-bill area (no OCR — the + // admin reads the payment slip and types the fields). + const isPdf = (doc.mimeType || '').includes('pdf'); + const [fileUrl, setFileUrl] = useState(null); + useEffect(() => { + let url: string | null = null; + let cancelled = false; + accountingService.getInboundFileBlob(doc.id) + .then((blob) => { if (!cancelled) { url = URL.createObjectURL(blob); setFileUrl(url); } }) + .catch(() => { if (!cancelled) setFileUrl(null); }); + return () => { cancelled = true; if (url) URL.revokeObjectURL(url); }; + }, [doc.id]); + // #page= jumps to the last page; view=FitH,300 fits the width and + // positions ~300pt from the bottom — the Swiss QR-bill payment part. + const previewSrc = fileUrl + ? (isPdf ? `${fileUrl}#page=${doc.pageCount || 1}&view=FitH,300` : fileUrl) + : null; + const save = useMutation({ mutationFn: async () => { // 1) Confirm the document's fields (assist is never blind-trusted). @@ -102,7 +121,7 @@ const TriageModal: React.FC<{ return (
-
+

{t('accounting.inbox.triageTitle', 'Categorize document')} @@ -112,7 +131,29 @@ const TriageModal: React.FC<{

-
+
+ {/* Document preview — PDFs open at the last page (QR-bill area). */} +
+ {previewSrc ? ( + isPdf ? ( +