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:
Luca
2026-06-11 00:04:16 +02:00
parent c1c5ac726c
commit c305492845
11 changed files with 1164 additions and 0 deletions
@@ -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));
}
}
};