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));
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user