40eb03f0d8
The 3.94.0-beta.0 release PR (#859) failed its backend job on workflowEngine.test.js: bootCrmDb() runs every core migration in beforeAll, and with migrations 163-165 merged the setup crossed the suite's jest.setTimeout(30000) on CI runners — the log shows migration 099 still seeding after the hook timed out. Same pass is green locally and passed on #857's rebase minutes earlier: borderline-slow, not deterministic. - jest.config.js: testTimeout 120000 as the default, so bootCrmDb suites without an explicit pin stop being time bombs as the chain grows - every suite-level jest.setTimeout below 120s raised to 120s — local pins OVERRIDE the config default, so the 30s/60s ones would keep flaking regardless of the global bump No test logic changed anywhere. Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
212 lines
9.6 KiB
JavaScript
212 lines
9.6 KiB
JavaScript
/**
|
|
* 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(120000);
|
|
|
|
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();
|
|
});
|
|
});
|