feat(accounting): re-bill proof attachment, CRM panel & hours↔re-bills cross-add (#979)
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.
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* renderProofName — the configurable Beleg proof-attachment filename template.
|
||||
* Pure function; no DB. Covers token substitution, the multi-proof index
|
||||
* fallback, padding, and filesystem-safe sanitisation.
|
||||
*/
|
||||
const { renderProofName } = require('../../src/services/invoice/rebillProofs');
|
||||
|
||||
describe('renderProofName', () => {
|
||||
const base = { invoiceNumber: 'R-2026-0042', supplierName: 'ACME AG', seq: 1, hasMulti: false, issueDate: '2026-08-03' };
|
||||
|
||||
it('defaults to Beleg-<invoice>.pdf', () => {
|
||||
expect(renderProofName('Beleg-{INVOICE}', base)).toBe('Beleg-R-2026-0042.pdf');
|
||||
expect(renderProofName('', base)).toBe('Beleg-R-2026-0042.pdf');
|
||||
expect(renderProofName(null, base)).toBe('Beleg-R-2026-0042.pdf');
|
||||
});
|
||||
|
||||
it('substitutes every token incl. padded SEQ and date parts', () => {
|
||||
expect(renderProofName('{SUPPLIER}-{INVOICE}-{YEAR}{MONTH}-{SEQ:03d}', { ...base, seq: 7 }))
|
||||
.toBe('ACME-AG-R-2026-0042-202608-007.pdf');
|
||||
});
|
||||
|
||||
it('appends an index for multiple proofs only when the template has no {SEQ}', () => {
|
||||
// No {SEQ} + multi → auto-suffixed with the index.
|
||||
expect(renderProofName('Beleg-{INVOICE}', { ...base, seq: 2, hasMulti: true })).toBe('Beleg-R-2026-0042-2.pdf');
|
||||
// Single proof → no suffix.
|
||||
expect(renderProofName('Beleg-{INVOICE}', { ...base, seq: 1, hasMulti: false })).toBe('Beleg-R-2026-0042.pdf');
|
||||
// Explicit {SEQ} → no double index even when multi.
|
||||
expect(renderProofName('Beleg-{INVOICE}-{SEQ}', { ...base, seq: 2, hasMulti: true })).toBe('Beleg-R-2026-0042-2.pdf');
|
||||
});
|
||||
|
||||
it('sanitises unsafe characters and slashes, and always ends in a single .pdf', () => {
|
||||
expect(renderProofName('Beleg {INVOICE}', { ...base, invoiceNumber: '2026/0042' })).toBe('Beleg-2026-0042.pdf');
|
||||
// Author-supplied extension is stripped and re-added (no double .pdf).
|
||||
expect(renderProofName('{INVOICE}.pdf', base)).toBe('R-2026-0042.pdf');
|
||||
// Falls back to 'Beleg' if the template renders empty after sanitising.
|
||||
expect(renderProofName('{SUPPLIER}', { ...base, supplierName: '///' })).toBe('Beleg.pdf');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user