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:
@@ -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,
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user