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
+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). */