165cebdb5c
Closes #866. Three features, all behind the `incomingInvoices` feature flag: 1. Attach the stored supplier proof PDF to the client-invoice email when a captured invoice is re-billed/passed through, as a SEPARATE attachment so invoice immutability holds. Global default (off), per-customer tri-state override, and per-file selection in a new Send dialog. A missing proof at issue time stamps inbound_documents.proof_attach_error rather than silently dropping, and never blocks the send. Proof filename is a configurable template with {INVOICE} {SUPPLIER} {YEAR} {MONTH} {SEQ}/{SEQ:0Nd} tokens. 2. Re-bills & passthrough panel under CRM → Customer, grouped Open/Sent/Paid with status derived from the linked invoice lifecycle rather than a duplicated column. 3. Cross-add dialog rolling open hours and open re-bills into one invoice, symmetric from both entry points. The two stay distinct, contiguous line groups — never merged into shared line items. Migration 169 is additive, hasColumn-guarded and idempotent. Review (two rounds) closed two concerns: - Storno stranding: nothing cleared inbound_documents.billed_invoice_id when a covering invoice was cancelled, so a Storno'd re-bill showed as Open in the new panel while every billing path filters on that column being NULL — the supplier cost could never be re-billed. releaseRebillsForCancelledInvoice now detaches the linkage on both invoice-cancel paths, with a regression test on the issued-cancel path. - Permission gating: the new controls rendered on data presence alone while their endpoints require accounting.view / accounting.manage / customers.edit. Now gated at both the query and render layers. Known follow-up: two cross-add counter queries are gated on a permission their endpoint does not check (HoursSection.tsx:174, CustomerCrmPanels.tsx:270) — degrades safely, one line each.
257 lines
12 KiB
JavaScript
257 lines
12 KiB
JavaScript
/**
|
|
* Issue #866 — the createInvoice-free halves of the re-bill proof + CRM panel
|
|
* feature, against a real SQLite schema:
|
|
*
|
|
* • listCustomerRebills — status DERIVED from the linked invoice lifecycle
|
|
* (open / sent / paid; a cancelled/Storno'd cover drops back to open) plus
|
|
* cost-vs-rebilled math and mode.
|
|
* • collectRebillProofAttachments — the Send-dialog per-file selection, the
|
|
* all-or-none default resolution (per-customer override else global), the
|
|
* Beleg-<inv#> filename (suffix only when >1), and the missing-file marker.
|
|
*
|
|
* The invoice-MINTING paths (billCombinedForCustomer / billPendingRebills) call
|
|
* createInvoice inside a db.transaction, which deadlocks on the SQLite harness
|
|
* (global-db sequence write vs. held write lock) — same limitation the sibling
|
|
* incomingInvoiceRebill.test.js documents. They're covered by the existing
|
|
* billPendingRebills / billUnbilledEntries suites; here we hand-craft billed
|
|
* state instead.
|
|
*/
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
|
|
|
jest.setTimeout(120000);
|
|
|
|
describe('#866 re-bill proof attachment + CRM panel', () => {
|
|
let db;
|
|
let cleanup;
|
|
let adminId;
|
|
let expenseService;
|
|
let rebillProofs;
|
|
let flagCache;
|
|
|
|
beforeAll(async () => {
|
|
({ db, cleanup } = await bootCrmDb());
|
|
const dbModule = require('../../src/database/db');
|
|
dbModule.logActivity = async () => {};
|
|
({ adminId } = await seedMinimal(db));
|
|
expenseService = require('../../src/services/expenseService');
|
|
rebillProofs = require('../../src/services/invoice/rebillProofs');
|
|
flagCache = require('../../src/middleware/requireFeatureFlag');
|
|
}, 120000);
|
|
|
|
afterAll(async () => {
|
|
if (cleanup) await cleanup();
|
|
});
|
|
|
|
const unwrapId = (ins) => (typeof ins[0] === 'object' ? ins[0].id : ins[0]);
|
|
let seq = 0;
|
|
|
|
async function makeCustomer(overrides = {}) {
|
|
seq += 1;
|
|
const ins = await db('customer_accounts').insert({
|
|
email: `c866-${seq}@example.com`,
|
|
display_name: `C866 ${seq}`,
|
|
password_hash: 'x',
|
|
preferred_language: 'de',
|
|
is_active: 1,
|
|
billing_cadence: 'per_event',
|
|
created_at: new Date(),
|
|
...overrides,
|
|
}).returning('id');
|
|
return unwrapId(ins);
|
|
}
|
|
|
|
async function makeDoc(customerId, overrides = {}) {
|
|
const ins = await db('inbound_documents').insert({
|
|
source: 'upload', status: 'categorized', parse_status: 'parsed', parse_method: 'none',
|
|
supplier_name: 'ACME AG', currency: 'CHF', total_amount_minor: 10000,
|
|
invoice_date: '2026-06-01', disposition: 'rebill', customer_account_id: customerId,
|
|
created_at: new Date(), updated_at: new Date(),
|
|
...overrides,
|
|
}).returning('id');
|
|
return unwrapId(ins);
|
|
}
|
|
|
|
async function makeInvoice(customerId, status, number) {
|
|
const ins = await db('invoices').insert({
|
|
invoice_number: number,
|
|
customer_account_id: customerId,
|
|
status,
|
|
currency: 'CHF',
|
|
issue_date: '2026-06-01', due_date: '2026-07-01',
|
|
vat_rate: 0, net_amount_minor: 10000, vat_amount_minor: 0, total_amount_minor: 10000,
|
|
created_at: new Date(), updated_at: new Date(),
|
|
}).returning('id');
|
|
return unwrapId(ins);
|
|
}
|
|
|
|
describe('listCustomerRebills', () => {
|
|
it('derives open / sent / paid and open→cost==rebilled for passthrough, +markup for rebill', async () => {
|
|
const customerId = await makeCustomer();
|
|
|
|
// Open re-bill (10% markup): rebilled = 11000.
|
|
await makeDoc(customerId, { total_amount_minor: 10000, markup_type: 'percent', markup_percent: 10 });
|
|
// Open passthrough: no markup, rebilled == cost.
|
|
await makeDoc(customerId, { disposition: 'durchlaufend', total_amount_minor: 5000, markup_type: 'none' });
|
|
// Sent (on a 'sent' invoice).
|
|
const sentInv = await makeInvoice(customerId, 'sent', 'R-2026-0001');
|
|
await makeDoc(customerId, { total_amount_minor: 8000, markup_type: 'none', billed_invoice_id: sentInv });
|
|
// Paid.
|
|
const paidInv = await makeInvoice(customerId, 'paid', 'R-2026-0002');
|
|
await makeDoc(customerId, { total_amount_minor: 8000, markup_type: 'none', billed_invoice_id: paidInv });
|
|
// Cancelled cover → drops back to 'open', no invoice link surfaced.
|
|
const cancInv = await makeInvoice(customerId, 'cancelled', 'R-2026-0003');
|
|
await makeDoc(customerId, { total_amount_minor: 8000, markup_type: 'none', billed_invoice_id: cancInv });
|
|
|
|
const items = await expenseService.listCustomerRebills(customerId);
|
|
const byStatus = (s) => items.filter((r) => r.status === s);
|
|
|
|
expect(items).toHaveLength(5);
|
|
expect(byStatus('open')).toHaveLength(3); // 2 genuinely-open + 1 cancelled-cover
|
|
expect(byStatus('sent')).toHaveLength(1);
|
|
expect(byStatus('paid')).toHaveLength(1);
|
|
|
|
const rebill = items.find((r) => r.mode === 'rebill' && r.costMinor === 10000);
|
|
expect(rebill.rebilledMinor).toBe(11000);
|
|
const passthrough = items.find((r) => r.mode === 'passthrough');
|
|
expect(passthrough.rebilledMinor).toBe(passthrough.costMinor);
|
|
|
|
const sent = byStatus('sent')[0];
|
|
expect(sent.invoiceNumber).toBe('R-2026-0001');
|
|
expect(sent.invoiceId).toBe(sentInv);
|
|
|
|
const cancelledCover = items.find((r) => r.status === 'open' && r.invoiceNumber === null && r.costMinor === 8000);
|
|
expect(cancelledCover).toBeDefined(); // cancelled cover isn't shown as a live invoice link
|
|
});
|
|
});
|
|
|
|
describe('storno releases the re-bill linkage (#866 review)', () => {
|
|
it("clears billed_invoice_id so a Storno'd cover returns to the billable pool", async () => {
|
|
const invoiceService = require('../../src/services/invoiceService');
|
|
const customerId = await makeCustomer();
|
|
const invId = await makeInvoice(customerId, 'sent', 'R-2026-9000');
|
|
const lineIns = await db('invoice_line_items').insert({
|
|
invoice_id: invId, position: 1, quantity: 1, description: 'Rebill',
|
|
unit_price_minor: 8000, discount_percent: 0, line_total_minor: 8000,
|
|
}).returning('id');
|
|
const lineId = unwrapId(lineIns);
|
|
const docId = await makeDoc(customerId, {
|
|
total_amount_minor: 8000, markup_type: 'none', billed_invoice_id: invId, billed_invoice_line_item_id: lineId,
|
|
});
|
|
|
|
// Storno claims a fresh number from document_sequences; the other tests
|
|
// seed explicit R-2026-000x numbers without advancing it, so push the
|
|
// counter past them to avoid a number collision (a test artifact — real
|
|
// invoices always claim through the sequence).
|
|
await db('document_sequences').insert({ kind: 'invoice', year: 2026, current_value: 9000, created_at: new Date(), updated_at: new Date() })
|
|
.onConflict(['kind', 'year']).ignore();
|
|
await db('document_sequences').where({ kind: 'invoice', year: 2026 }).update({ current_value: 9000 });
|
|
|
|
// Storno the covering invoice (the issued-cancel path).
|
|
await db.transaction(async (trx) => invoiceService.createStorno(invId, adminId, trx));
|
|
|
|
const doc = await db('inbound_documents').where({ id: docId }).first();
|
|
expect(doc.billed_invoice_id).toBeNull();
|
|
expect(doc.billed_invoice_line_item_id).toBeNull();
|
|
|
|
// It now surfaces as a genuinely-open item AND the pending pool picks it up.
|
|
const items = await expenseService.listCustomerRebills(customerId);
|
|
const row = items.find((r) => r.id === docId);
|
|
expect(row.status).toBe('open');
|
|
expect(row.invoiceId).toBeNull();
|
|
const pending = await db('inbound_documents')
|
|
.where({ customer_account_id: customerId }).whereNull('billed_invoice_id')
|
|
.whereIn('disposition', ['rebill', 'durchlaufend']).where('status', 'categorized');
|
|
expect(pending.map((p) => p.id)).toContain(docId);
|
|
});
|
|
});
|
|
|
|
describe('collectRebillProofAttachments', () => {
|
|
const businessDocs = () => path.join(process.env.STORAGE_PATH, 'business-docs', 'inbound', '2026');
|
|
|
|
async function enableIncoming() {
|
|
const existing = await db('feature_flags').where({ key: 'incomingInvoices' }).first();
|
|
if (existing) await db('feature_flags').where({ key: 'incomingInvoices' }).update({ value: 1 });
|
|
else await db('feature_flags').insert({ key: 'incomingInvoices', value: 1 });
|
|
flagCache.invalidateFeatureFlagCache();
|
|
}
|
|
|
|
function writeProof(name) {
|
|
fs.mkdirSync(businessDocs(), { recursive: true });
|
|
const p = path.join(businessDocs(), name);
|
|
fs.writeFileSync(p, '%PDF-1.4\n% test proof\n');
|
|
return p;
|
|
}
|
|
|
|
it('honours explicit selection, names Beleg-<inv#>, and marks a missing file', async () => {
|
|
await enableIncoming();
|
|
const customerId = await makeCustomer();
|
|
const invId = await makeInvoice(customerId, 'scheduled', 'R-2026-1000');
|
|
const invoice = await db('invoices').where({ id: invId }).first();
|
|
|
|
const good1 = await makeDoc(customerId, { billed_invoice_id: invId, file_path: writeProof('p1.pdf') });
|
|
const good2 = await makeDoc(customerId, { billed_invoice_id: invId, file_path: writeProof('p2.pdf') });
|
|
const missing = await makeDoc(customerId, { billed_invoice_id: invId, file_path: path.join(businessDocs(), 'nope.pdf') });
|
|
|
|
// Select the two good proofs → two attachments, suffixed because >1.
|
|
const both = await rebillProofs.collectRebillProofAttachments(invoice, null, [good1, good2]);
|
|
expect(both.map((a) => a.filename).sort()).toEqual(['Beleg-R-2026-1000-1.pdf', 'Beleg-R-2026-1000-2.pdf']);
|
|
|
|
// Select exactly one → single, unsuffixed.
|
|
const one = await rebillProofs.collectRebillProofAttachments(invoice, null, [good1]);
|
|
expect(one).toHaveLength(1);
|
|
expect(one[0].filename).toBe('Beleg-R-2026-1000.pdf');
|
|
|
|
// Select the missing-file doc → no attachment, but a marker is persisted.
|
|
const none = await rebillProofs.collectRebillProofAttachments(invoice, null, [missing]);
|
|
expect(none).toHaveLength(0);
|
|
const markerRow = await db('inbound_documents').where({ id: missing }).first('proof_attach_error');
|
|
expect(markerRow.proof_attach_error).toBeTruthy();
|
|
// A successful attach clears any prior marker.
|
|
await rebillProofs.collectRebillProofAttachments(invoice, null, [good1]);
|
|
const cleared = await db('inbound_documents').where({ id: good1 }).first('proof_attach_error');
|
|
expect(cleared.proof_attach_error).toBeNull();
|
|
});
|
|
|
|
it('resolves the all-or-none default from the per-customer override then global', async () => {
|
|
await enableIncoming();
|
|
const customerId = await makeCustomer();
|
|
const invId = await makeInvoice(customerId, 'scheduled', 'R-2026-2000');
|
|
const invoice = await db('invoices').where({ id: invId }).first();
|
|
await makeDoc(customerId, { billed_invoice_id: invId, file_path: writeProof('d1.pdf') });
|
|
|
|
// Global default off, no override → none.
|
|
const off = await rebillProofs.collectRebillProofAttachments(invoice, { rebill_attach_proof: null }, undefined);
|
|
expect(off).toHaveLength(0);
|
|
|
|
// Per-customer override ON → all, regardless of the (off) global.
|
|
const on = await rebillProofs.collectRebillProofAttachments(invoice, { rebill_attach_proof: true }, undefined);
|
|
expect(on).toHaveLength(1);
|
|
|
|
// Global ON (no override) → all.
|
|
await db('app_settings').insert({ setting_key: 'accounting_rebill_attach_proof', setting_value: JSON.stringify(true), setting_type: 'accounting' });
|
|
const globalOn = await rebillProofs.collectRebillProofAttachments(invoice, { rebill_attach_proof: null }, undefined);
|
|
expect(globalOn).toHaveLength(1);
|
|
// Override OFF beats global ON.
|
|
const overrideOff = await rebillProofs.collectRebillProofAttachments(invoice, { rebill_attach_proof: false }, undefined);
|
|
expect(overrideOff).toHaveLength(0);
|
|
});
|
|
|
|
it('attaches nothing when the incoming-invoices flag is off', async () => {
|
|
const existing = await db('feature_flags').where({ key: 'incomingInvoices' }).first();
|
|
if (existing) await db('feature_flags').where({ key: 'incomingInvoices' }).update({ value: 0 });
|
|
else await db('feature_flags').insert({ key: 'incomingInvoices', value: 0 });
|
|
flagCache.invalidateFeatureFlagCache();
|
|
|
|
const customerId = await makeCustomer();
|
|
const invId = await makeInvoice(customerId, 'scheduled', 'R-2026-3000');
|
|
const invoice = await db('invoices').where({ id: invId }).first();
|
|
const doc = await makeDoc(customerId, { billed_invoice_id: invId, file_path: writeProof('f1.pdf') });
|
|
|
|
const res = await rebillProofs.collectRebillProofAttachments(invoice, { rebill_attach_proof: true }, [doc]);
|
|
expect(res).toHaveLength(0);
|
|
});
|
|
});
|
|
});
|