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,98 @@
|
||||
// Combined hours + re-bills billing (issue #866, Feature 3).
|
||||
//
|
||||
// When a per-event customer has BOTH open hour entries AND open re-bills, the
|
||||
// admin can roll them into ONE client invoice. Hours and re-bills are NEVER
|
||||
// merged into shared line items — they stay as distinct lines, grouped
|
||||
// contiguously (hours first, then re-bills) with no section headers (product
|
||||
// decision). Each source row is stamped with its own invoice line so the CRM
|
||||
// panels keep deriving status correctly.
|
||||
//
|
||||
// Reuses the extracted build/stamp helpers from customerHoursService and
|
||||
// expenseService so there is one code path for line-item construction and
|
||||
// stamping. Lives in its own module to avoid a require cycle between those two
|
||||
// services.
|
||||
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { AppError } = require('../utils/errors');
|
||||
const invoiceService = require('./invoiceService');
|
||||
const customerHoursService = require('./customerHoursService');
|
||||
const expenseService = require('./expenseService');
|
||||
|
||||
/**
|
||||
* Bundle open hours and/or open re-bills for a per-event customer into one
|
||||
* invoice. At least one side must be requested AND non-empty.
|
||||
*
|
||||
* @param customerId
|
||||
* @param opts.includeHours include unbilled hour entries
|
||||
* @param opts.includeRebills include pending rebill/passthrough documents
|
||||
* @returns { invoiceId, entriesBilled, rebillsBilled }
|
||||
*/
|
||||
async function billCombinedForCustomer(customerId, { includeHours, includeRebills }, adminId) {
|
||||
const customer = await db('customer_accounts').where({ id: customerId }).first();
|
||||
if (!customer) throw new AppError('Customer not found', 404);
|
||||
// Both underlying flows are per-event only (accumulator cadences auto-bill on
|
||||
// save/categorise); keep the combined path consistent.
|
||||
if (customer.billing_cadence === 'monthly' || customer.billing_cadence === 'manual') {
|
||||
throw new AppError(
|
||||
'Accumulator-mode customers (monthly / manual) bill automatically; combining is for per-event customers.',
|
||||
409, 'CADENCE_MISMATCH',
|
||||
);
|
||||
}
|
||||
if (!includeHours && !includeRebills) {
|
||||
throw new AppError('Nothing selected to bill', 400, 'NOTHING_SELECTED');
|
||||
}
|
||||
|
||||
let logInfo = null;
|
||||
const result = await db.transaction(async (trx) => {
|
||||
const hours = includeHours
|
||||
? await customerHoursService.buildUnbilledHourLineItems(trx, customer)
|
||||
: { entries: [], lineItems: [] };
|
||||
const rebills = includeRebills
|
||||
? await expenseService.buildPendingRebillLineItems(trx, customer)
|
||||
: { docs: [], lineItems: [] };
|
||||
|
||||
if (hours.entries.length === 0 && rebills.docs.length === 0) {
|
||||
throw new AppError('No open hours or re-bills to bill', 409, 'NO_OPEN_ITEMS');
|
||||
}
|
||||
|
||||
// Contiguous, hours first then re-bills. Positions run 1..N across both
|
||||
// groups so each source row maps to exactly one invoice line.
|
||||
const lineItems = [];
|
||||
let pos = 0;
|
||||
for (const li of hours.lineItems) { pos += 1; lineItems.push({ ...li, position: pos }); }
|
||||
const hoursCount = pos;
|
||||
for (const li of rebills.lineItems) { pos += 1; lineItems.push({ ...li, position: pos }); }
|
||||
|
||||
const { invoiceIds } = await invoiceService.createInvoice({
|
||||
customerAccountId: customer.id,
|
||||
lineItems,
|
||||
}, adminId, trx);
|
||||
const invoiceId = invoiceIds[0];
|
||||
|
||||
const insertedLines = await trx('invoice_line_items').where({ invoice_id: invoiceId }).orderBy('position', 'asc');
|
||||
const lineByPos = new Map(insertedLines.map((li) => [li.position, li.id]));
|
||||
|
||||
if (hours.entries.length) {
|
||||
const hourLineIds = hours.entries.map((_, i) => lineByPos.get(i + 1) || null);
|
||||
await customerHoursService.stampBilledEntries(trx, hours.entries, invoiceId, hourLineIds);
|
||||
}
|
||||
if (rebills.docs.length) {
|
||||
const rebillLineIds = rebills.docs.map((_, i) => lineByPos.get(hoursCount + i + 1) || null);
|
||||
await expenseService.stampBilledRebills(trx, rebills.docs, invoiceId, rebillLineIds);
|
||||
}
|
||||
|
||||
logInfo = {
|
||||
meta: { customerId: customer.id, invoiceId, entriesBilled: hours.entries.length, rebillsBilled: rebills.docs.length },
|
||||
};
|
||||
return { invoiceId, entriesBilled: hours.entries.length, rebillsBilled: rebills.docs.length };
|
||||
});
|
||||
// Audit log after commit (global-db write deadlocks inside the SQLite trx).
|
||||
if (logInfo) {
|
||||
try {
|
||||
await logActivity('combined_hours_rebills_billed', logInfo.meta, null, `admin:${adminId}`);
|
||||
} catch (_) { /* audit log is best-effort */ }
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = { billCombinedForCustomer };
|
||||
@@ -581,6 +581,11 @@ async function updateCustomer(id, updates, updatedByAdminId) {
|
||||
// Per-customer Skonto opt-out (migration 112). Boolean, coerced
|
||||
// via formatBoolean below for SQLite compatibility.
|
||||
'skonto_disabled',
|
||||
// Per-customer re-bill proof-attachment override (migration 169, #866).
|
||||
// Tri-state: null = inherit global default, true/false = force. Handled
|
||||
// in its own branch below so null survives (formatBoolean would coerce
|
||||
// it to false and silently lose the "inherit" state).
|
||||
'rebill_attach_proof',
|
||||
];
|
||||
for (const f of fields) {
|
||||
if (updates[f] !== undefined) {
|
||||
@@ -597,6 +602,10 @@ async function updateCustomer(id, updates, updatedByAdminId) {
|
||||
|| f === 'skonto_disabled'
|
||||
) {
|
||||
allowed[f] = formatBoolean(updates[f]);
|
||||
} else if (f === 'rebill_attach_proof') {
|
||||
// Tri-state override. null/'' → NULL (inherit global default);
|
||||
// otherwise a real boolean (coerced for SQLite).
|
||||
allowed[f] = (updates[f] === null || updates[f] === '') ? null : formatBoolean(updates[f]);
|
||||
} else if (f === 'hourly_rate_minor') {
|
||||
// Default hourly rate. Null clears it (forces per-entry
|
||||
// overrides); otherwise coerce to a non-negative bigint-safe
|
||||
|
||||
@@ -447,6 +447,38 @@ async function deleteEntry(entryId, adminId) {
|
||||
* onto the running draft on save, so there should be no unbilled rows.
|
||||
* Returns the new invoice id.
|
||||
*/
|
||||
// Load this customer's unbilled hour entries and build their invoice line items
|
||||
// (no `position` yet — the caller assigns it, so hours can be combined
|
||||
// contiguously with re-bills in one invoice; #866). Shared by the hours-only
|
||||
// path and the combined orchestrator.
|
||||
async function buildUnbilledHourLineItems(trx, customer) {
|
||||
const entries = await trx('customer_hour_entries')
|
||||
.where({ customer_account_id: customer.id, status: 'unbilled' })
|
||||
.orderBy('entry_date', 'asc').orderBy('start_time', 'asc');
|
||||
const installDefaultMinor = await getInstallDefaultRateMinor(trx);
|
||||
const lineItems = entries.map((entry) => {
|
||||
const rate = resolveEffectiveRate(entry, customer, installDefaultMinor);
|
||||
return buildLineItemFromEntry(entry, rate);
|
||||
});
|
||||
return { entries, lineItems };
|
||||
}
|
||||
|
||||
// Stamp each hour entry with the invoice + its specific line-item id. `lineIds`
|
||||
// is aligned to `entries` order.
|
||||
async function stampBilledEntries(trx, entries, invoiceId, lineIds) {
|
||||
const now = new Date();
|
||||
for (let i = 0; i < entries.length; i += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await trx('customer_hour_entries').where({ id: entries[i].id }).update({
|
||||
status: 'billed',
|
||||
invoice_id: invoiceId,
|
||||
invoice_line_item_id: lineIds[i] || null,
|
||||
billed_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function billUnbilledEntries(customerId, adminId) {
|
||||
const customer = await db('customer_accounts').where({ id: customerId }).first();
|
||||
if (!customer) throw new AppError('Customer not found', 404);
|
||||
@@ -460,19 +492,11 @@ async function billUnbilledEntries(customerId, adminId) {
|
||||
|
||||
let logInfo = null; // logged after commit — see createEntry note.
|
||||
const result = await db.transaction(async (trx) => {
|
||||
const unbilled = await trx('customer_hour_entries')
|
||||
.where({ customer_account_id: customer.id, status: 'unbilled' })
|
||||
.orderBy('entry_date', 'asc').orderBy('start_time', 'asc');
|
||||
const { entries: unbilled, lineItems: rawLines } = await buildUnbilledHourLineItems(trx, customer);
|
||||
if (unbilled.length === 0) {
|
||||
throw new AppError('No unbilled entries to bill', 409, 'NO_UNBILLED');
|
||||
}
|
||||
|
||||
const installDefaultMinor = await getInstallDefaultRateMinor(trx);
|
||||
const lineItems = unbilled.map((entry, idx) => {
|
||||
const rate = resolveEffectiveRate(entry, customer, installDefaultMinor);
|
||||
const li = buildLineItemFromEntry(entry, rate);
|
||||
return { ...li, position: idx + 1 };
|
||||
});
|
||||
const lineItems = rawLines.map((li, idx) => ({ ...li, position: idx + 1 }));
|
||||
|
||||
// No installment metadata — hour-billing always mints a single
|
||||
// standalone invoice. createInvoice returns `{ invoiceIds: [N] }`
|
||||
@@ -491,19 +515,8 @@ async function billUnbilledEntries(customerId, adminId) {
|
||||
.where({ invoice_id: invoiceId })
|
||||
.orderBy('position', 'asc');
|
||||
const lineByPos = new Map(insertedLines.map((li) => [li.position, li.id]));
|
||||
|
||||
const now = new Date();
|
||||
for (let i = 0; i < unbilled.length; i += 1) {
|
||||
const entry = unbilled[i];
|
||||
const lineItemId = lineByPos.get(i + 1) || null;
|
||||
await trx('customer_hour_entries').where({ id: entry.id }).update({
|
||||
status: 'billed',
|
||||
invoice_id: invoiceId,
|
||||
invoice_line_item_id: lineItemId,
|
||||
billed_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
}
|
||||
const lineIds = unbilled.map((_, i) => lineByPos.get(i + 1) || null);
|
||||
await stampBilledEntries(trx, unbilled, invoiceId, lineIds);
|
||||
|
||||
logInfo = { type: 'hour_entries_billed', meta: { customerId: customer.id, invoiceId, entryCount: unbilled.length } };
|
||||
return { invoiceId, entriesBilled: unbilled.length };
|
||||
@@ -590,6 +603,9 @@ module.exports = {
|
||||
updateEntry,
|
||||
deleteEntry,
|
||||
billUnbilledEntries,
|
||||
// Shared with the combined hours+re-bills orchestrator (#866).
|
||||
buildUnbilledHourLineItems,
|
||||
stampBilledEntries,
|
||||
getInstallDefaultRateMinor,
|
||||
_internal: {
|
||||
computeDurationMinutes,
|
||||
|
||||
@@ -21,6 +21,9 @@ const { db, logActivity } = require('../database/db');
|
||||
const { AppError } = require('../utils/errors');
|
||||
const logger = require('../utils/logger');
|
||||
const invoiceService = require('./invoiceService');
|
||||
// Tri-state proof-attach resolver — single source of truth lives with the
|
||||
// send-time attachment logic (no require cycle: rebillProofs never imports us).
|
||||
const { resolveDefaultAttach } = require('./invoice/rebillProofs');
|
||||
|
||||
/**
|
||||
* Actor for logActivity. `adminId` is legitimately absent on automated paths —
|
||||
@@ -54,7 +57,7 @@ function toIsoDate(v) {
|
||||
// ── Accounting settings (app_settings, type 'accounting') ───────────────────
|
||||
async function getAccountingSettings() {
|
||||
const keys = ['accounting_km_rate_minor', 'accounting_per_diem_rate_minor', 'accounting_require_proof',
|
||||
'accounting_vat_reclaim_countries'];
|
||||
'accounting_vat_reclaim_countries', 'accounting_rebill_attach_proof'];
|
||||
let rows = [];
|
||||
try {
|
||||
rows = await db('app_settings').whereIn('setting_key', keys).select('setting_key', 'setting_value');
|
||||
@@ -69,6 +72,8 @@ async function getAccountingSettings() {
|
||||
kmRateMinor: Number.isFinite(Number(map.accounting_km_rate_minor)) ? Number(map.accounting_km_rate_minor) : 0,
|
||||
perDiemRateMinor: Number.isFinite(Number(map.accounting_per_diem_rate_minor)) ? Number(map.accounting_per_diem_rate_minor) : 0,
|
||||
requireProof: map.accounting_require_proof === true || map.accounting_require_proof === 1 || map.accounting_require_proof === '1',
|
||||
// Global default for attaching supplier proof PDFs on re-bill invoices (#866).
|
||||
rebillAttachProof: map.accounting_rebill_attach_proof === true || map.accounting_rebill_attach_proof === 1 || map.accounting_rebill_attach_proof === '1',
|
||||
vatReclaimCountries: Array.isArray(map.accounting_vat_reclaim_countries)
|
||||
? map.accounting_vat_reclaim_countries.map((c) => String(c || '').toUpperCase()) : [],
|
||||
};
|
||||
@@ -423,6 +428,14 @@ async function categorizeInbound(id, payload, adminId) {
|
||||
if (!row) throw new AppError('Incoming invoice not found', 404, 'INBOUND_NOT_FOUND');
|
||||
const doc = transformInbound(row);
|
||||
|
||||
// A doc attached to a customer will become a client invoice line, which
|
||||
// needs an amount. Require one NOW (0 is fine — a legitimately zero-value
|
||||
// pass-through — but null is not) rather than letting a value-less item sit
|
||||
// PENDING and blow up the whole bundle later at bill time.
|
||||
if (customerAccountId && doc.totalAmountMinor == null && doc.netAmountMinor == null) {
|
||||
throw new AppError('Set the invoice amount before re-billing (0 is allowed).', 400, 'AMOUNT_REQUIRED');
|
||||
}
|
||||
|
||||
// #1: unwind any prior re-bill so the disposition can change.
|
||||
if (doc.billedInvoiceId) await unwindBilledLine(trx, doc);
|
||||
|
||||
@@ -483,6 +496,11 @@ async function rebillInbound(id, payload, adminId, trx0) {
|
||||
const row = await trx('inbound_documents').where({ id }).first();
|
||||
if (!row) throw new AppError('Incoming invoice not found', 404, 'INBOUND_NOT_FOUND');
|
||||
const doc = transformInbound(row);
|
||||
// A client invoice line needs an amount (0 allowed, null not) — fail here
|
||||
// rather than deep inside buildInboundLineItem.
|
||||
if (doc.totalAmountMinor == null && doc.netAmountMinor == null) {
|
||||
throw new AppError('Set the invoice amount before re-billing (0 is allowed).', 400, 'AMOUNT_REQUIRED');
|
||||
}
|
||||
if (doc.billedInvoiceId) await unwindBilledLine(trx, doc);
|
||||
const markup = await resolveMarkup(
|
||||
{ markupType: doc.markupType, markupPercent: doc.markupPercent, markupFlatMinor: doc.markupFlatMinor },
|
||||
@@ -559,6 +577,44 @@ async function listPendingRebillSummary() {
|
||||
return Array.from(byCustomer.values()).sort((a, b) => b.openAmountMinor - a.openAmountMinor);
|
||||
}
|
||||
|
||||
// Load this customer's pending (categorised-but-unbilled) rebill/passthrough
|
||||
// documents and build their invoice line items (no `position` yet — the caller
|
||||
// assigns it, so re-bills can be combined contiguously with hours in one
|
||||
// invoice; #866). Shared by the re-bills-only path and the combined orchestrator.
|
||||
async function buildPendingRebillLineItems(trx, customer) {
|
||||
const docs = await trx('inbound_documents')
|
||||
.where({ customer_account_id: customer.id })
|
||||
.whereNull('billed_invoice_id')
|
||||
.whereIn('disposition', CUSTOMER_DISPOSITIONS)
|
||||
.where('status', 'categorized')
|
||||
.orderBy('invoice_date', 'asc').orderBy('id', 'asc');
|
||||
const lineItems = [];
|
||||
for (let i = 0; i < docs.length; i += 1) {
|
||||
const doc = transformInbound(docs[i]);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const markup = await resolveMarkup(
|
||||
{ markupType: doc.markupType, markupPercent: doc.markupPercent, markupFlatMinor: doc.markupFlatMinor },
|
||||
null, null, trx,
|
||||
);
|
||||
lineItems.push(buildInboundLineItem(doc, doc.disposition, markup));
|
||||
}
|
||||
return { docs, lineItems };
|
||||
}
|
||||
|
||||
// Stamp each inbound document with the invoice + its specific line-item id.
|
||||
// `lineIds` is aligned to `docs` order.
|
||||
async function stampBilledRebills(trx, docs, invoiceId, lineIds) {
|
||||
const now = new Date();
|
||||
for (let i = 0; i < docs.length; i += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await trx('inbound_documents').where({ id: docs[i].id }).update({
|
||||
billed_invoice_id: invoiceId,
|
||||
billed_invoice_line_item_id: lineIds[i] || null,
|
||||
updated_at: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-event flow: bundle all pending rebill/passthrough documents for a
|
||||
* customer into ONE invoice, one line per document. Refuses for monthly/manual
|
||||
@@ -576,24 +632,9 @@ async function billPendingRebills(customerId, adminId) {
|
||||
}
|
||||
|
||||
const result = await db.transaction(async (trx) => {
|
||||
const pending = await trx('inbound_documents')
|
||||
.where({ customer_account_id: customer.id })
|
||||
.whereNull('billed_invoice_id')
|
||||
.whereIn('disposition', CUSTOMER_DISPOSITIONS)
|
||||
.where('status', 'categorized')
|
||||
.orderBy('invoice_date', 'asc').orderBy('id', 'asc');
|
||||
const { docs: pending, lineItems: rawLines } = await buildPendingRebillLineItems(trx, customer);
|
||||
if (pending.length === 0) throw new AppError('No pending re-bills to bill', 409, 'NO_PENDING');
|
||||
|
||||
const lineItems = [];
|
||||
for (let i = 0; i < pending.length; i += 1) {
|
||||
const doc = transformInbound(pending[i]);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const markup = await resolveMarkup(
|
||||
{ markupType: doc.markupType, markupPercent: doc.markupPercent, markupFlatMinor: doc.markupFlatMinor },
|
||||
null, null, trx,
|
||||
);
|
||||
lineItems.push({ ...buildInboundLineItem(doc, doc.disposition, markup), position: i + 1 });
|
||||
}
|
||||
const lineItems = rawLines.map((li, idx) => ({ ...li, position: idx + 1 }));
|
||||
|
||||
const { invoiceIds } = await invoiceService.createInvoice({
|
||||
customerAccountId: customer.id,
|
||||
@@ -603,15 +644,8 @@ async function billPendingRebills(customerId, adminId) {
|
||||
|
||||
const insertedLines = await trx('invoice_line_items').where({ invoice_id: invoiceId }).orderBy('position', 'asc');
|
||||
const lineByPos = new Map(insertedLines.map((li) => [li.position, li.id]));
|
||||
const now = new Date();
|
||||
for (let i = 0; i < pending.length; i += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await trx('inbound_documents').where({ id: pending[i].id }).update({
|
||||
billed_invoice_id: invoiceId,
|
||||
billed_invoice_line_item_id: lineByPos.get(i + 1) || null,
|
||||
updated_at: now,
|
||||
});
|
||||
}
|
||||
const lineIds = pending.map((_, i) => lineByPos.get(i + 1) || null);
|
||||
await stampBilledRebills(trx, pending, invoiceId, lineIds);
|
||||
|
||||
return { invoiceId, count: pending.length };
|
||||
});
|
||||
@@ -620,6 +654,106 @@ async function billPendingRebills(customerId, adminId) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Derive a re-bill row's status from its linked client-invoice lifecycle — no
|
||||
// duplicated status column (avoids drift, #866). A covering invoice that was
|
||||
// cancelled (Storno) drops back to 'open' so storno'd items never inflate the
|
||||
// sent/paid aggregates.
|
||||
function deriveRebillStatus(billedInvoiceId, invoiceStatus) {
|
||||
if (!billedInvoiceId) return 'open';
|
||||
if (invoiceStatus === 'paid') return 'paid';
|
||||
if (invoiceStatus === 'cancelled') return 'open';
|
||||
return 'sent'; // scheduled / sent / overdue
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-bill / passthrough items for one customer (issue #866, Feature 2). One row
|
||||
* per captured supplier invoice attached to this customer as a rebill/
|
||||
* passthrough, with cost vs re-billed amount (incl. markup), mode, linked event
|
||||
* + client invoice, and a status DERIVED from the invoice lifecycle. History-
|
||||
* only — feeds the CRM → Customer panel.
|
||||
*/
|
||||
async function listCustomerRebills(customerId) {
|
||||
const rows = await db('inbound_documents as d')
|
||||
.leftJoin('invoices as inv', 'd.billed_invoice_id', 'inv.id')
|
||||
.leftJoin('events as e', 'd.event_id', 'e.id')
|
||||
.where('d.customer_account_id', customerId)
|
||||
.whereIn('d.disposition', CUSTOMER_DISPOSITIONS)
|
||||
.where('d.status', 'categorized')
|
||||
.orderBy('d.invoice_date', 'desc').orderBy('d.id', 'desc')
|
||||
.select(
|
||||
'd.id', 'd.supplier_name', 'd.invoice_date', 'd.currency',
|
||||
'd.net_amount_minor', 'd.total_amount_minor', 'd.disposition',
|
||||
'd.markup_type', 'd.markup_percent', 'd.markup_flat_minor',
|
||||
'd.billed_invoice_id', 'd.proof_attach_error', 'd.file_path', 'd.event_id',
|
||||
'e.event_name as event_name',
|
||||
'inv.invoice_number as invoice_number', 'inv.status as invoice_status',
|
||||
);
|
||||
|
||||
return rows.map((r) => {
|
||||
const base = r.total_amount_minor != null ? Number(r.total_amount_minor)
|
||||
: (r.net_amount_minor != null ? Number(r.net_amount_minor) : 0);
|
||||
const isPassthrough = r.disposition === 'durchlaufend';
|
||||
// Passthrough is invoiced at cost (VAT-neutral, no markup); re-bill carries
|
||||
// the stored markup snapshot.
|
||||
const markup = isPassthrough ? { type: 'none', percent: null, flatMinor: null } : {
|
||||
type: MARKUP_TYPES.includes(r.markup_type) ? r.markup_type : 'none',
|
||||
percent: r.markup_percent != null ? Number(r.markup_percent) : null,
|
||||
flatMinor: Number.isInteger(r.markup_flat_minor) ? r.markup_flat_minor : null,
|
||||
};
|
||||
const status = deriveRebillStatus(r.billed_invoice_id, r.invoice_status);
|
||||
const billed = status !== 'open';
|
||||
return {
|
||||
id: r.id,
|
||||
supplierName: r.supplier_name || null,
|
||||
date: toIsoDate(r.invoice_date),
|
||||
currency: r.currency || null,
|
||||
costMinor: base,
|
||||
rebilledMinor: base + computeMarkupMinor(base, markup),
|
||||
mode: isPassthrough ? 'passthrough' : 'rebill',
|
||||
eventId: r.event_id || null,
|
||||
eventName: r.event_name || null,
|
||||
hasProof: !!r.file_path,
|
||||
proofAttachError: r.proof_attach_error || null,
|
||||
status,
|
||||
invoiceId: billed ? r.billed_invoice_id : null,
|
||||
invoiceNumber: billed ? (r.invoice_number || null) : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-bill proofs attached to ONE (not-yet-sent) client invoice, for the Send
|
||||
* dialog's per-file selection (#866, Feature 1). Also returns the resolved
|
||||
* attach default (per-customer override else global) so the dialog can
|
||||
* pre-check the boxes.
|
||||
*/
|
||||
async function listInvoiceRebillProofs(invoiceId) {
|
||||
const rows = await db('inbound_documents')
|
||||
.where({ billed_invoice_id: invoiceId })
|
||||
.whereIn('disposition', CUSTOMER_DISPOSITIONS)
|
||||
.orderBy('id', 'asc')
|
||||
.select('id', 'supplier_name', 'original_filename', 'file_path', 'currency',
|
||||
'net_amount_minor', 'total_amount_minor', 'disposition', 'proof_attach_error');
|
||||
const proofs = rows.map((r) => ({
|
||||
id: r.id,
|
||||
supplierName: r.supplier_name || null,
|
||||
filename: r.original_filename || null,
|
||||
hasProof: !!r.file_path,
|
||||
currency: r.currency || null,
|
||||
amountMinor: r.total_amount_minor != null ? Number(r.total_amount_minor)
|
||||
: (r.net_amount_minor != null ? Number(r.net_amount_minor) : 0),
|
||||
mode: r.disposition === 'durchlaufend' ? 'passthrough' : 'rebill',
|
||||
proofAttachError: r.proof_attach_error || null,
|
||||
}));
|
||||
|
||||
const inv = await db('invoices').where({ id: invoiceId }).first('customer_account_id');
|
||||
const customer = inv && inv.customer_account_id
|
||||
? await db('customer_accounts').where({ id: inv.customer_account_id }).first('rebill_attach_proof')
|
||||
: null;
|
||||
const { rebillAttachProof } = await getAccountingSettings();
|
||||
return { proofs, attachDefault: resolveDefaultAttach(customer, rebillAttachProof) };
|
||||
}
|
||||
|
||||
/** Mark the supplier paid on the incoming invoice (the payable lives here). */
|
||||
async function markInboundSupplierPayment(id, { paid, paidAt, paymentMethod, paymentReference }, adminId) {
|
||||
await getInbound(id);
|
||||
@@ -833,6 +967,11 @@ module.exports = {
|
||||
rebillInbound,
|
||||
listPendingRebillSummary,
|
||||
billPendingRebills,
|
||||
listCustomerRebills,
|
||||
listInvoiceRebillProofs,
|
||||
// Shared with the combined hours+re-bills orchestrator (#866).
|
||||
buildPendingRebillLineItems,
|
||||
stampBilledRebills,
|
||||
markInboundSupplierPayment,
|
||||
// expenses
|
||||
createExpense,
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// Re-bill / passthrough proof attachments (issue #866).
|
||||
//
|
||||
// When a client invoice re-bills one or more captured supplier invoices
|
||||
// (inbound_documents.billed_invoice_id → this invoice), the original stored
|
||||
// supplier PDF can ride along on the invoice email as a SEPARATE attachment
|
||||
// (the invoice PDF itself is never touched — invoice immutability). Whether a
|
||||
// given proof attaches is decided at ISSUE time:
|
||||
//
|
||||
// • Manual send → the admin's per-file selection from the Send dialog
|
||||
// (proofInboundIds), which defaults to the resolved toggle.
|
||||
// • Auto send → (scheduler / monthly flush, no admin present) the resolved
|
||||
// default: per-customer override (customer_accounts
|
||||
// .rebill_attach_proof, tri-state) else the global
|
||||
// accounting_rebill_attach_proof (default off).
|
||||
//
|
||||
// A selected proof whose file is missing/unreadable does NOT silently drop and
|
||||
// does NOT block the send — we stamp inbound_documents.proof_attach_error so the
|
||||
// re-bill row surfaces a recovery banner in CRM → Customer.
|
||||
//
|
||||
// Kept in its own module (not expenseService) to avoid the invoiceService ↔
|
||||
// expenseService require cycle.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { db } = require('../../database/db');
|
||||
const logger = require('../../utils/logger');
|
||||
const { isFeatureEnabled } = require('../../middleware/requireFeatureFlag');
|
||||
const { assertPathInside } = require('../../utils/safePath');
|
||||
const { getStoragePath } = require('../../config/storage');
|
||||
|
||||
// Dispositions that re-bill/pass a supplier invoice to a client (mirrors
|
||||
// expenseService.CUSTOMER_DISPOSITIONS — duplicated as a 2-item constant rather
|
||||
// than imported, to keep this module free of the require cycle).
|
||||
const CUSTOMER_DISPOSITIONS = ['rebill', 'durchlaufend'];
|
||||
|
||||
const DEFAULT_PROOF_FILENAME_FORMAT = 'Beleg-{INVOICE}';
|
||||
|
||||
// Render a proof attachment filename from the admin-configurable template.
|
||||
// Tokens: {INVOICE} (client invoice number), {SUPPLIER}, {YEAR}, {MONTH},
|
||||
// {SEQ} / {SEQ:0Nd} (per-invoice proof index). Always yields a filesystem-safe
|
||||
// name ending in .pdf. When one invoice carries several proofs but the template
|
||||
// has no {SEQ}, an index is appended so the filenames stay unique.
|
||||
function renderProofName(format, { invoiceNumber, supplierName, seq, hasMulti, issueDate }) {
|
||||
const d = issueDate ? new Date(issueDate) : new Date();
|
||||
const year = Number.isNaN(d.getTime()) ? '' : String(d.getFullYear());
|
||||
const month = Number.isNaN(d.getTime()) ? '' : String(d.getMonth() + 1).padStart(2, '0');
|
||||
let hadSeq = false;
|
||||
let name = String(format || DEFAULT_PROOF_FILENAME_FORMAT)
|
||||
.replace(/\{INVOICE\}/g, invoiceNumber || 'invoice')
|
||||
.replace(/\{SUPPLIER\}/g, supplierName || '')
|
||||
.replace(/\{YEAR\}/g, year)
|
||||
.replace(/\{MONTH\}/g, month)
|
||||
.replace(/\{SEQ:(\d+)d\}/g, (_, p) => { hadSeq = true; return String(seq).padStart(parseInt(p, 10), '0'); })
|
||||
.replace(/\{SEQ\}/g, () => { hadSeq = true; return String(seq); });
|
||||
if (hasMulti && !hadSeq) name += `-${seq}`;
|
||||
// Filesystem-safe: drop any author-supplied extension, collapse whitespace +
|
||||
// unsafe chars to '-', trim stray separators. Some German schemes use '/'.
|
||||
name = name.replace(/\.pdf$/i, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^[-.]+|[-.]+$/g, '');
|
||||
if (!name) name = 'Beleg';
|
||||
return `${name}.pdf`;
|
||||
}
|
||||
|
||||
async function readFilenameFormat() {
|
||||
try {
|
||||
const row = await db('app_settings').where({ setting_key: 'crm_rebill_proof_filename_format' }).first('setting_value');
|
||||
if (!row) return DEFAULT_PROOF_FILENAME_FORMAT;
|
||||
let v = row.setting_value;
|
||||
if (typeof v === 'string') { try { v = JSON.parse(v); } catch (_e) { /* keep raw */ } }
|
||||
return (typeof v === 'string' && v.trim()) ? v.trim() : DEFAULT_PROOF_FILENAME_FORMAT;
|
||||
} catch (_e) {
|
||||
return DEFAULT_PROOF_FILENAME_FORMAT;
|
||||
}
|
||||
}
|
||||
|
||||
async function readGlobalDefault() {
|
||||
try {
|
||||
const row = await db('app_settings').where({ setting_key: 'accounting_rebill_attach_proof' }).first('setting_value');
|
||||
if (!row) return false;
|
||||
let v = row.setting_value;
|
||||
if (typeof v === 'string') { try { v = JSON.parse(v); } catch (_e) { /* keep raw */ } }
|
||||
return v === true || v === 1 || v === '1';
|
||||
} catch (_e) {
|
||||
return false; // app_settings absent in some test harnesses → off
|
||||
}
|
||||
}
|
||||
|
||||
// Tri-state resolution: per-customer override wins; NULL/undefined inherits the
|
||||
// global default.
|
||||
function resolveDefaultAttach(customer, globalOn) {
|
||||
const ov = customer ? customer.rebill_attach_proof : null;
|
||||
if (ov === null || ov === undefined) return !!globalOn;
|
||||
return ov === true || ov === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the proof attachments for an invoice being issued, and persist per-row
|
||||
* failure markers. Returns an array of nodemailer-style attachment descriptors
|
||||
* ({ filename, contentPath, contentType }) — possibly empty. Never throws into
|
||||
* the send path.
|
||||
*
|
||||
* @param invoice the invoice row (needs id, invoice_number)
|
||||
* @param customer the customer_accounts row (for the tri-state override)
|
||||
* @param proofInboundIds optional explicit selection (manual send). When
|
||||
* omitted, the resolved default decides all-or-none.
|
||||
*/
|
||||
async function collectRebillProofAttachments(invoice, customer, proofInboundIds) {
|
||||
// Backend flag gate — no proof handling at all when incoming-invoices is off.
|
||||
if (!(await isFeatureEnabled('incomingInvoices'))) return [];
|
||||
|
||||
let rebillRows;
|
||||
try {
|
||||
rebillRows = await db('inbound_documents')
|
||||
.where({ billed_invoice_id: invoice.id })
|
||||
.whereIn('disposition', CUSTOMER_DISPOSITIONS)
|
||||
.select('id', 'file_path', 'supplier_name', 'original_filename');
|
||||
} catch (_e) {
|
||||
return []; // table absent (older install / test harness)
|
||||
}
|
||||
if (!rebillRows || rebillRows.length === 0) return [];
|
||||
|
||||
// Decide the set to attach.
|
||||
let selected;
|
||||
if (Array.isArray(proofInboundIds)) {
|
||||
// Manual send: attach exactly the admin's picks that actually belong to
|
||||
// this invoice's re-bill set (ignore anything foreign).
|
||||
const wanted = new Set(proofInboundIds.map((n) => parseInt(n, 10)).filter(Number.isInteger));
|
||||
selected = rebillRows.filter((r) => wanted.has(r.id));
|
||||
} else {
|
||||
// Auto send: all-or-none per the resolved default.
|
||||
const globalOn = await readGlobalDefault();
|
||||
selected = resolveDefaultAttach(customer, globalOn) ? rebillRows : [];
|
||||
}
|
||||
if (selected.length === 0) return [];
|
||||
|
||||
const businessDocs = path.join(getStoragePath(), 'business-docs');
|
||||
const format = await readFilenameFormat();
|
||||
const multi = selected.length > 1;
|
||||
const attachments = [];
|
||||
|
||||
for (let i = 0; i < selected.length; i += 1) {
|
||||
const row = selected[i];
|
||||
let markerErr = null;
|
||||
if (!row.file_path) {
|
||||
markerErr = 'proof file missing (no stored PDF on the supplier invoice)';
|
||||
} else {
|
||||
try {
|
||||
const safe = assertPathInside(row.file_path, [businessDocs]);
|
||||
if (!fs.existsSync(safe)) {
|
||||
markerErr = 'proof file not found on disk at issue time';
|
||||
} else {
|
||||
attachments.push({
|
||||
filename: renderProofName(format, {
|
||||
invoiceNumber: invoice.invoice_number,
|
||||
supplierName: row.supplier_name,
|
||||
seq: i + 1,
|
||||
hasMulti: multi,
|
||||
issueDate: invoice.issue_date,
|
||||
}),
|
||||
contentPath: safe,
|
||||
contentType: 'application/pdf',
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
markerErr = `proof path rejected: ${e.message}`;
|
||||
}
|
||||
}
|
||||
// Persist / clear the failure marker (best-effort; never blocks the send).
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await db('inbound_documents').where({ id: row.id })
|
||||
.update({ proof_attach_error: markerErr, updated_at: new Date() });
|
||||
} catch (_e) { /* marker column may be absent pre-migration — ignore */ }
|
||||
if (markerErr) logger.warn?.(`rebillProofs: invoice ${invoice.invoice_number} inbound ${row.id}: ${markerErr}`);
|
||||
}
|
||||
|
||||
return attachments;
|
||||
}
|
||||
|
||||
module.exports = { collectRebillProofAttachments, resolveDefaultAttach, renderProofName };
|
||||
@@ -14,12 +14,19 @@ const { computeDueDate, ensureCustomerCanBill, formatMajor, getHierarchyHelpers,
|
||||
const { getInvoiceById } = require('./queries');
|
||||
const { createInvoice } = require('./create');
|
||||
const { buildInvoiceRenderContext } = require('./render');
|
||||
const { collectRebillProofAttachments } = require('./rebillProofs');
|
||||
|
||||
|
||||
/**
|
||||
* Send an invoice email + PDF. Flips status scheduled → sent.
|
||||
*
|
||||
* @param options.proofInboundIds optional explicit re-bill proof selection
|
||||
* (issue #866). Set by the manual Send dialog so the admin picks which
|
||||
* supplier proofs ride the email — all, some, or none. When omitted
|
||||
* (auto-send / scheduler) the resolved per-customer/global default
|
||||
* decides all-or-none.
|
||||
*/
|
||||
async function sendInvoice(id, adminId) {
|
||||
async function sendInvoice(id, adminId, options = {}) {
|
||||
const data = await getInvoiceById(id);
|
||||
if (!data) throw new AppError('Invoice not found', 404);
|
||||
const { invoice, lineItems } = data;
|
||||
@@ -118,6 +125,23 @@ async function sendInvoice(id, adminId) {
|
||||
});
|
||||
|
||||
const { to: invoiceTo, cc: invoiceCc } = resolveBillingRecipients(customer, invoice.cc_pdf_email);
|
||||
|
||||
// Re-bill/passthrough proof attachments (#866). Separate attachments — the
|
||||
// invoice PDF above is never touched. Selection comes from the Send dialog on
|
||||
// a manual send; auto-sends fall back to the resolved default. Best-effort:
|
||||
// a missing proof marks the re-bill row but never blocks the send.
|
||||
const invoiceAttachments = [{
|
||||
filename: `${invoice.invoice_number}.pdf`,
|
||||
contentPath: pdfPath,
|
||||
contentType: 'application/pdf',
|
||||
}];
|
||||
try {
|
||||
const proofs = await collectRebillProofAttachments(invoice, customer, options.proofInboundIds);
|
||||
if (proofs.length) invoiceAttachments.push(...proofs);
|
||||
} catch (e) {
|
||||
logger.warn?.(`sendInvoice: proof attachment collection failed for ${invoice.invoice_number}: ${e.message}`);
|
||||
}
|
||||
|
||||
await emailProcessor.queueEmail(invoice.event_id || null, invoiceTo, 'invoice_sent', {
|
||||
invoice_number: invoice.invoice_number,
|
||||
customer_name: customer.display_name || customer.first_name || customer.email.split('@')[0],
|
||||
@@ -131,11 +155,7 @@ async function sendInvoice(id, adminId) {
|
||||
// above) rather than the event-first default resolution.
|
||||
__language: ctx.locale,
|
||||
cc: invoiceCc,
|
||||
attachments: [{
|
||||
filename: `${invoice.invoice_number}.pdf`,
|
||||
contentPath: pdfPath,
|
||||
contentType: 'application/pdf',
|
||||
}],
|
||||
attachments: invoiceAttachments,
|
||||
});
|
||||
|
||||
try { await logActivity('invoice_sent', { invoiceId: id }, invoice.event_id || null, `admin:${adminId}`); } catch (_) {}
|
||||
@@ -185,6 +205,26 @@ async function sendInvoice(id, adminId) {
|
||||
* cancellation itself; the storno sits in `status='scheduled'`
|
||||
* and the cron picks it up.
|
||||
*/
|
||||
/**
|
||||
* When an invoice is cancelled, detach any re-billed/passed-through supplier
|
||||
* invoices linked to it (#866 review). Nothing else clears
|
||||
* inbound_documents.billed_invoice_id, so without this a Storno'd cover would
|
||||
* strand the supplier cost: the CRM panel shows it as Open but every billing
|
||||
* path filters on billed_invoice_id IS NULL, so it could never be re-billed.
|
||||
* Mirrors the categorise-time reset; returns the item to the billable pool.
|
||||
* Best-effort + schema-guarded (no-op on non-accounting installs).
|
||||
*/
|
||||
async function releaseRebillsForCancelledInvoice(conn, invoiceId) {
|
||||
try {
|
||||
if (!(await conn.schema.hasTable('inbound_documents'))) return;
|
||||
await conn('inbound_documents')
|
||||
.where({ billed_invoice_id: invoiceId })
|
||||
.update({ billed_invoice_id: null, billed_invoice_line_item_id: null, updated_at: new Date() });
|
||||
} catch (e) {
|
||||
logger.warn?.(`releaseRebillsForCancelledInvoice failed for invoice ${invoiceId}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function createStorno(originalId, adminId, trx = db) {
|
||||
const original = await trx('invoices').where({ id: originalId }).first();
|
||||
if (!original) throw new AppError('Invoice not found', 404);
|
||||
@@ -305,6 +345,8 @@ async function createStorno(originalId, adminId, trx = db) {
|
||||
cancellation_storno_id: stornoId,
|
||||
updated_at: now,
|
||||
});
|
||||
// Free any re-billed supplier invoices so the cost isn't stranded (#866 review).
|
||||
await releaseRebillsForCancelledInvoice(trx, originalId);
|
||||
|
||||
try {
|
||||
// Pass `trx` so the audit insert rides the transaction's connection;
|
||||
@@ -597,6 +639,8 @@ async function cancelInvoice(id, adminId) {
|
||||
await db('invoices').where({ id }).update({
|
||||
status: 'cancelled', updated_at: new Date(),
|
||||
});
|
||||
// Free any re-billed supplier invoices so the cost isn't stranded (#866 review).
|
||||
await releaseRebillsForCancelledInvoice(db, id);
|
||||
try {
|
||||
await logActivity('invoice_cancelled',
|
||||
{ invoiceId: id, viaStorno: false },
|
||||
|
||||
Reference in New Issue
Block a user