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.
This commit is contained in:
Luca
2026-06-11 12:38:23 +02:00
parent c59df52d40
commit 5e78fb6475
3 changed files with 427 additions and 509 deletions
@@ -1,80 +1,87 @@
/** /**
* Unit tests for the expense money/markup logic — the silently-regressable * Unit tests for the accounting money logic — re-bill markup (incoming
* bits of the re-bill flow. Pure functions only (no DB), via _internal. * invoices) and internal-expense amount/build. Pure functions via _internal.
*/ */
const expenseService = require('../../src/services/expenseService'); const expenseService = require('../../src/services/expenseService');
const { computeMarkupMinor, resolveMarkup, buildExpenseInsert } = expenseService._internal; const { computeMarkupMinor, resolveMarkup, computeExpenseAmount, buildExpenseInsert } = expenseService._internal;
describe('computeMarkupMinor', () => { 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(10000, { type: 'percent', percent: 10 })).toBe(1000);
expect(computeMarkupMinor(333, { type: 'percent', percent: 10 })).toBe(33); // 33.3 -> 33 expect(computeMarkupMinor(333, { type: 'percent', percent: 10 })).toBe(33);
expect(computeMarkupMinor(335, { type: 'percent', percent: 10 })).toBe(34); // 33.5 -> 34 expect(computeMarkupMinor(335, { type: 'percent', percent: 10 })).toBe(34);
}); });
it('flat / none', () => {
it('flat adds the flat minor amount', () => {
expect(computeMarkupMinor(10000, { type: 'flat', flatMinor: 500 })).toBe(500); 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: 'none' })).toBe(0);
expect(computeMarkupMinor(10000, { type: 'percent', percent: null })).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)', () => { describe('resolveMarkup precedence (no contract / no DB)', () => {
it('explicit override wins over the expense clause', async () => { it('override > source clause', async () => {
const expense = { markupType: 'flat', markupFlatMinor: 999 }; await expect(resolveMarkup({ markupType: 'flat', markupFlatMinor: 999 }, { markupType: 'percent', markupPercent: 5 }, null, null))
const override = { markupType: 'percent', markupPercent: 5 };
await expect(resolveMarkup(expense, override, null, null))
.resolves.toEqual({ type: 'percent', percent: 5, flatMinor: null }); .resolves.toEqual({ type: 'percent', percent: 5, flatMinor: null });
}); });
it("source clause when no override", async () => {
it("falls back to the expense's own clause when no override", async () => { await expect(resolveMarkup({ markupType: 'flat', markupFlatMinor: 200 }, {}, null, null))
const expense = { markupType: 'flat', markupFlatMinor: 200 };
await expect(resolveMarkup(expense, {}, null, null))
.resolves.toEqual({ type: 'flat', percent: null, flatMinor: 200 }); .resolves.toEqual({ type: 'flat', percent: null, flatMinor: 200 });
}); });
it('none when nothing set', async () => {
it('defaults to none when nothing is set', async () => {
await expect(resolveMarkup({ markupType: 'none' }, {}, null, null)) await expect(resolveMarkup({ markupType: 'none' }, {}, null, null))
.resolves.toEqual({ type: 'none', percent: null, flatMinor: null }); .resolves.toEqual({ type: 'none', percent: null, flatMinor: null });
}); });
}); });
describe('buildExpenseInsert', () => { describe('computeExpenseAmount', () => {
it('rejects an unknown disposition', () => { it('mileage / per-diem = quantity x rate, rounded', () => {
expect(() => buildExpenseInsert({ disposition: 'bogus' }, 1)).toThrow(/disposition/); 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('amount = the entered minor amount', () => {
it('defaults tax_treatment to domestic and status to open', () => { expect(computeExpenseAmount('amount', null, null, 5000)).toBe(5000);
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('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', () => { describe('buildExpenseInsert (internal expense)', () => {
const row = buildExpenseInsert({ disposition: 'abgelehnt', declineReason: 'not ours' }, 1); it('defaults: kind=amount, disposition=eigener_aufwand, tax=domestic, status=open', () => {
expect(row.status).toBe('declined'); const row = buildExpenseInsert({ chfAmountMinor: 5000 }, 7);
expect(row.decline_reason).toBe('not ours'); 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 withOverride = buildExpenseInsert({ kind: 'mileage', quantity: 42, rateMinor: 100 }, 1, { kmRateMinor: 70 });
const pct = buildExpenseInsert({ disposition: 'rebill', markupType: 'percent', markupPercent: 12, markupFlatMinor: 500 }, 1); expect(withOverride.rate_minor).toBe(100);
expect(pct.markup_percent).toBe(12); expect(withOverride.chf_amount_minor).toBe(4200);
expect(pct.markup_flat_minor).toBeNull(); });
const flat = buildExpenseInsert({ disposition: 'rebill', markupType: 'flat', markupPercent: 12, markupFlatMinor: 500 }, 1); it('per_diem uses days x per-diem rate', () => {
expect(flat.markup_flat_minor).toBe(500); const row = buildExpenseInsert({ kind: 'per_diem', quantity: 2 }, 1, { perDiemRateMinor: 8000 });
expect(flat.markup_percent).toBeNull(); expect(row.rate_minor).toBe(8000);
expect(row.chf_amount_minor).toBe(16000);
}); });
it('parked flag maps to status=parked', () => { it('event_id null = booked to company; proof path carried', () => {
const row = buildExpenseInsert({ disposition: 'rebill', unbilledParked: true }, 1); const company = buildExpenseInsert({ kind: 'amount', chfAmountMinor: 100 }, 1, { receiptPath: '/p/x.pdf' });
expect(row.status).toBe('parked'); expect(company.event_id).toBeNull();
expect(row.unbilled_parked).toBe(true); expect(company.receipt_path).toBe('/p/x.pdf');
const evt = buildExpenseInsert({ kind: 'amount', chfAmountMinor: 100, eventId: 9 }, 1);
expect(evt.event_id).toBe(9);
}); });
}); });
+120 -130
View File
@@ -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` / * /inbound/* → Incoming invoices (external supplier invoices). Gated by the
* `accounting.manage` permissions. camelCase API ↔ camelCase service payloads * `incomingInvoices` flag. Disposition, supplier-payment and
* (the service maps to snake_case columns). Money is integer minor units. * 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 express = require('express');
const { body, param, query } = require('express-validator'); const { body, param, query } = require('express-validator');
const multer = require('multer'); const multer = require('multer');
const path = require('path'); const path = require('path');
const fs = require('fs').promises; const fs = require('fs').promises;
const { createReadStream } = require('fs');
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions'); const { requirePermission } = require('../middleware/permissions');
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers'); const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const { getStoragePath } = require('../config/storage'); const { getStoragePath } = require('../config/storage');
const { createReadStream } = require('fs');
const { assertPathInside } = require('../utils/safePath'); const { assertPathInside } = require('../utils/safePath');
const { db } = require('../database/db'); const { db } = require('../database/db');
const expenseService = require('../services/expenseService'); const expenseService = require('../services/expenseService');
const rasterizeService = require('../services/rasterizeService');
const expenseCategoriesService = require('../services/expenseCategoriesService'); const expenseCategoriesService = require('../services/expenseCategoriesService');
const rasterizeService = require('../services/rasterizeService');
const router = express.Router(); const router = express.Router();
// Inbound documents accept PDFs AND images (phone/tablet camera capture). const ALLOWED_MIME = ['application/pdf', 'image/jpeg', 'image/png'];
const inboundStorage = multer.diskStorage({
function diskUpload(subdir) {
return multer({
storage: multer.diskStorage({
destination: async (_req, _file, cb) => { destination: async (_req, _file, cb) => {
const year = new Date().getFullYear(); const dir = path.join(getStoragePath(), 'business-docs', subdir, String(new Date().getFullYear()));
const dir = path.join(getStoragePath(), 'business-docs', 'inbound', String(year));
await fs.mkdir(dir, { recursive: true }); await fs.mkdir(dir, { recursive: true });
cb(null, dir); cb(null, dir);
}, },
filename: (_req, file, cb) => { filename: (_req, file, cb) => cb(null, `${subdir.split('/').pop()}-${Date.now()}${path.extname(file.originalname) || ''}`),
const ext = path.extname(file.originalname) || ''; }),
cb(null, `inbound-${Date.now()}${ext}`); 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 INBOUND_MIME = ['application/pdf', 'image/jpeg', 'image/png']; }
const inboundUpload = multer({ const inboundUpload = diskUpload('inbound');
storage: inboundStorage, const proofUpload = diskUpload('expenses/proof');
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'));
},
});
// Gated on the `incomingInvoices` sub-feature, which the feature-flag function requireFlag(key, code) {
// dependency rules force OFF whenever the `accounting` master is off — so return async (req, res, next) => {
// this single check covers both.
async function requireIncomingInvoicesFlag(req, res, next) {
try { try {
const row = await db('feature_flags').where({ key: 'incomingInvoices' }).first(); const row = await db('feature_flags').where({ key }).first();
const enabled = row && (row.value === true || row.value === 1 || row.value === '1'); 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' }); if (!enabled) return res.status(403).json({ error: `${key} feature is disabled`, code });
return next(); return next();
} catch (err) { return next(err); } } 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(adminAuth);
router.use(requireIncomingInvoicesFlag);
// ── Expense categories (literal path — register BEFORE '/:id') ────────────── const toInt = (v) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n : undefined; };
router.get('/categories', requirePermission('accounting.view'), handleAsync(async (_req, res) => {
return successResponse(res, { items: await expenseCategoriesService.list() });
}));
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()], [body('name').isString().isLength({ min: 1, max: 128 }), body('color').optional({ nullable: true }).isString()],
handleAsync(async (req, res) => { handleAsync(async (req, res) => {
validateRequest(req); validateRequest(req);
const cat = await expenseCategoriesService.create(req.body, req.admin.id); return successResponse(res, { category: await expenseCategoriesService.create(req.body, req.admin.id) }, 201, 'Category created');
return successResponse(res, { category: cat }, 201, 'Category created');
})); }));
router.patch('/categories/:id', requirePermission('accounting.manage'), router.patch('/categories/:id', requireAccounting, requirePermission('accounting.manage'),
[param('id').isInt({ min: 1 })], [param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => { handleAsync(async (req, res) => {
validateRequest(req); validateRequest(req);
const cat = await expenseCategoriesService.update(parseInt(req.params.id, 10), req.body); return successResponse(res, { category: await expenseCategoriesService.update(toInt(req.params.id), req.body) });
return successResponse(res, { category: cat });
})); }));
router.delete('/categories/:id', requirePermission('accounting.manage'), router.delete('/categories/:id', requireAccounting, requirePermission('accounting.manage'),
[param('id').isInt({ min: 1 })], [param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => { handleAsync(async (req, res) => {
validateRequest(req); 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') ─────────────── // ── Incoming invoices (external) ────────────────────────────────────────────
router.post('/inbound', requirePermission('accounting.manage'), router.post('/inbound', requireIncoming, requirePermission('accounting.manage'),
inboundUpload.single('file'), inboundUpload.single('file'),
[body('source').optional().isIn(['upload', 'camera', 'email', 'manual'])], [body('source').optional().isIn(['upload', 'camera', 'email', 'manual'])],
handleAsync(async (req, res) => { handleAsync(async (req, res) => {
validateRequest(req); validateRequest(req);
if (!req.file) return res.status(400).json({ error: 'No file uploaded', code: 'NO_FILE' }); if (!req.file) return res.status(400).json({ error: 'No file uploaded', code: 'NO_FILE' });
const doc = await expenseService.recordInboundDocument({ const document = await expenseService.recordInboundDocument({
source: req.body.source || 'upload', source: req.body.source || 'upload', filePath: req.file.path,
filePath: req.file.path, originalFilename: req.file.originalname, mimeType: req.file.mimetype,
originalFilename: req.file.originalname,
mimeType: req.file.mimetype,
}, req.admin.id); }, 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 })], [query('status').optional().isString(), query('page').optional().isInt({ min: 1 }), query('pageSize').optional().isInt({ min: 1, max: 100 })],
handleAsync(async (req, res) => { handleAsync(async (req, res) => { validateRequest(req); return successResponse(res, await expenseService.listInbound(req.query)); }));
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 })], [param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => { handleAsync(async (req, res) => {
validateRequest(req); validateRequest(req);
return successResponse(res, { document: await expenseService.getInbound(parseInt(req.params.id, 10)) }); const row = await db('inbound_documents').where({ id: toInt(req.params.id) }).first('file_path', 'mime_type');
}));
// 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');
if (!row || !row.file_path) return res.status(404).json({ error: 'File not found', code: 'NO_FILE' }); 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 safe = assertPathInside(row.file_path, [path.join(getStoragePath(), 'business-docs')]);
const isPdf = (row.mime_type || '').includes('pdf'); const isPdf = (row.mime_type || '').includes('pdf');
@@ -139,18 +124,15 @@ router.get('/inbound/:id/file', requirePermission('accounting.view'),
createReadStream(safe).pipe(res); createReadStream(safe).pipe(res);
})); }));
// Rasterised PDF page (PNG) — the ONLY way a PDF is shown in-browser. The raw router.get('/inbound/:id/page/:n', requireIncoming, requirePermission('accounting.view'),
// PDF never reaches the client. CSP-locked + nosniff.
router.get('/inbound/:id/page/:n', requirePermission('accounting.view'),
[param('id').isInt({ min: 1 }), param('n').isInt({ min: 1 })], [param('id').isInt({ min: 1 }), param('n').isInt({ min: 1 })],
handleAsync(async (req, res) => { handleAsync(async (req, res) => {
validateRequest(req); 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'); 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 || !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' }); 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, toInt(req.params.n)), row.page_count || 1);
const page = Math.min(Math.max(1, parseInt(req.params.n, 10)), maxPage);
const srcPdf = assertPathInside(row.file_path, [path.join(getStoragePath(), 'business-docs')]); const srcPdf = assertPathInside(row.file_path, [path.join(getStoragePath(), 'business-docs')]);
const pngPath = await rasterizeService.getRenderedPagePath(id, srcPdf, page); const pngPath = await rasterizeService.getRenderedPagePath(id, srcPdf, page);
const safePng = assertPathInside(pngPath, [path.join(getStoragePath(), 'business-docs')]); 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); createReadStream(safePng).pipe(res);
})); }));
router.patch('/inbound/:id', requirePermission('accounting.manage'), router.get('/inbound/:id', requireIncoming, requirePermission('accounting.view'),
[param('id').isInt({ min: 1 })], [param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => { handleAsync(async (req, res) => { validateRequest(req); return successResponse(res, { document: await expenseService.getInbound(toInt(req.params.id)) }); }));
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'), 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)], [param('id').isInt({ min: 1 }), body('disposition').isIn(expenseService.DISPOSITIONS)],
handleAsync(async (req, res) => { handleAsync(async (req, res) => { validateRequest(req); return successResponse(res, { document: await expenseService.categorizeInbound(toInt(req.params.id), req.body, req.admin.id) }, 200, 'Categorized'); }));
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.post('/inbound/:id/rebill', requireIncoming, requirePermission('accounting.manage'),
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 }), [param('id').isInt({ min: 1 }), body('customerAccountId').isInt({ min: 1 }),
body('eventId').optional({ nullable: true }).isInt({ min: 1 }), body('eventId').optional({ nullable: true }).isInt({ min: 1 }), body('contractId').optional({ nullable: true }).isInt({ min: 1 }),
body('contractId').optional({ nullable: true }).isInt({ min: 1 }),
body('markupType').optional().isIn(expenseService.MARKUP_TYPES)], 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) => { handleAsync(async (req, res) => {
validateRequest(req); validateRequest(req);
const result = await expenseService.rebillToEvent(parseInt(req.params.id, 10), req.body, req.admin.id); const b = req.body;
return successResponse(res, result, 201, 'Expense re-billed'); 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'), router.get('/:id/proof', requireExpenses, requirePermission('accounting.view'),
[param('id').isInt({ min: 1 }), body('paid').isBoolean(), [param('id').isInt({ min: 1 })],
body('paymentMethod').optional({ nullable: true }).isIn(expenseService.PAYMENT_METHODS)],
handleAsync(async (req, res) => { handleAsync(async (req, res) => {
validateRequest(req); 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 }); return successResponse(res, { expense });
})); }));
+246 -325
View File
@@ -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 * INCOMING INVOICES (external) → the `inbound_documents` row IS the payable.
* them a disposition, and — for "rebill" (Weiterverrechnung) — folds the cost * It carries its own disposition, supplier-payment, booking (event_id, NULL
* onto a client's event invoice as a line item. The re-bill flow mirrors * = company) and re-bill linkage. A supplier invoice never creates an
* customerHoursService.billUnbilledEntries: resolve amount + markup, then * `expenses` row, so it appears ONLY in the incoming-invoices surface.
* createInvoice({ customerAccountId, eventId, lineItems }) and stamp the
* source row billed.
* *
* Money is integer minor units throughout. The QR-encoded amount on an * EXPENSES (internal) → `expenses` rows are own costs entered by
* inbound document is stored separately and NEVER used as the authoritative * staff: kind = amount | mileage(km) | per_diem, amount = quantity x rate
* total. All VAT/tax handling here is v1 (capture-only) and must be reviewed * (rate from accounting settings, per-entry override), optional proof file,
* with a Treuhänder before being relied upon. * 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 crypto = require('crypto');
const fsp = require('fs').promises; const fsp = require('fs').promises;
const { PDFDocument } = require('pdf-lib'); const { PDFDocument } = require('pdf-lib');
const { db, logActivity } = require('../database/db'); const { db, logActivity } = require('../database/db');
const { AppError } = require('../utils/errors'); const { AppError } = require('../utils/errors');
const { hasColumnCached } = require('../utils/schemaCache');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const invoiceService = require('./invoiceService'); 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 TAX_TREATMENTS = ['domestic', 'reverse_charge_service', 'foreign_vat_non_reclaimable', 'import_goods'];
const MARKUP_TYPES = ['none', 'percent', 'flat']; const MARKUP_TYPES = ['none', 'percent', 'flat'];
const PAYMENT_METHODS = ['bank_transfer', 'cash', 'twint', 'paypal', 'card', 'other']; 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 = { const DISPOSITION_DOC_STATUS = {
rebill: 'categorized', rebill: 'categorized',
durchlaufend: 'categorized', durchlaufend: 'categorized',
@@ -39,14 +39,30 @@ const DISPOSITION_DOC_STATUS = {
function toIsoDate(v) { function toIsoDate(v) {
if (!v) return null; if (!v) return null;
if (v instanceof Date) return v.toISOString().slice(0, 10); 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) { // ── Accounting settings (app_settings, type 'accounting') ───────────────────
if (!raw) return []; async function getAccountingSettings() {
try { const a = JSON.parse(raw); return Array.isArray(a) ? a : []; } catch (_e) { return []; } 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) { function transformInbound(row) {
if (!row) return null; if (!row) return null;
return { return {
@@ -57,7 +73,6 @@ function transformInbound(row) {
status: row.status, status: row.status,
parseStatus: row.parse_status, parseStatus: row.parse_status,
parseMethod: row.parse_method, parseMethod: row.parse_method,
parseError: row.parse_error,
pageCount: row.page_count, pageCount: row.page_count,
supplierName: row.supplier_name, supplierName: row.supplier_name,
invoiceNumber: row.invoice_number, invoiceNumber: row.invoice_number,
@@ -71,45 +86,21 @@ function transformInbound(row) {
iban: row.iban, iban: row.iban,
paymentReference: row.payment_reference, paymentReference: row.payment_reference,
duplicateOfId: row.duplicate_of_id, duplicateOfId: row.duplicate_of_id,
createdAt: row.created_at, // classification + booking (migration 126)
updatedAt: row.updated_at,
};
}
function transformExpense(row) {
if (!row) return null;
return {
id: row.id,
inboundDocumentId: row.inbound_document_id,
disposition: row.disposition, disposition: row.disposition,
taxTreatment: row.tax_treatment, taxTreatment: row.tax_treatment,
eventId: row.event_id, eventId: row.event_id,
customerAccountId: row.customer_account_id, categoryId: row.category_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, markupType: row.markup_type,
markupPercent: row.markup_percent != null ? Number(row.markup_percent) : null, markupPercent: row.markup_percent != null ? Number(row.markup_percent) : null,
markupFlatMinor: row.markup_flat_minor, markupFlatMinor: row.markup_flat_minor,
categoryId: row.category_id,
tags: parseTags(row.tags),
billedInvoiceId: row.billed_invoice_id, billedInvoiceId: row.billed_invoice_id,
billedInvoiceLineItemId: row.billed_invoice_line_item_id, billedInvoiceLineItemId: row.billed_invoice_line_item_id,
unbilledParked: !!row.unbilled_parked, // supplier payment (paid on the incoming invoice itself)
billedAt: row.billed_at,
supplierPaid: !!row.supplier_paid, supplierPaid: !!row.supplier_paid,
supplierPaidAt: row.supplier_paid_at, supplierPaidAt: row.supplier_paid_at,
paymentMethod: row.payment_method, supplierPaymentMethod: row.supplier_payment_method,
paymentReference: row.payment_reference, supplierPaymentRef: row.supplier_payment_ref,
declineReason: row.decline_reason,
status: row.status,
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
}; };
@@ -121,8 +112,6 @@ function clampPage(page, pageSize) {
return { p, ps }; 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) { async function inspectFile(filePath, mimeType) {
const buf = await fsp.readFile(filePath); const buf = await fsp.readFile(filePath);
const sha = crypto.createHash('sha256').update(buf).digest('hex'); 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 }); const pdf = await PDFDocument.load(buf, { updateMetadata: false });
pageCount = pdf.getPageCount(); pageCount = pdf.getPageCount();
} catch (e) { } 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 }; 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) { async function recordInboundDocument({ source, filePath, originalFilename, mimeType }, adminId) {
let fileSha256 = null; let fileSha256 = null;
let pageCount = null; let pageCount = null;
try { try {
const info = await inspectFile(filePath, mimeType); const info = await inspectFile(filePath, mimeType);
fileSha256 = info.sha; fileSha256 = info.sha; pageCount = info.pageCount;
pageCount = info.pageCount;
} catch (e) { } catch (e) {
logger.warn?.(`expenseService: could not inspect ${filePath}: ${e.message}`); 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; 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 now = new Date();
const row = { const row = {
source: source || 'upload', source: source || 'upload',
@@ -180,22 +151,9 @@ async function recordInboundDocument({ source, filePath, originalFilename, mimeT
mime_type: mimeType || null, mime_type: mimeType || null,
file_sha256: fileSha256, file_sha256: fileSha256,
status: duplicateOfId ? 'duplicate' : 'unsorted', status: duplicateOfId ? 'duplicate' : 'unsorted',
parse_status: parse.error ? 'failed' : (parse.parsed ? 'parsed' : 'pending'), parse_status: 'pending',
parse_method: parse.method || 'none', parse_method: 'none',
parse_error: parse.error || null,
page_count: pageCount, 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, duplicate_of_id: duplicateOfId,
created_by_admin_id: adminId || null, created_by_admin_id: adminId || null,
created_at: now, 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 inserted = await db('inbound_documents').insert(row).returning('id');
const id = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; 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); return getInbound(id);
} }
async function getInbound(id) { async function getInbound(id) {
const row = await db('inbound_documents').where({ id }).first(); 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); return transformInbound(row);
} }
@@ -219,13 +177,8 @@ async function listInbound({ status, page, pageSize } = {}) {
if (status) base.where({ status }); if (status) base.where({ status });
const countRow = await base.clone().count({ count: '*' }).first(); const countRow = await base.clone().count({ count: '*' }).first();
const total = parseInt(countRow?.count || 0, 10); const total = parseInt(countRow?.count || 0, 10);
const rows = await base.clone() const rows = await base.clone().orderBy('created_at', 'desc').limit(ps).offset((p - 1) * ps);
.orderBy('created_at', 'desc') return { items: rows.map(transformInbound), pagination: { page: p, pageSize: ps, total, totalPages: Math.ceil(total / ps) } };
.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 = { const INBOUND_EDITABLE = {
@@ -235,7 +188,6 @@ const INBOUND_EDITABLE = {
paymentReference: 'payment_reference', paymentReference: 'payment_reference',
}; };
/** Confirm / correct best-effort parsed fields (assist is never blind-trusted). */
async function updateInbound(id, payload, adminId) { async function updateInbound(id, payload, adminId) {
await getInbound(id); await getInbound(id);
const patch = { updated_at: new Date(), parse_status: 'manual' }; 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]; if (payload[camel] !== undefined) patch[snake] = payload[camel] === '' ? null : payload[camel];
} }
await db('inbound_documents').where({ id }).update(patch); 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); 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) { function computeMarkupMinor(baseMinor, markup) {
const now = new Date(); 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; const disposition = payload.disposition;
if (!DISPOSITIONS.includes(disposition)) { if (!DISPOSITIONS.includes(disposition)) {
throw new AppError(`disposition must be one of ${DISPOSITIONS.join(', ')}`, 400, 'BAD_DISPOSITION'); throw new AppError(`disposition must be one of ${DISPOSITIONS.join(', ')}`, 400, 'BAD_DISPOSITION');
} }
const taxTreatment = payload.taxTreatment && TAX_TREATMENTS.includes(payload.taxTreatment) if (disposition === 'rebill') {
? payload.taxTreatment : 'domestic'; const { document } = await rebillInbound(id, payload, adminId);
const markupType = payload.markupType && MARKUP_TYPES.includes(payload.markupType) // also stamp tax_treatment/category/event from payload
? payload.markupType : 'none'; await db('inbound_documents').where({ id }).update({
let status = 'open'; tax_treatment: TAX_TREATMENTS.includes(payload.taxTreatment) ? payload.taxTreatment : (document.taxTreatment || 'domestic'),
if (disposition === 'abgelehnt') status = 'declined'; category_id: payload.categoryId || null,
else if (payload.unbilledParked) status = 'parked'; updated_at: new Date(),
});
return { return getInbound(id);
inbound_document_id: payload.inboundDocumentId || null, }
const patch = {
disposition, disposition,
tax_treatment: taxTreatment, tax_treatment: TAX_TREATMENTS.includes(payload.taxTreatment) ? payload.taxTreatment : 'domestic',
event_id: payload.eventId || null, event_id: payload.eventId || null, // null = company
customer_account_id: payload.customerAccountId || null, 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, supplier_name: payload.supplierName || null,
description: payload.description || null, description: payload.description || null,
original_currency: payload.originalCurrency || null, kind,
original_amount_minor: Number.isInteger(payload.originalAmountMinor) ? payload.originalAmountMinor : null, quantity,
chf_amount_minor: Number.isInteger(payload.chfAmountMinor) ? payload.chfAmountMinor : null, rate_minor: rateMinor,
fx_locked: !!payload.fxLocked, chf_amount_minor: chf,
fx_lock_reason: payload.fxLockReason || null, gross_amount_minor: chf,
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, category_id: payload.categoryId || null,
tags: Array.isArray(payload.tags) ? JSON.stringify(payload.tags) : null, receipt_path: opts.receiptPath || null,
unbilled_parked: !!payload.unbilledParked, status: 'open',
decline_reason: disposition === 'abgelehnt' ? (payload.declineReason || null) : null,
status,
created_by_admin_id: adminId || null, created_by_admin_id: adminId || null,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
@@ -299,240 +387,73 @@ async function getExpense(id) {
return transformExpense(row); return transformExpense(row);
} }
async function createManualExpense(payload, adminId) { async function createExpense(payload, adminId, { receiptPath } = {}) {
const row = buildExpenseInsert(payload, adminId); 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 inserted = await db('expenses').insert(row).returning('id');
const id = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; 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); return getExpense(id);
} }
/** Create an expense FROM an inbound document and move the doc out of "Unsortiert". */ async function listExpenses({ kind, eventId, categoryId, page, pageSize } = {}) {
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 { p, ps } = clampPage(page, pageSize);
const base = db('expenses'); const base = db('expenses').where({ inbound_document_id: null }); // internal only
if (status) base.where({ status }); if (kind) base.where({ kind });
if (disposition) base.where({ disposition }); if (categoryId) base.where({ category_id: categoryId });
if (customerAccountId) base.where({ customer_account_id: customerAccountId }); if (eventId === 'company') base.whereNull('event_id');
if (eventId) base.where({ event_id: eventId }); else if (eventId) base.where({ event_id: eventId });
const countRow = await base.clone().count({ count: '*' }).first(); const countRow = await base.clone().count({ count: '*' }).first();
const total = parseInt(countRow?.count || 0, 10); const total = parseInt(countRow?.count || 0, 10);
const rows = await base.clone() const rows = await base.clone().orderBy('created_at', 'desc').limit(ps).offset((p - 1) * ps);
.orderBy('created_at', 'desc') return { items: rows.map(transformExpense), pagination: { page: p, pageSize: ps, total, totalPages: Math.ceil(total / ps) } };
.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 = { const EXPENSE_EDITABLE = {
supplierName: 'supplier_name', description: 'description', taxTreatment: 'tax_treatment', supplierName: 'supplier_name', description: 'description', taxTreatment: 'tax_treatment',
eventId: 'event_id', customerAccountId: 'customer_account_id', categoryId: 'category_id', eventId: 'event_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) { async function updateExpense(id, payload, adminId, { receiptPath } = {}) {
const existing = await getExpense(id); await getExpense(id);
if (existing.billedInvoiceId) {
throw new AppError('Expense already billed — edit is locked', 409, 'EXPENSE_LOCKED');
}
const patch = { updated_at: new Date() }; const patch = { updated_at: new Date() };
for (const [camel, snake] of Object.entries(EXPENSE_EDITABLE)) { for (const [camel, snake] of Object.entries(EXPENSE_EDITABLE)) {
if (payload[camel] !== undefined) patch[snake] = payload[camel] === '' ? null : payload[camel]; 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; if (receiptPath) patch.receipt_path = receiptPath;
// 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 db('expenses').where({ id }).update(patch);
await logActivity('expense_updated', { expenseId: id }, adminId); await logActivity('expense_updated', { expenseId: id }, adminId);
return getExpense(id); 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 = { module.exports = {
// inbound documents getAccountingSettings,
// incoming invoices
recordInboundDocument, recordInboundDocument,
getInbound, getInbound,
listInbound, listInbound,
updateInbound, updateInbound,
categorizeInbound, categorizeInbound,
rebillInbound,
markInboundSupplierPayment,
// expenses // expenses
createManualExpense, createExpense,
getExpense, getExpense,
listExpenses, listExpenses,
updateExpense, updateExpense,
setSupplierPayment, // constants
rebillToEvent,
// constants (for route validators)
DISPOSITIONS, DISPOSITIONS,
TAX_TREATMENTS, TAX_TREATMENTS,
MARKUP_TYPES, MARKUP_TYPES,
PAYMENT_METHODS, PAYMENT_METHODS,
// exposed for unit tests EXPENSE_KINDS,
_internal: { computeMarkupMinor, resolveMarkup, buildExpenseInsert, transformExpense, transformInbound }, // unit-test surface
_internal: { computeMarkupMinor, resolveMarkup, computeExpenseAmount, buildExpenseInsert, transformExpense, transformInbound },
}; };