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] 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.