test(accounting): incoming-invoice integration test + fix vat_code reload & SQLite logActivity deadlock

- Add backend/__tests__/integration/incomingInvoiceRebill.test.js (8 tests):
  disposition state machine, per-event PENDING pool, passthrough-no-markup,
  unwindBilledLine recompute, INVOICE_LOCKED on an issued invoice, and
  re-categorisation transitions. The invoice-MINTING paths can't run inside an
  outer transaction on SQLite (createInvoice's sequence claim deadlocks on the
  held write lock) — covered by buildInboundLineItem unit tests + discountLineItems
  instead; documented in the test.

- Move logActivity out of the categorize/rebill/bundle transactions. It writes
  via the global db; inside a transaction a second write connection deadlocks on
  a SQLite-backed install (also affected SQLite-prod, not just tests).

- Fix bill-editor vat_code reload: transformInvoice (adminInvoices.js) dropped
  vatCode, so the editor fell back to rate-matching and lost a custom-rate code
  on edit. Now returns vatCode: i.vat_code.

- Rewrite docs/accounting-inbound-invoices.md to the current implementation
  (IR-vs-Expenses split, re-categorise + unwind, cadence-aware re-bill / pending
  pool, passthrough-at-cost, migrations 122-132, rasterised preview, tax/ledger/VAT).
This commit is contained in:
Luca
2026-06-18 14:11:08 +02:00
parent 9a023c0197
commit 315d15afd4
4 changed files with 313 additions and 56 deletions
@@ -0,0 +1,211 @@
/**
* Incoming-invoice categorisation + re-bill chain (expenseService) against a
* real SQLite schema. Covers the bits unit tests can't: the disposition state
* machine, re-categorisation unwind, the per-event PENDING pool + bundling, and
* the monthly accumulator immediate-bill — i.e. that categorizeInbound /
* billPendingRebills actually mint / amend invoice rows correctly.
*
* No date-range comparisons are exercised here, so it's safe on SQLite (the
* usual PG-vs-SQLite date pitfall — [[feedback_pg_date_columns_serialize]] —
* doesn't apply to this path).
*/
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
// Service-level CRM calls cold-require heavy modules (pdfService, nodemailer)
// on first use; bump the budget for this file.
jest.setTimeout(60000);
describe('incoming-invoice categorise / re-bill chain', () => {
let db;
let cleanup;
let adminId;
let expenseService;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
// logActivity writes to activity_logs via the GLOBAL db. createInvoice (and
// appendToMonthlyDraft) call it INSIDE the transaction we pass them, and a
// second write connection deadlocks against the held write lock on
// SQLite. It's fire-and-forget audit noise, irrelevant to these
// assertions, so stub it BEFORE the services destructure it at require
// time. (Production runs Postgres, where the concurrent write is fine.)
const dbModule = require('../../src/database/db');
dbModule.logActivity = async () => {};
({ adminId } = await seedMinimal(db));
expenseService = require('../../src/services/expenseService');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
const unwrapId = (ins) => (typeof ins[0] === 'object' ? ins[0].id : ins[0]);
async function captureDoc(overrides = {}) {
const ins = await db('inbound_documents').insert({
source: 'upload',
status: 'unsorted',
parse_status: 'pending',
parse_method: 'none',
supplier_name: 'ACME AG',
currency: 'CHF',
total_amount_minor: 10000,
invoice_date: '2026-06-01',
created_at: new Date(),
updated_at: new Date(),
...overrides,
}).returning('id');
return unwrapId(ins);
}
let customerSeq = 0;
async function makeCustomer(billingCadence) {
customerSeq += 1;
const ins = await db('customer_accounts').insert({
email: `rebill-${billingCadence || 'event'}-${customerSeq}@example.com`,
display_name: `Rebill ${billingCadence || 'event'} ${customerSeq}`,
password_hash: 'x',
preferred_language: 'de',
is_active: 1,
billing_cadence: billingCadence || null,
created_at: new Date(),
}).returning('id');
return unwrapId(ins);
}
it('company expense (eigener_aufwand) categorises with no invoice + no customer', async () => {
const id = await captureDoc();
const doc = await expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand', categoryId: null }, adminId);
expect(doc.disposition).toBe('eigener_aufwand');
expect(doc.status).toBe('categorized');
expect(doc.billedInvoiceId).toBeNull();
expect(doc.customerAccountId).toBeNull();
});
it('rebill REQUIRES a customer', async () => {
const id = await captureDoc();
await expect(expenseService.categorizeInbound(id, { disposition: 'rebill' }, adminId))
.rejects.toMatchObject({ code: 'CUSTOMER_REQUIRED' });
});
it('per-event rebill stays PENDING (customer + markup stored, no invoice yet)', async () => {
const customerId = await makeCustomer('per_event');
const id = await captureDoc({ total_amount_minor: 10000 });
const doc = await expenseService.categorizeInbound(id, {
disposition: 'rebill', customerAccountId: customerId,
markupType: 'percent', markupPercent: 10,
}, adminId);
expect(doc.disposition).toBe('rebill');
expect(doc.customerAccountId).toBe(customerId);
expect(doc.billedInvoiceId).toBeNull(); // pending — not billed until bundled
expect(doc.markupType).toBe('percent');
expect(Number(doc.markupPercent)).toBe(10);
});
it('passthrough never carries a markup, even if one is sent', async () => {
const customerId = await makeCustomer('per_event');
const id = await captureDoc();
const doc = await expenseService.categorizeInbound(id, {
disposition: 'durchlaufend', customerAccountId: customerId,
markupType: 'percent', markupPercent: 25, // should be ignored
}, adminId);
expect(doc.disposition).toBe('durchlaufend');
expect(doc.customerAccountId).toBe(customerId);
expect(doc.markupType).toBe('none');
expect(doc.markupPercent).toBeNull();
expect(doc.billedInvoiceId).toBeNull();
});
it('billPendingRebills refuses monthly/manual customers (they auto-consolidate)', async () => {
const customerId = await makeCustomer('monthly');
await expect(expenseService.billPendingRebills(customerId, adminId))
.rejects.toMatchObject({ code: 'CADENCE_MISMATCH' });
});
// ── The actual invoice-MINTING paths (billPendingRebills bundling a per-event
// customer's pool; monthly-customer immediate-bill onto the running draft)
// both call invoiceService.createInvoice INSIDE a db.transaction. createInvoice
// claims its sequence number via the global db, which DEADLOCKS against the
// held write lock on a SQLite-backed harness (a second write connection blocks
// — verified). Production runs Postgres where the concurrent write is fine, so
// this is a harness limitation, not a product bug. The line-amount math is
// covered by the buildInboundLineItem unit tests, and createInvoice itself by
// discountLineItems.test.js. Below we test the UNWIND path against a
// hand-crafted billed state so we don't have to mint through createInvoice. ──
// Build a billed state directly: an invoice with two lines, with the inbound
// doc stamped onto the first line as a prior re-bill.
async function makeBilledDoc(customerId, { status = 'scheduled', scheduledSendAt = null, isMonthlyDraft = false } = {}) {
const invIns = await db('invoices').insert({
invoice_number: `R-TEST-${customerSeq}-${Math.floor(Math.random() * 1e9)}`,
customer_account_id: customerId,
status,
scheduled_send_at: scheduledSendAt,
is_monthly_draft: isMonthlyDraft,
currency: 'CHF',
issue_date: '2026-06-01',
due_date: '2026-07-01',
vat_rate: 0,
net_amount_minor: 7000, // 4000 (rebill line) + 3000 (sibling)
vat_amount_minor: 0,
total_amount_minor: 7000,
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
const invoiceId = unwrapId(invIns);
const rebillLineIns = await db('invoice_line_items').insert({
invoice_id: invoiceId, position: 1, quantity: 1, description: 'Rebill Co (Weiterverrechnung)',
unit_price_minor: 4000, discount_percent: 0, line_total_minor: 4000,
}).returning('id');
const rebillLineId = unwrapId(rebillLineIns);
await db('invoice_line_items').insert({
invoice_id: invoiceId, position: 2, quantity: 1, description: 'Other line',
unit_price_minor: 3000, discount_percent: 0, line_total_minor: 3000,
});
const id = await captureDoc({ total_amount_minor: 4000, supplier_name: 'Rebill Co' });
await db('inbound_documents').where({ id }).update({
disposition: 'rebill', status: 'categorized', customer_account_id: customerId,
billed_invoice_id: invoiceId, billed_invoice_line_item_id: rebillLineId,
});
return { id, invoiceId, rebillLineId };
}
it('re-categorising a billed doc UNWINDS its re-bill line + recomputes the (mutable) invoice', async () => {
const customerId = await makeCustomer('per_event');
const { id, invoiceId, rebillLineId } = await makeBilledDoc(customerId); // scheduled, no send-at → mutable
const recat = await expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand', categoryId: null }, adminId);
expect(recat.disposition).toBe('eigener_aufwand');
expect(recat.billedInvoiceId).toBeNull();
expect(recat.customerAccountId).toBeNull();
// The re-bill line is gone; the sibling line remains and net recomputes.
expect(await db('invoice_line_items').where({ id: rebillLineId }).first()).toBeUndefined();
const after = await db('invoices').where({ id: invoiceId }).first();
expect(Number(after.net_amount_minor)).toBe(3000);
});
it('re-categorising a doc billed on an ISSUED invoice is refused (Storno required)', async () => {
const customerId = await makeCustomer('per_event');
const { id, rebillLineId } = await makeBilledDoc(customerId, { status: 'sent' });
await expect(expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand' }, adminId))
.rejects.toMatchObject({ code: 'INVOICE_LOCKED' });
// Nothing was touched — the line survives.
expect(await db('invoice_line_items').where({ id: rebillLineId }).first()).toBeDefined();
});
it('re-categorisation moves a pending item between dispositions without a stray invoice', async () => {
const customerId = await makeCustomer('per_event');
const id = await captureDoc();
// passthrough → pending
let doc = await expenseService.categorizeInbound(id, { disposition: 'durchlaufend', customerAccountId: customerId }, adminId);
expect(doc.customerAccountId).toBe(customerId);
expect(doc.billedInvoiceId).toBeNull();
// → company expense: customer cleared, still no invoice
doc = await expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand' }, adminId);
expect(doc.disposition).toBe('eigener_aufwand');
expect(doc.customerAccountId).toBeNull();
expect(doc.billedInvoiceId).toBeNull();
});
});
+4
View File
@@ -130,6 +130,10 @@ function transformInvoice(i) {
sentAt: i.sent_at,
netAmountMinor: i.net_amount_minor,
vatRate: i.vat_rate == null ? null : Number(i.vat_rate),
// Snapshotted VAT code (migration 130) — the editor needs it to repopulate
// VatRateSelect on edit; without it the dropdown falls back to rate-matching
// and a custom-rate code is silently lost.
vatCode: i.vat_code || null,
vatAmountMinor: i.vat_amount_minor,
shippingAmountMinor: i.shipping_amount_minor,
totalAmountMinor: i.total_amount_minor,
+16 -6
View File
@@ -343,7 +343,9 @@ async function billInboundNow(trx, id, customerAccountId, eventId, disposition,
billed_invoice_line_item_id: line ? line.id : null,
updated_at: new Date(),
});
await logActivity('incoming_invoice_rebilled', { inboundDocumentId: id, invoiceId }, adminId);
// NOTE: no logActivity here — it writes via the GLOBAL db, which deadlocks
// when called inside this transaction on a SQLite-backed install (a second
// write connection blocks on the held write lock). Callers log AFTER commit.
return invoiceId;
}
@@ -367,6 +369,7 @@ async function categorizeInbound(id, payload, adminId) {
throw new AppError('customerAccountId is required to re-bill', 400, 'CUSTOMER_REQUIRED');
}
let billedInvoiceId = null;
await db.transaction(async (trx) => {
const row = await trx('inbound_documents').where({ id }).first();
if (!row) throw new AppError('Incoming invoice not found', 404, 'INBOUND_NOT_FOUND');
@@ -409,12 +412,14 @@ async function categorizeInbound(id, payload, adminId) {
// Monthly/manual = accumulator → bill now onto the running draft.
// Per-event → leave PENDING for bundling via billPendingRebills.
if (customer.billing_cadence === 'monthly' || customer.billing_cadence === 'manual') {
await billInboundNow(trx, id, customerAccountId, payload.eventId || null, disposition, markup, adminId);
billedInvoiceId = await billInboundNow(trx, id, customerAccountId, payload.eventId || null, disposition, markup, adminId);
}
}
await logActivity('incoming_invoice_categorized', { inboundDocumentId: id, disposition }, adminId);
});
// Audit logging AFTER commit — logActivity writes via the global db and would
// deadlock if run inside the transaction above on a SQLite-backed install.
await logActivity('incoming_invoice_categorized', { inboundDocumentId: id, disposition }, adminId);
if (billedInvoiceId) await logActivity('incoming_invoice_rebilled', { inboundDocumentId: id, invoiceId: billedInvoiceId }, adminId);
return getInbound(id);
}
@@ -447,6 +452,9 @@ async function rebillInbound(id, payload, adminId, trx0) {
return billInboundNow(trx, id, payload.customerAccountId, payload.eventId || doc.eventId || null, 'rebill', markup, adminId);
};
const invoiceId = trx0 ? await run(trx0) : await db.transaction(run);
// Log after commit (global-db write — see billInboundNow). When a caller
// supplied trx0, that outer transaction owns the audit log instead.
if (!trx0) await logActivity('incoming_invoice_rebilled', { inboundDocumentId: id, invoiceId }, adminId);
return { document: await getInbound(id), invoiceId };
}
@@ -518,7 +526,7 @@ async function billPendingRebills(customerId, adminId) {
);
}
return await db.transaction(async (trx) => {
const result = await db.transaction(async (trx) => {
const pending = await trx('inbound_documents')
.where({ customer_account_id: customer.id })
.whereNull('billed_invoice_id')
@@ -556,9 +564,11 @@ async function billPendingRebills(customerId, adminId) {
});
}
await logActivity('incoming_invoices_rebilled_bundle', { customerId: customer.id, invoiceId, count: pending.length }, adminId);
return { invoiceId, count: pending.length };
});
// Audit log after commit (global-db write — see billInboundNow).
await logActivity('incoming_invoices_rebilled_bundle', { customerId: customer.id, invoiceId: result.invoiceId, count: result.count }, adminId);
return result;
}
/** Mark the supplier paid on the incoming invoice (the payable lives here). */