/** * 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(); }); });