From 5e78fb6475513cbda38257b406493eb649a5e33c Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:38:23 +0200 Subject: [PATCH] feat(accounting): backend rework - incoming invoices vs internal expenses (stage 2) Implements the split decided in review: Incoming invoices (external) - the inbound_documents row IS the payable: - categorizeInbound now UPDATES the document (disposition + tax_treatment + booking event_id (null=company) + category), no derived expense row, so a supplier invoice appears only in the incoming-invoices surface. - rebillInbound mints the client invoice from the document (base = invoice total + markup) and links it on the doc. - markInboundSupplierPayment records supplier payment ON the incoming invoice (mark-paid lives here now). Expenses (internal) - own costs only: - createExpense: kind = amount|mileage|per_diem; amount = quantity x rate (rate from accounting settings, per-entry override; snapshotted); optional proof file; booked to an event or the company; require-proof enforced from settings. No supplier payment, always own-cost. - listExpenses returns internal rows only (inbound_document_id IS NULL). Routes: per-flag gating (incomingInvoices vs expenses; categories on the accounting master); supplier-payment + re-bill moved under /inbound/:id/*; POST/PATCH expenses accept a multipart proof upload; GET /:id/proof streams it (PDF download-only, image inline). getAccountingSettings reads app_settings. Verified: node -c, require-graph, 12 unit tests (markup + expense amount/build). Frontend rework (service + the two UIs + settings tab + category i18n) follows. --- .../services/expenseService.markup.test.js | 99 +-- backend/src/routes/adminExpenses.js | 266 ++++---- backend/src/services/expenseService.js | 571 ++++++++---------- 3 files changed, 427 insertions(+), 509 deletions(-) diff --git a/backend/__tests__/services/expenseService.markup.test.js b/backend/__tests__/services/expenseService.markup.test.js index 3b172392..a78a538e 100644 --- a/backend/__tests__/services/expenseService.markup.test.js +++ b/backend/__tests__/services/expenseService.markup.test.js @@ -1,80 +1,87 @@ /** - * Unit tests for the expense money/markup logic — the silently-regressable - * bits of the re-bill flow. Pure functions only (no DB), via _internal. + * Unit tests for the accounting money logic — re-bill markup (incoming + * invoices) and internal-expense amount/build. Pure functions via _internal. */ const expenseService = require('../../src/services/expenseService'); -const { computeMarkupMinor, resolveMarkup, buildExpenseInsert } = expenseService._internal; +const { computeMarkupMinor, resolveMarkup, computeExpenseAmount, buildExpenseInsert } = expenseService._internal; describe('computeMarkupMinor', () => { - it('percent of base, rounded to integer minor units', () => { + it('percent of base, rounded', () => { expect(computeMarkupMinor(10000, { type: 'percent', percent: 10 })).toBe(1000); - expect(computeMarkupMinor(333, { type: 'percent', percent: 10 })).toBe(33); // 33.3 -> 33 - expect(computeMarkupMinor(335, { type: 'percent', percent: 10 })).toBe(34); // 33.5 -> 34 + expect(computeMarkupMinor(333, { type: 'percent', percent: 10 })).toBe(33); + expect(computeMarkupMinor(335, { type: 'percent', percent: 10 })).toBe(34); }); - - it('flat adds the flat minor amount', () => { + it('flat / none', () => { expect(computeMarkupMinor(10000, { type: 'flat', flatMinor: 500 })).toBe(500); - }); - - it('none / missing values add nothing', () => { expect(computeMarkupMinor(10000, { type: 'none' })).toBe(0); expect(computeMarkupMinor(10000, { type: 'percent', percent: null })).toBe(0); - expect(computeMarkupMinor(10000, { type: 'flat', flatMinor: null })).toBe(0); }); }); describe('resolveMarkup precedence (no contract / no DB)', () => { - it('explicit override wins over the expense clause', async () => { - const expense = { markupType: 'flat', markupFlatMinor: 999 }; - const override = { markupType: 'percent', markupPercent: 5 }; - await expect(resolveMarkup(expense, override, null, null)) + it('override > source clause', async () => { + await expect(resolveMarkup({ markupType: 'flat', markupFlatMinor: 999 }, { markupType: 'percent', markupPercent: 5 }, null, null)) .resolves.toEqual({ type: 'percent', percent: 5, flatMinor: null }); }); - - it("falls back to the expense's own clause when no override", async () => { - const expense = { markupType: 'flat', markupFlatMinor: 200 }; - await expect(resolveMarkup(expense, {}, null, null)) + it("source clause when no override", async () => { + await expect(resolveMarkup({ markupType: 'flat', markupFlatMinor: 200 }, {}, null, null)) .resolves.toEqual({ type: 'flat', percent: null, flatMinor: 200 }); }); - - it('defaults to none when nothing is set', async () => { + it('none when nothing set', async () => { await expect(resolveMarkup({ markupType: 'none' }, {}, null, null)) .resolves.toEqual({ type: 'none', percent: null, flatMinor: null }); }); }); -describe('buildExpenseInsert', () => { - it('rejects an unknown disposition', () => { - expect(() => buildExpenseInsert({ disposition: 'bogus' }, 1)).toThrow(/disposition/); +describe('computeExpenseAmount', () => { + it('mileage / per-diem = quantity x rate, rounded', () => { + expect(computeExpenseAmount('mileage', 42, 70, null)).toBe(2940); // 42 km x CHF 0.70 + expect(computeExpenseAmount('per_diem', 3, 8000, null)).toBe(24000); // 3 days x CHF 80 + expect(computeExpenseAmount('mileage', 10.5, 71, null)).toBe(746); // 745.5 -> 746 }); - - it('defaults tax_treatment to domestic and status to open', () => { - const row = buildExpenseInsert({ disposition: 'eigener_aufwand' }, 7); - expect(row.tax_treatment).toBe('domestic'); - expect(row.status).toBe('open'); - expect(row.created_by_admin_id).toBe(7); + it('amount = the entered minor amount', () => { + expect(computeExpenseAmount('amount', null, null, 5000)).toBe(5000); }); + it('null when quantity or rate missing', () => { + expect(computeExpenseAmount('mileage', null, 70, null)).toBeNull(); + expect(computeExpenseAmount('mileage', 42, null, null)).toBeNull(); + }); +}); - it('declined disposition sets status=declined + keeps the reason', () => { - const row = buildExpenseInsert({ disposition: 'abgelehnt', declineReason: 'not ours' }, 1); - expect(row.status).toBe('declined'); - expect(row.decline_reason).toBe('not ours'); +describe('buildExpenseInsert (internal expense)', () => { + it('defaults: kind=amount, disposition=eigener_aufwand, tax=domestic, status=open', () => { + const row = buildExpenseInsert({ chfAmountMinor: 5000 }, 7); + expect(row.kind).toBe('amount'); + expect(row.disposition).toBe('eigener_aufwand'); + expect(row.tax_treatment).toBe('domestic'); + expect(row.status).toBe('open'); + expect(row.chf_amount_minor).toBe(5000); + expect(row.created_by_admin_id).toBe(7); + expect(row.inbound_document_id).toBeNull(); }); + + it('mileage uses the override rate, else the settings km rate', () => { + const withDefault = buildExpenseInsert({ kind: 'mileage', quantity: 42 }, 1, { kmRateMinor: 70 }); + expect(withDefault.rate_minor).toBe(70); + expect(withDefault.chf_amount_minor).toBe(2940); - it('only persists the markup field that matches the markup type', () => { - const pct = buildExpenseInsert({ disposition: 'rebill', markupType: 'percent', markupPercent: 12, markupFlatMinor: 500 }, 1); - expect(pct.markup_percent).toBe(12); - expect(pct.markup_flat_minor).toBeNull(); + const withOverride = buildExpenseInsert({ kind: 'mileage', quantity: 42, rateMinor: 100 }, 1, { kmRateMinor: 70 }); + expect(withOverride.rate_minor).toBe(100); + expect(withOverride.chf_amount_minor).toBe(4200); + }); - const flat = buildExpenseInsert({ disposition: 'rebill', markupType: 'flat', markupPercent: 12, markupFlatMinor: 500 }, 1); - expect(flat.markup_flat_minor).toBe(500); - expect(flat.markup_percent).toBeNull(); + it('per_diem uses days x per-diem rate', () => { + const row = buildExpenseInsert({ kind: 'per_diem', quantity: 2 }, 1, { perDiemRateMinor: 8000 }); + expect(row.rate_minor).toBe(8000); + expect(row.chf_amount_minor).toBe(16000); }); - it('parked flag maps to status=parked', () => { - const row = buildExpenseInsert({ disposition: 'rebill', unbilledParked: true }, 1); - expect(row.status).toBe('parked'); - expect(row.unbilled_parked).toBe(true); + it('event_id null = booked to company; proof path carried', () => { + const company = buildExpenseInsert({ kind: 'amount', chfAmountMinor: 100 }, 1, { receiptPath: '/p/x.pdf' }); + expect(company.event_id).toBeNull(); + expect(company.receipt_path).toBe('/p/x.pdf'); + const evt = buildExpenseInsert({ kind: 'amount', chfAmountMinor: 100, eventId: 9 }, 1); + expect(evt.event_id).toBe(9); }); }); diff --git a/backend/src/routes/adminExpenses.js b/backend/src/routes/adminExpenses.js index 4839f3fb..8c6c761a 100644 --- a/backend/src/routes/adminExpenses.js +++ b/backend/src/routes/adminExpenses.js @@ -1,134 +1,119 @@ /** - * Admin Accounting routes — inbound supplier invoices + expenses + re-bill. + * Admin Accounting routes. * - * 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. + * /inbound/* → Incoming invoices (external supplier invoices). Gated by the + * `incomingInvoices` flag. Disposition, supplier-payment and + * re-bill all act on the document itself. + * / → Expenses (internal). Gated by the `expenses` flag. Create + * accepts an optional proof upload (required when the accounting + * setting says so). + * /categories → expense categories. Gated by the `accounting` master. + * + * camelCase API; money in 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 { createReadStream } = require('fs'); 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 rasterizeService = require('../services/rasterizeService'); const expenseCategoriesService = require('../services/expenseCategoriesService'); +const rasterizeService = require('../services/rasterizeService'); 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')); - }, -}); +const ALLOWED_MIME = ['application/pdf', 'image/jpeg', 'image/png']; -// 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: 'incomingInvoices' }).first(); - const enabled = row && (row.value === true || row.value === 1 || row.value === '1'); - if (!enabled) return res.status(403).json({ error: 'Incoming invoices feature is disabled', code: 'INCOMING_INVOICES_DISABLED' }); - return next(); - } catch (err) { return next(err); } +function diskUpload(subdir) { + return multer({ + storage: multer.diskStorage({ + destination: async (_req, _file, cb) => { + const dir = path.join(getStoragePath(), 'business-docs', subdir, String(new Date().getFullYear())); + await fs.mkdir(dir, { recursive: true }); + cb(null, dir); + }, + filename: (_req, file, cb) => cb(null, `${subdir.split('/').pop()}-${Date.now()}${path.extname(file.originalname) || ''}`), + }), + limits: { fileSize: 15 * 1024 * 1024 }, + fileFilter: (_req, file, cb) => (ALLOWED_MIME.includes(file.mimetype) ? cb(null, true) : cb(new Error('Only PDF, JPEG or PNG files are allowed'))), + }); } +const inboundUpload = diskUpload('inbound'); +const proofUpload = diskUpload('expenses/proof'); + +function requireFlag(key, code) { + return async (req, res, next) => { + try { + const row = await db('feature_flags').where({ key }).first(); + const enabled = row && (row.value === true || row.value === 1 || row.value === '1'); + if (!enabled) return res.status(403).json({ error: `${key} feature is disabled`, code }); + return next(); + } catch (err) { return next(err); } + }; +} +const requireIncoming = requireFlag('incomingInvoices', 'INCOMING_INVOICES_DISABLED'); +const requireExpenses = requireFlag('expenses', 'EXPENSES_DISABLED'); +const requireAccounting = requireFlag('accounting', 'ACCOUNTING_DISABLED'); router.use(adminAuth); -router.use(requireIncomingInvoicesFlag); -// ── Expense categories (literal path — register BEFORE '/:id') ────────────── -router.get('/categories', requirePermission('accounting.view'), handleAsync(async (_req, res) => { - return successResponse(res, { items: await expenseCategoriesService.list() }); -})); +const toInt = (v) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n : undefined; }; -router.post('/categories', requirePermission('accounting.manage'), +// ── Expense categories (accounting master) ────────────────────────────────── +router.get('/categories', requireAccounting, requirePermission('accounting.view'), handleAsync(async (_req, res) => + successResponse(res, { items: await expenseCategoriesService.list() }))); + +router.post('/categories', requireAccounting, 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'); + return successResponse(res, { category: await expenseCategoriesService.create(req.body, req.admin.id) }, 201, 'Category created'); })); -router.patch('/categories/:id', requirePermission('accounting.manage'), +router.patch('/categories/:id', requireAccounting, 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 }); + return successResponse(res, { category: await expenseCategoriesService.update(toInt(req.params.id), req.body) }); })); -router.delete('/categories/:id', requirePermission('accounting.manage'), +router.delete('/categories/:id', requireAccounting, 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))); + return successResponse(res, await expenseCategoriesService.remove(toInt(req.params.id))); })); -// ── Inbound documents (literal path — register BEFORE '/:id') ─────────────── -router.post('/inbound', requirePermission('accounting.manage'), +// ── Incoming invoices (external) ──────────────────────────────────────────── +router.post('/inbound', requireIncoming, 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, + const document = 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'); + return successResponse(res, { document }, 201, 'Document captured'); })); -router.get('/inbound', requirePermission('accounting.view'), +router.get('/inbound', requireIncoming, 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)); - })); + handleAsync(async (req, res) => { validateRequest(req); return successResponse(res, await expenseService.listInbound(req.query)); })); -router.get('/inbound/:id', requirePermission('accounting.view'), +router.get('/inbound/:id/file', requireIncoming, 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)) }); - })); - -// Stream the stored file. Images render inline (already flat raster); PDFs are -// served as a DOWNLOAD only and are NEVER rendered inline in the browser — -// inline PDF preview goes through the rasterised /page/:n images below so a -// malicious PDF can't execute JS or phone home in the admin's session. -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'); + const row = await db('inbound_documents').where({ id: toInt(req.params.id) }).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')]); const isPdf = (row.mime_type || '').includes('pdf'); @@ -139,18 +124,15 @@ router.get('/inbound/:id/file', requirePermission('accounting.view'), createReadStream(safe).pipe(res); })); -// Rasterised PDF page (PNG) — the ONLY way a PDF is shown in-browser. The raw -// PDF never reaches the client. CSP-locked + nosniff. -router.get('/inbound/:id/page/:n', requirePermission('accounting.view'), +router.get('/inbound/:id/page/:n', requireIncoming, requirePermission('accounting.view'), [param('id').isInt({ min: 1 }), param('n').isInt({ min: 1 })], handleAsync(async (req, res) => { validateRequest(req); - const id = parseInt(req.params.id, 10); + const id = toInt(req.params.id); const row = await db('inbound_documents').where({ id }).first('file_path', 'mime_type', 'page_count'); if (!row || !row.file_path) return res.status(404).json({ error: 'File not found', code: 'NO_FILE' }); if (!(row.mime_type || '').includes('pdf')) return res.status(415).json({ error: 'Not a PDF', code: 'NOT_PDF' }); - const maxPage = row.page_count || 1; - const page = Math.min(Math.max(1, parseInt(req.params.n, 10)), maxPage); + const page = Math.min(Math.max(1, toInt(req.params.n)), row.page_count || 1); const srcPdf = assertPathInside(row.file_path, [path.join(getStoragePath(), 'business-docs')]); const pngPath = await rasterizeService.getRenderedPagePath(id, srcPdf, page); const safePng = assertPathInside(pngPath, [path.join(getStoragePath(), 'business-docs')]); @@ -161,72 +143,80 @@ router.get('/inbound/:id/page/:n', requirePermission('accounting.view'), createReadStream(safePng).pipe(res); })); -router.patch('/inbound/:id', requirePermission('accounting.manage'), +router.get('/inbound/:id', requireIncoming, requirePermission('accounting.view'), [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 }); - })); + handleAsync(async (req, res) => { validateRequest(req); return successResponse(res, { document: await expenseService.getInbound(toInt(req.params.id)) }); })); -router.post('/inbound/:id/categorize', requirePermission('accounting.manage'), +router.patch('/inbound/:id', requireIncoming, requirePermission('accounting.manage'), + [param('id').isInt({ min: 1 })], + handleAsync(async (req, res) => { validateRequest(req); return successResponse(res, { document: await expenseService.updateInbound(toInt(req.params.id), req.body, req.admin.id) }); })); + +router.post('/inbound/:id/categorize', requireIncoming, 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'); - })); + handleAsync(async (req, res) => { validateRequest(req); return successResponse(res, { document: await expenseService.categorizeInbound(toInt(req.params.id), req.body, req.admin.id) }, 200, 'Categorized'); })); -// ── 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'), +router.post('/inbound/:id/rebill', requireIncoming, 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('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); return successResponse(res, await expenseService.rebillInbound(toInt(req.params.id), req.body, req.admin.id), 201, 'Re-billed'); })); + +router.post('/inbound/:id/supplier-payment', requireIncoming, 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); return successResponse(res, { document: await expenseService.markInboundSupplierPayment(toInt(req.params.id), req.body, req.admin.id) }); })); + +// ── Expenses (internal) ───────────────────────────────────────────────────── +router.get('/', requireExpenses, requirePermission('accounting.view'), + [query('kind').optional().isIn(expenseService.EXPENSE_KINDS), query('categoryId').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('/', requireExpenses, requirePermission('accounting.manage'), + proofUpload.single('proof'), + [body('kind').optional().isIn(expenseService.EXPENSE_KINDS)], 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'); + const b = req.body; + const payload = { + kind: b.kind || 'amount', + quantity: b.quantity !== undefined && b.quantity !== '' ? Number(b.quantity) : undefined, + rateMinor: toInt(b.rateMinor), + chfAmountMinor: toInt(b.chfAmountMinor), + eventId: toInt(b.eventId) || null, + categoryId: toInt(b.categoryId) || null, + supplierName: b.supplierName || null, + description: b.description || null, + taxTreatment: b.taxTreatment, + }; + const expense = await expenseService.createExpense(payload, req.admin.id, { receiptPath: req.file ? req.file.path : null }); + return successResponse(res, { expense }, 201, 'Expense created'); })); -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)], +router.get('/:id/proof', requireExpenses, requirePermission('accounting.view'), + [param('id').isInt({ min: 1 })], handleAsync(async (req, res) => { validateRequest(req); - const expense = await expenseService.setSupplierPayment(parseInt(req.params.id, 10), req.body, req.admin.id); + const row = await db('expenses').where({ id: toInt(req.params.id) }).first('receipt_path'); + if (!row || !row.receipt_path) return res.status(404).json({ error: 'No proof', code: 'NO_PROOF' }); + const safe = assertPathInside(row.receipt_path, [path.join(getStoragePath(), 'business-docs')]); + const isPdf = safe.toLowerCase().endsWith('.pdf'); + res.setHeader('Content-Type', isPdf ? 'application/pdf' : 'application/octet-stream'); + res.setHeader('Content-Disposition', isPdf ? 'attachment' : 'inline'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + if (!isPdf) res.setHeader('Content-Security-Policy', "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'"); + createReadStream(safe).pipe(res); + })); + +router.get('/:id', requireExpenses, requirePermission('accounting.view'), + [param('id').isInt({ min: 1 })], + handleAsync(async (req, res) => { validateRequest(req); return successResponse(res, { expense: await expenseService.getExpense(toInt(req.params.id)) }); })); + +router.patch('/:id', requireExpenses, requirePermission('accounting.manage'), + proofUpload.single('proof'), + [param('id').isInt({ min: 1 })], + handleAsync(async (req, res) => { + validateRequest(req); + const expense = await expenseService.updateExpense(toInt(req.params.id), req.body, req.admin.id, { receiptPath: req.file ? req.file.path : null }); return successResponse(res, { expense }); })); diff --git a/backend/src/services/expenseService.js b/backend/src/services/expenseService.js index e3b70cd2..0157d0b8 100644 --- a/backend/src/services/expenseService.js +++ b/backend/src/services/expenseService.js @@ -1,24 +1,24 @@ /** - * Expense / inbound-document service (migration 124). + * Accounting service — two separate concepts (split in migration 126): * - * 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. + * INCOMING INVOICES (external) → the `inbound_documents` row IS the payable. + * It carries its own disposition, supplier-payment, booking (event_id, NULL + * = company) and re-bill linkage. A supplier invoice never creates an + * `expenses` row, so it appears ONLY in the incoming-invoices surface. * - * 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. + * EXPENSES (internal) → `expenses` rows are own costs entered by + * staff: kind = amount | mileage(km) | per_diem, amount = quantity x rate + * (rate from accounting settings, per-entry override), optional proof file, + * booked to an event or the company. No supplier payment (you incur these). + * + * Money is integer minor units. VAT/tax handling is v1 (capture only) — verify + * with a Treuhaender. */ 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'); const logger = require('../utils/logger'); const invoiceService = require('./invoiceService'); @@ -26,8 +26,8 @@ const DISPOSITIONS = ['rebill', 'durchlaufend', 'eigener_aufwand', 'duplikat', ' 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']; +const EXPENSE_KINDS = ['amount', 'mileage', 'per_diem']; -// Disposition → inbound_documents.status once categorised. const DISPOSITION_DOC_STATUS = { rebill: 'categorized', durchlaufend: 'categorized', @@ -39,14 +39,30 @@ const DISPOSITION_DOC_STATUS = { 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 + return String(v).slice(0, 10); } -function parseTags(raw) { - if (!raw) return []; - try { const a = JSON.parse(raw); return Array.isArray(a) ? a : []; } catch (_e) { return []; } +// ── Accounting settings (app_settings, type 'accounting') ─────────────────── +async function getAccountingSettings() { + const keys = ['accounting_km_rate_minor', 'accounting_per_diem_rate_minor', 'accounting_require_proof']; + let rows = []; + try { + rows = await db('app_settings').whereIn('setting_key', keys).select('setting_key', 'setting_value'); + } catch (_e) { /* table may not exist in some test harnesses */ } + const map = {}; + for (const r of rows) { + let v = r.setting_value; + if (typeof v === 'string') { try { v = JSON.parse(v); } catch (_e) { /* keep raw */ } } + map[r.setting_key] = v; + } + return { + kmRateMinor: Number.isFinite(Number(map.accounting_km_rate_minor)) ? Number(map.accounting_km_rate_minor) : 0, + perDiemRateMinor: Number.isFinite(Number(map.accounting_per_diem_rate_minor)) ? Number(map.accounting_per_diem_rate_minor) : 0, + requireProof: map.accounting_require_proof === true || map.accounting_require_proof === 1 || map.accounting_require_proof === '1', + }; } +// ── Incoming invoices (inbound_documents) ─────────────────────────────────── function transformInbound(row) { if (!row) return null; return { @@ -57,7 +73,6 @@ function transformInbound(row) { status: row.status, parseStatus: row.parse_status, parseMethod: row.parse_method, - parseError: row.parse_error, pageCount: row.page_count, supplierName: row.supplier_name, invoiceNumber: row.invoice_number, @@ -71,45 +86,21 @@ function transformInbound(row) { 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, + // classification + booking (migration 126) 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, + categoryId: row.category_id, 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, + // supplier payment (paid on the incoming invoice itself) supplierPaid: !!row.supplier_paid, supplierPaidAt: row.supplier_paid_at, - paymentMethod: row.payment_method, - paymentReference: row.payment_reference, - declineReason: row.decline_reason, - status: row.status, + supplierPaymentMethod: row.supplier_payment_method, + supplierPaymentRef: row.supplier_payment_ref, createdAt: row.created_at, updatedAt: row.updated_at, }; @@ -121,8 +112,6 @@ function clampPage(page, pageSize) { return { p, ps }; } -// 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); const sha = crypto.createHash('sha256').update(buf).digest('hex'); @@ -132,25 +121,18 @@ async function inspectFile(filePath, mimeType) { 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}`); + logger.warn?.(`expenseService: PDF page count failed for ${filePath}: ${e.message}`); } } return { sha, pageCount }; } -// ── 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; let pageCount = null; try { const info = await inspectFile(filePath, mimeType); - fileSha256 = info.sha; - pageCount = info.pageCount; + fileSha256 = info.sha; pageCount = info.pageCount; } catch (e) { logger.warn?.(`expenseService: could not inspect ${filePath}: ${e.message}`); } @@ -161,17 +143,6 @@ async function recordInboundDocument({ source, filePath, originalFilename, mimeT 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', @@ -180,22 +151,9 @@ async function recordInboundDocument({ source, filePath, originalFilename, mimeT 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, + parse_status: 'pending', + parse_method: 'none', page_count: pageCount, - 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, @@ -203,13 +161,13 @@ async function recordInboundDocument({ source, filePath, originalFilename, mimeT }; 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); + await logActivity('incoming_invoice_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'); + if (!row) throw new AppError('Incoming invoice not found', 404, 'INBOUND_NOT_FOUND'); return transformInbound(row); } @@ -219,13 +177,8 @@ async function listInbound({ status, page, pageSize } = {}) { 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 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 = { @@ -235,7 +188,6 @@ const INBOUND_EDITABLE = { 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' }; @@ -243,50 +195,186 @@ async function updateInbound(id, payload, adminId) { 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); + await logActivity('incoming_invoice_updated', { inboundDocumentId: id }, adminId); return getInbound(id); } -// ── Expenses ─────────────────────────────────────────────────────────────── +// markup helpers (shared with re-bill) +async function resolveMarkup(source, 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 (source && source.markupType && source.markupType !== 'none') { + return pick(source.markupType, source.markupPercent, source.markupFlatMinor); + } + const { hasColumnCached } = require('../utils/schemaCache'); + 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 buildExpenseInsert(payload, adminId) { - const now = new Date(); +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 incoming invoice to a client (mints an editable scheduled invoice). */ +async function rebillInbound(id, payload, adminId, trx0) { + const run = async (trx) => { + const row = await trx('inbound_documents').where({ id }).first(); + if (!row) throw new AppError('Incoming invoice not found', 404, 'INBOUND_NOT_FOUND'); + const doc = transformInbound(row); + if (doc.billedInvoiceId) throw new AppError('Already re-billed', 409, 'ALREADY_BILLED'); + if (!payload.customerAccountId) throw new AppError('customerAccountId is required to re-bill', 400, 'CUSTOMER_REQUIRED'); + const base = doc.totalAmountMinor != null ? doc.totalAmountMinor : doc.netAmountMinor; + if (base == null) throw new AppError('Incoming invoice has no amount to re-bill', 400, 'AMOUNT_REQUIRED'); + + const markup = await resolveMarkup( + { markupType: doc.markupType, markupPercent: doc.markupPercent, markupFlatMinor: doc.markupFlatMinor }, + payload, payload.contractId, trx, + ); + const lineTotal = base + computeMarkupMinor(base, markup); + const label = doc.supplierName || 'Weiterverrechnete Auslage'; + const { invoiceIds } = await invoiceService.createInvoice({ + customerAccountId: payload.customerAccountId, + eventId: payload.eventId || doc.eventId || null, + lineItems: [{ description: `${label} (Weiterverrechnung)`, quantity: 1, unit_price_minor: lineTotal, discount_percent: 0, line_total_minor: lineTotal }], + }, 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'); + const line = await trx('invoice_line_items').where({ invoice_id: invoiceId }).orderBy('id', 'desc').first('id'); + + await trx('inbound_documents').where({ id }).update({ + disposition: 'rebill', + status: 'categorized', + event_id: payload.eventId || doc.eventId || null, + 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, + updated_at: new Date(), + }); + await logActivity('incoming_invoice_rebilled', { inboundDocumentId: id, invoiceId }, adminId); + return invoiceId; + }; + const invoiceId = trx0 ? await run(trx0) : await db.transaction(run); + return { document: await getInbound(id), invoiceId }; +} + +/** Give an incoming invoice a disposition (updates the document, no expense row). */ +async function categorizeInbound(id, payload, adminId) { + const doc = await getInbound(id); 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, + if (disposition === 'rebill') { + const { document } = await rebillInbound(id, payload, adminId); + // also stamp tax_treatment/category/event from payload + await db('inbound_documents').where({ id }).update({ + tax_treatment: TAX_TREATMENTS.includes(payload.taxTreatment) ? payload.taxTreatment : (document.taxTreatment || 'domestic'), + category_id: payload.categoryId || null, + updated_at: new Date(), + }); + return getInbound(id); + } + const patch = { disposition, - tax_treatment: taxTreatment, - event_id: payload.eventId || null, - customer_account_id: payload.customerAccountId || null, + tax_treatment: TAX_TREATMENTS.includes(payload.taxTreatment) ? payload.taxTreatment : 'domestic', + event_id: payload.eventId || null, // null = company + category_id: disposition === 'eigener_aufwand' ? (payload.categoryId || null) : null, + status: DISPOSITION_DOC_STATUS[disposition] || 'categorized', + updated_at: new Date(), + }; + if (disposition === 'duplikat' && payload.duplicateOfId) patch.duplicate_of_id = payload.duplicateOfId; + await db('inbound_documents').where({ id }).update(patch); + await logActivity('incoming_invoice_categorized', { inboundDocumentId: id, disposition }, adminId); + return getInbound(id); +} + +/** Mark the supplier paid on the incoming invoice (the payable lives here). */ +async function markInboundSupplierPayment(id, { paid, paidAt, paymentMethod, paymentReference }, adminId) { + await getInbound(id); + if (paymentMethod && !PAYMENT_METHODS.includes(paymentMethod)) { + throw new AppError(`paymentMethod must be one of ${PAYMENT_METHODS.join(', ')}`, 400, 'BAD_PAYMENT_METHOD'); + } + await db('inbound_documents').where({ id }).update({ + supplier_paid: !!paid, + supplier_paid_at: paid ? (paidAt ? new Date(paidAt) : new Date()) : null, + supplier_payment_method: paid ? (paymentMethod || null) : null, + supplier_payment_ref: paid ? (paymentReference || null) : null, + updated_at: new Date(), + }); + await logActivity('incoming_invoice_supplier_payment', { inboundDocumentId: id, paid: !!paid }, adminId); + return getInbound(id); +} + +// ── Expenses (internal) ───────────────────────────────────────────────────── +function transformExpense(row) { + if (!row) return null; + return { + id: row.id, + kind: row.kind || 'amount', + quantity: row.quantity != null ? Number(row.quantity) : null, + rateMinor: row.rate_minor, + eventId: row.event_id, // null = company + supplierName: row.supplier_name, + description: row.description, + chfAmountMinor: row.chf_amount_minor, + categoryId: row.category_id, + receiptPath: row.receipt_path, + hasProof: !!row.receipt_path, + taxTreatment: row.tax_treatment, + status: row.status, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +/** Compute the booked amount (minor) for an internal expense. */ +function computeExpenseAmount(kind, quantity, rateMinor, amountMinor) { + if (kind === 'mileage' || kind === 'per_diem') { + if (quantity != null && rateMinor != null) return Math.round(Number(quantity) * Number(rateMinor)); + return null; + } + return Number.isInteger(amountMinor) ? amountMinor : null; +} + +function buildExpenseInsert(payload, adminId, opts = {}) { + const now = new Date(); + const kind = EXPENSE_KINDS.includes(payload.kind) ? payload.kind : 'amount'; + let rateMinor = null; + if (kind === 'mileage') rateMinor = Number.isInteger(payload.rateMinor) ? payload.rateMinor : (opts.kmRateMinor ?? null); + else if (kind === 'per_diem') rateMinor = Number.isInteger(payload.rateMinor) ? payload.rateMinor : (opts.perDiemRateMinor ?? null); + const quantity = (kind === 'mileage' || kind === 'per_diem') && payload.quantity != null ? Number(payload.quantity) : null; + const chf = computeExpenseAmount(kind, quantity, rateMinor, payload.chfAmountMinor); + return { + inbound_document_id: null, + disposition: 'eigener_aufwand', // internal expenses are always own-cost + tax_treatment: TAX_TREATMENTS.includes(payload.taxTreatment) ? payload.taxTreatment : 'domestic', + event_id: payload.eventId || null, // null = company 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, + kind, + quantity, + rate_minor: rateMinor, + chf_amount_minor: chf, + gross_amount_minor: chf, 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, + receipt_path: opts.receiptPath || null, + status: 'open', created_by_admin_id: adminId || null, created_at: now, updated_at: now, @@ -299,240 +387,73 @@ async function getExpense(id) { return transformExpense(row); } -async function createManualExpense(payload, adminId) { - const row = buildExpenseInsert(payload, adminId); +async function createExpense(payload, adminId, { receiptPath } = {}) { + const settings = await getAccountingSettings(); + if (settings.requireProof && !receiptPath) { + throw new AppError('A proof file is required for expenses', 400, 'PROOF_REQUIRED'); + } + const row = buildExpenseInsert(payload, adminId, { + receiptPath, + kmRateMinor: settings.kmRateMinor, + perDiemRateMinor: settings.perDiemRateMinor, + }); 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); + await logActivity('expense_created', { expenseId: id, kind: row.kind }, 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 } = {}) { +async function listExpenses({ kind, eventId, categoryId, 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 base = db('expenses').where({ inbound_document_id: null }); // internal only + if (kind) base.where({ kind }); + if (categoryId) base.where({ category_id: categoryId }); + if (eventId === 'company') base.whereNull('event_id'); + else 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 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', + eventId: 'event_id', categoryId: 'category_id', }; -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'); - } +async function updateExpense(id, payload, adminId, { receiptPath } = {}) { + await getExpense(id); 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'); - } + if (receiptPath) patch.receipt_path = receiptPath; 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 + getAccountingSettings, + // incoming invoices recordInboundDocument, getInbound, listInbound, updateInbound, categorizeInbound, + rebillInbound, + markInboundSupplierPayment, // expenses - createManualExpense, + createExpense, getExpense, listExpenses, updateExpense, - setSupplierPayment, - rebillToEvent, - // constants (for route validators) + // constants DISPOSITIONS, TAX_TREATMENTS, MARKUP_TYPES, PAYMENT_METHODS, - // exposed for unit tests - _internal: { computeMarkupMinor, resolveMarkup, buildExpenseInsert, transformExpense, transformInbound }, + EXPENSE_KINDS, + // unit-test surface + _internal: { computeMarkupMinor, resolveMarkup, computeExpenseAmount, buildExpenseInsert, transformExpense, transformInbound }, };