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