feat(accounting): re-categorize incoming invoices, note field, pending re-bill pool

Address three incoming-invoice issues:

1. Re-categorization: a categorized invoice can now be changed again (e.g.
   passthrough → company expense). New "Re-categorize" button pre-fills the
   triage modal from the existing disposition/customer/markup/note.
   categorizeInbound is re-runnable — it unwinds any prior re-bill line
   (removes the invoice line + recomputes totals) before applying the new
   disposition, and refuses (INVOICE_LOCKED) when the re-bill is on an
   already-issued invoice.

2. Note field: new `note` column (migration 132 — 126 is already on beta)
   captured in triage and shown in the read-only view.

3. Re-bill like hours: rebill/passthrough now persist customer_account_id.
   Per-event customers accumulate as PENDING items, surfaced in a new
   "Pending re-bills" card and bundled into one invoice via "Bill these"
   (mirrors unbilled-hours billing). Monthly/manual customers keep
   auto-consolidating onto their running draft. Passthrough (durchlaufend)
   can now also attach to a customer with optional markup.

Adds backend unit tests for buildInboundLineItem + isInvoiceMutable and
en/de translations (other locales fall back to English defaults).
This commit is contained in:
Luca
2026-06-18 12:23:13 +02:00
parent 8deb7e0741
commit 36a8e42f90
8 changed files with 553 additions and 76 deletions
@@ -0,0 +1,48 @@
/**
* Migration 132: incoming-invoice categorisation note + customer linkage.
*
* - note : free-text note captured during triage (issue: no
* note field on categorisation).
* - customer_account_id: the client a rebill/passthrough is attached to.
* Previously the customer was passed transiently to
* the re-bill call and only lived on the resulting
* invoice. Persisting it lets a categorised-but-not-
* yet-billed item sit as a PENDING re-bill in the
* customer's pool (per-event customers), exactly like
* unbilled hour entries. Loose link (no hard FK —
* mirrors the inbound event_id / expenses approach),
* indexed for the pending-summary lookup.
*
* Migration 126 (which added the disposition/re-bill columns) is already
* deployed to beta, so these go in a NEW migration rather than an in-place
* edit. Additive + hasColumn-guarded so re-runs are safe.
*/
async function addColumn(knex, table, column, builder) {
// eslint-disable-next-line no-await-in-loop
if (!(await knex.schema.hasColumn(table, column))) {
await knex.schema.alterTable(table, builder);
}
}
exports.up = async function (knex) {
if (!(await knex.schema.hasTable('inbound_documents'))) return;
await addColumn(knex, 'inbound_documents', 'note', (t) => t.text('note'));
await addColumn(knex, 'inbound_documents', 'customer_account_id', (t) => t.integer('customer_account_id').unsigned());
if (await knex.schema.hasColumn('inbound_documents', 'customer_account_id')) {
// Index the pending-rebill lookup (customer_account_id + billed_invoice_id).
try {
await knex.schema.alterTable('inbound_documents', (t) => t.index(['customer_account_id'], 'inbound_documents_customer_account_id_index'));
} catch (_e) { /* index may already exist */ }
}
};
exports.down = async function (knex) {
if (!(await knex.schema.hasTable('inbound_documents'))) return;
for (const col of ['note', 'customer_account_id']) {
// eslint-disable-next-line no-await-in-loop
if (await knex.schema.hasColumn('inbound_documents', col)) {
// eslint-disable-next-line no-await-in-loop
await knex.schema.alterTable('inbound_documents', (t) => t.dropColumn(col));
}
}
};