Files
picpeak/backend/src/services/expenseService.js
T
Luca 165cebdb5c 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.
2026-08-03 22:03:31 +02:00

990 lines
45 KiB
JavaScript

/**
* Accounting service — two separate concepts (split in migration 126):
*
* INCOMING INVOICES (external) → the `inbound_documents` row IS the payable.
* It carries its own disposition, supplier-payment, booking (event_id, NULL
* = company) and re-bill linkage. A supplier invoice never creates an
* `expenses` row, so it appears ONLY in the incoming-invoices surface.
*
* EXPENSES (internal) → `expenses` rows are own costs entered by
* staff: kind = amount | mileage(km) | per_diem, amount = quantity x rate
* (rate from accounting settings, per-entry override), optional proof file,
* booked to an event or the company. No supplier payment (you incur these).
*
* Money is integer minor units. VAT/tax handling is v1 (capture only) — verify
* with a Treuhaender.
*/
const crypto = require('crypto');
const fsp = require('fs').promises;
const { PDFDocument } = require('pdf-lib');
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 —
* emailIntakeService calls recordInboundDocument() with none — and an
* unconditional `{ type: 'admin' }` would store actor_type='admin' with a null
* id, mislabelling mailbox captures as somebody's deliberate action. Returning
* null restores logActivity's 'system' attribution for those.
*/
const adminActor = (adminId) => (adminId ? { type: 'admin', id: adminId } : null);
const DISPOSITIONS = ['rebill', 'durchlaufend', 'eigener_aufwand', 'duplikat', 'abgelehnt'];
const TAX_TREATMENTS = ['domestic', 'reverse_charge_service', 'foreign_vat_non_reclaimable', 'import_goods'];
const MARKUP_TYPES = ['none', 'percent', 'flat'];
const PAYMENT_METHODS = ['bank_transfer', 'cash', 'twint', 'paypal', 'card', 'other'];
const EXPENSE_KINDS = ['amount', 'mileage', 'per_diem'];
const DISPOSITION_DOC_STATUS = {
rebill: 'categorized',
durchlaufend: 'categorized',
eigener_aufwand: 'categorized',
duplikat: 'duplicate',
abgelehnt: 'declined',
};
function toIsoDate(v) {
if (!v) return null;
if (v instanceof Date) return v.toISOString().slice(0, 10);
return String(v).slice(0, 10);
}
// ── 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_rebill_attach_proof'];
let rows = [];
try {
rows = await db('app_settings').whereIn('setting_key', keys).select('setting_key', 'setting_value');
} catch (_e) { /* table may not exist in some test harnesses */ }
const map = {};
for (const r of rows) {
let v = r.setting_value;
if (typeof v === 'string') { try { v = JSON.parse(v); } catch (_e) { /* keep raw */ } }
map[r.setting_key] = v;
}
return {
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()) : [],
};
}
/**
* Default tax treatment from the supplier country: explicit payload wins; else
* a country in the reclaim list (typically CH / LI) is `domestic` (input VAT
* reclaimable), an out-of-list country is `foreign_vat_non_reclaimable`, and an
* unknown country falls back to `domestic`. reverse_charge / import_goods stay
* admin-set (can't be auto-detected).
*/
function resolveTaxTreatment(payloadTreatment, supplierCountry, reclaimCountries) {
if (TAX_TREATMENTS.includes(payloadTreatment)) return payloadTreatment;
const cc = String(supplierCountry || '').toUpperCase();
if (!cc) return 'domestic';
// Don't auto-classify until the admin has actually configured their reclaim
// countries — an unset (empty) list must not make every supplier, including
// the admin's own domestic one, "foreign". (PR #636 review #1.)
if (!reclaimCountries || reclaimCountries.length === 0) return 'domestic';
return reclaimCountries.includes(cc) ? 'domestic' : 'foreign_vat_non_reclaimable';
}
// ── Incoming invoices (inbound_documents) ───────────────────────────────────
function transformInbound(row) {
if (!row) return null;
return {
id: row.id,
source: row.source,
originalFilename: row.original_filename,
mimeType: row.mime_type,
status: row.status,
parseStatus: row.parse_status,
parseMethod: row.parse_method,
pageCount: row.page_count,
supplierName: row.supplier_name,
invoiceNumber: row.invoice_number,
invoiceDate: toIsoDate(row.invoice_date),
dueDate: toIsoDate(row.due_date),
currency: row.currency,
netAmountMinor: row.net_amount_minor,
vatAmountMinor: row.vat_amount_minor,
totalAmountMinor: row.total_amount_minor,
qrAmountMinor: row.qr_amount_minor,
iban: row.iban,
paymentReference: row.payment_reference,
duplicateOfId: row.duplicate_of_id,
// classification + booking (migration 126)
disposition: row.disposition,
taxTreatment: row.tax_treatment,
eventId: row.event_id,
categoryId: row.category_id,
markupType: row.markup_type,
markupPercent: row.markup_percent != null ? Number(row.markup_percent) : null,
markupFlatMinor: row.markup_flat_minor,
billedInvoiceId: row.billed_invoice_id,
billedInvoiceLineItemId: row.billed_invoice_line_item_id,
// re-bill customer linkage (migration 132) — the client a rebill/passthrough
// is attached to. customerName/Email are denormalised from a LEFT JOIN in
// list/get (null when the row came from a query without the join).
customerAccountId: row.customer_account_id || null,
customerName: row.customer_display_name || row.customer_company_name || null,
customerEmail: row.customer_email || null,
supplierCountry: row.supplier_country || null,
note: row.note || null,
// supplier payment (paid on the incoming invoice itself)
supplierPaid: !!row.supplier_paid,
supplierPaidAt: row.supplier_paid_at,
supplierPaymentMethod: row.supplier_payment_method,
supplierPaymentRef: row.supplier_payment_ref,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function clampPage(page, pageSize) {
const p = Math.max(1, parseInt(page, 10) || 1);
const ps = Math.min(100, Math.max(1, parseInt(pageSize, 10) || 25));
return { p, ps };
}
async function inspectFile(filePath, mimeType) {
const buf = await fsp.readFile(filePath);
const sha = crypto.createHash('sha256').update(buf).digest('hex');
let pageCount = null;
if ((mimeType || '').includes('pdf')) {
try {
const pdf = await PDFDocument.load(buf, { updateMetadata: false });
pageCount = pdf.getPageCount();
} catch (e) {
logger.warn?.(`expenseService: PDF page count failed for ${filePath}: ${e.message}`);
}
}
return { sha, pageCount };
}
async function recordInboundDocument({ source, filePath, originalFilename, mimeType }, adminId) {
let fileSha256 = null;
let pageCount = null;
try {
const info = await inspectFile(filePath, mimeType);
fileSha256 = info.sha; pageCount = info.pageCount;
} catch (e) {
logger.warn?.(`expenseService: could not inspect ${filePath}: ${e.message}`);
}
let duplicateOfId = null;
if (fileSha256) {
const dup = await db('inbound_documents').where({ file_sha256: fileSha256 }).first('id');
if (dup) duplicateOfId = dup.id;
}
const now = new Date();
const row = {
source: source || 'upload',
original_filename: originalFilename || null,
file_path: filePath,
mime_type: mimeType || null,
file_sha256: fileSha256,
status: duplicateOfId ? 'duplicate' : 'unsorted',
parse_status: 'pending',
parse_method: 'none',
// Cap stored page_count to the renderable max (rasterizeService
// MAX_RENDERABLE_PAGES) so a hostile high-page PDF can't drive an
// unbounded inbox pager (PR #622 concern 6).
page_count: pageCount != null ? Math.min(pageCount, 200) : null,
duplicate_of_id: duplicateOfId,
created_by_admin_id: adminId || null,
created_at: now,
updated_at: now,
};
const inserted = await db('inbound_documents').insert(row).returning('id');
const id = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
await logActivity('incoming_invoice_captured', { inboundDocumentId: id, source: row.source, duplicate: !!duplicateOfId }, null, adminActor(adminId));
return getInbound(id);
}
// Denormalise the attached customer's name/email for the inbox UI (re-bill
// chip + pending-pool grouping). LEFT JOIN so docs without a customer still
// return. Selected explicitly to avoid colliding with inbound_documents.*.
const INBOUND_CUSTOMER_SELECT = [
'inbound_documents.*',
'c.display_name as customer_display_name',
'c.company_name as customer_company_name',
'c.email as customer_email',
];
function inboundWithCustomer() {
return db('inbound_documents')
.leftJoin('customer_accounts as c', 'inbound_documents.customer_account_id', 'c.id');
}
async function getInbound(id) {
const row = await inboundWithCustomer().where('inbound_documents.id', id).first(INBOUND_CUSTOMER_SELECT);
if (!row) throw new AppError('Incoming invoice not found', 404, 'INBOUND_NOT_FOUND');
return transformInbound(row);
}
async function listInbound({ status, page, pageSize } = {}) {
const { p, ps } = clampPage(page, pageSize);
const base = inboundWithCustomer();
if (status) base.where('inbound_documents.status', status);
const countRow = await base.clone().clearSelect().count({ count: 'inbound_documents.id' }).first();
const total = parseInt(countRow?.count || 0, 10);
const rows = await base.clone().orderBy('inbound_documents.created_at', 'desc').limit(ps).offset((p - 1) * ps)
.select(INBOUND_CUSTOMER_SELECT);
return { items: rows.map(transformInbound), pagination: { page: p, pageSize: ps, total, totalPages: Math.ceil(total / ps) } };
}
const INBOUND_EDITABLE = {
supplierName: 'supplier_name', invoiceNumber: 'invoice_number', invoiceDate: 'invoice_date',
dueDate: 'due_date', currency: 'currency', netAmountMinor: 'net_amount_minor',
vatAmountMinor: 'vat_amount_minor', totalAmountMinor: 'total_amount_minor', iban: 'iban',
paymentReference: 'payment_reference', note: 'note', supplierCountry: 'supplier_country',
};
async function updateInbound(id, payload, adminId) {
await getInbound(id);
const patch = { updated_at: new Date(), parse_status: 'manual' };
for (const [camel, snake] of Object.entries(INBOUND_EDITABLE)) {
if (payload[camel] !== undefined) patch[snake] = payload[camel] === '' ? null : payload[camel];
}
await db('inbound_documents').where({ id }).update(patch);
await logActivity('incoming_invoice_updated', { inboundDocumentId: id }, null, adminActor(adminId));
return getInbound(id);
}
// markup helpers (shared with re-bill)
async function resolveMarkup(source, override, contractId, trx) {
const pick = (type, percent, flatMinor) => ({
type: MARKUP_TYPES.includes(type) ? type : 'none',
percent: percent != null ? Number(percent) : null,
flatMinor: Number.isInteger(flatMinor) ? flatMinor : null,
});
if (override && override.markupType && override.markupType !== 'none') {
return pick(override.markupType, override.markupPercent, override.markupFlatMinor);
}
if (source && source.markupType && source.markupType !== 'none') {
return pick(source.markupType, source.markupPercent, source.markupFlatMinor);
}
const { hasColumnCached } = require('../utils/schemaCache');
if (contractId && (await hasColumnCached('contracts', 'expense_markup_type'))) {
const c = await (trx || db)('contracts').where({ id: contractId })
.first('expense_markup_type', 'expense_markup_percent', 'expense_markup_flat_minor');
if (c && c.expense_markup_type && c.expense_markup_type !== 'none') {
return pick(c.expense_markup_type, c.expense_markup_percent, c.expense_markup_flat_minor);
}
}
return pick('none', null, null);
}
function computeMarkupMinor(baseMinor, markup) {
if (markup.type === 'percent' && markup.percent != null) return Math.round(baseMinor * Number(markup.percent) / 100);
if (markup.type === 'flat' && Number.isInteger(markup.flatMinor)) return markup.flatMinor;
return 0;
}
// Dispositions that can be billed to a client. 'rebill' always carries a
// customer; 'durchlaufend' (passthrough) may now ALSO attach to a customer
// (with optional markup) so it can be re-billed like a rebill.
const CUSTOMER_DISPOSITIONS = ['rebill', 'durchlaufend'];
const BOOKING_DISPOSITIONS = ['rebill', 'durchlaufend'];
/**
* Can this invoice still be edited (line removed / appended)? Mirrors the
* hour-entry lock rules (customerHoursService.isEntryLocked, inverted):
* monthly drafts and not-yet-armed scheduled invoices are mutable; anything
* sent/paid/overdue/cancelled or past its scheduled_send_at is locked.
*/
function isInvoiceMutable(invoice) {
if (!invoice) return true; // referenced invoice gone — treat as not billed
if (invoice.is_monthly_draft === true || invoice.is_monthly_draft === 1) return true;
// NB: invoices have no 'draft' status (only quotes do). The editable,
// not-yet-sent invoice state IS 'scheduled' with no scheduled_send_at (or a
// future one), handled below — so there is no plain-'draft' case to slot in
// here (PR #636 review #6).
if (invoice.status !== 'scheduled') return false;
if (!invoice.scheduled_send_at) return true;
return new Date(invoice.scheduled_send_at).getTime() > Date.now();
}
/**
* Re-categorisation unwind: remove this document's billed line item from its
* invoice and recompute the invoice totals, so the disposition can change.
* Refuses when the invoice is already issued (Storno required instead).
*/
async function unwindBilledLine(trx, doc) {
const invoice = doc.billedInvoiceId
? await trx('invoices').where({ id: doc.billedInvoiceId }).first()
: null;
if (invoice && !isInvoiceMutable(invoice)) {
throw new AppError(
'This re-bill is on an invoice that has already been issued — Storno it before re-categorising.',
409, 'INVOICE_LOCKED',
);
}
if (doc.billedInvoiceLineItemId) {
await trx('invoice_line_items').where({ id: doc.billedInvoiceLineItemId }).del();
}
if (invoice) {
const allItems = await trx('invoice_line_items').where({ invoice_id: invoice.id });
if (allItems.length === 0) {
// The unwound re-bill was the only line — a net-zero invoice has no reason
// to survive, and these would otherwise pile up over re-categorisations.
// It's mutable (checked above) and never issued, so delete it outright
// (PR #636 review #5). For a monthly draft this just means the next append
// re-creates one.
await trx('invoices').where({ id: invoice.id }).del();
return;
}
let netMinor = 0;
for (const li of allItems) {
if (li.parent_line_item_id == null) netMinor += Number(li.line_total_minor || 0);
}
const vatRate = Number(invoice.vat_rate || 0);
const vatMinor = Math.round(netMinor * vatRate / 100);
const shippingMinor = Number(invoice.shipping_amount_minor || 0);
await trx('invoices').where({ id: invoice.id }).update({
net_amount_minor: netMinor,
vat_amount_minor: vatMinor,
total_amount_minor: netMinor + vatMinor + shippingMinor,
updated_at: new Date(),
});
}
}
/** The single invoice line that re-bills one incoming invoice (base + markup). */
function buildInboundLineItem(doc, disposition, markup) {
const base = doc.totalAmountMinor != null ? doc.totalAmountMinor : doc.netAmountMinor;
if (base == null) throw new AppError('Incoming invoice has no amount to re-bill', 400, 'AMOUNT_REQUIRED');
const lineTotal = base + computeMarkupMinor(base, markup);
const label = doc.supplierName || 'Weiterverrechnete Auslage';
const suffix = disposition === 'durchlaufend' ? ' (Durchlaufende Position)' : ' (Weiterverrechnung)';
return { description: `${label}${suffix}`, quantity: 1, unit_price_minor: lineTotal, discount_percent: 0, line_total_minor: lineTotal };
}
/**
* Immediately bill ONE incoming invoice to its customer. createInvoice routes
* monthly/manual customers onto the running draft (consolidated, like hours)
* and mints a standalone invoice for per-event customers. Stamps the document
* with the resulting invoice + line.
*/
async function billInboundNow(trx, id, customerAccountId, eventId, disposition, markup, adminId) {
const row = await trx('inbound_documents').where({ id }).first();
const doc = transformInbound(row);
const lineItem = buildInboundLineItem(doc, disposition, markup);
const { invoiceIds } = await invoiceService.createInvoice({
customerAccountId,
eventId: eventId || doc.eventId || null,
lineItems: [lineItem],
}, adminId, trx);
const invoiceId = Array.isArray(invoiceIds) ? invoiceIds[0] : null;
if (!invoiceId) throw new AppError('Failed to create the re-bill invoice', 500, 'REBILL_FAILED');
const line = await trx('invoice_line_items').where({ invoice_id: invoiceId }).orderBy('id', 'desc').first('id');
await trx('inbound_documents').where({ id }).update({
billed_invoice_id: invoiceId,
billed_invoice_line_item_id: line ? line.id : null,
updated_at: new Date(),
});
// NOTE: no logActivity here — it writes via the GLOBAL db, which deadlocks
// when called inside this transaction on a SQLite-backed install (a second
// write connection blocks on the held write lock). Callers log AFTER commit.
return invoiceId;
}
/**
* Give an incoming invoice a disposition (updates the document, no expense
* row). Re-runnable: re-categorising an already-billed document first unwinds
* its prior re-bill line. For rebill/passthrough with a customer, monthly &
* manual customers are billed immediately onto the running draft (like hours);
* per-event customers stay PENDING in the customer's pool until "Bill these".
*/
async function categorizeInbound(id, payload, adminId) {
const disposition = payload.disposition;
if (!DISPOSITIONS.includes(disposition)) {
throw new AppError(`disposition must be one of ${DISPOSITIONS.join(', ')}`, 400, 'BAD_DISPOSITION');
}
const billsToCustomer = CUSTOMER_DISPOSITIONS.includes(disposition);
const customerAccountId = billsToCustomer && payload.customerAccountId ? payload.customerAccountId : null;
// rebill REQUIRES a customer; passthrough may omit one (then it's only booked
// to an event/company and never re-billed).
if (disposition === 'rebill' && !customerAccountId) {
throw new AppError('customerAccountId is required to re-bill', 400, 'CUSTOMER_REQUIRED');
}
// Reclaim-country list for the tax-treatment auto-default (loaded before the
// transaction — a global-db read).
const { vatReclaimCountries } = await getAccountingSettings();
let billedInvoiceId = null;
await db.transaction(async (trx) => {
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 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);
// Markup is a re-bill concept only. A pass-through (durchlaufender Posten)
// is invoiced at cost / VAT-neutral, so it never carries a markup.
const appliesMarkup = disposition === 'rebill';
const markup = appliesMarkup
? await resolveMarkup(
{ markupType: payload.markupType, markupPercent: payload.markupPercent, markupFlatMinor: payload.markupFlatMinor },
payload, payload.contractId, trx,
)
: { type: 'none', percent: null, flatMinor: null };
const patch = {
disposition,
// Explicit treatment wins; else auto-default from the supplier country.
tax_treatment: resolveTaxTreatment(payload.taxTreatment, doc.supplierCountry, vatReclaimCountries),
event_id: BOOKING_DISPOSITIONS.includes(disposition) ? (payload.eventId || null) : null,
category_id: disposition === 'eigener_aufwand' ? (payload.categoryId || null) : null,
customer_account_id: customerAccountId,
markup_type: appliesMarkup ? markup.type : 'none',
markup_percent: appliesMarkup && markup.type === 'percent' ? markup.percent : null,
markup_flat_minor: appliesMarkup && markup.type === 'flat' ? markup.flatMinor : null,
// Cleared here; re-set by billInboundNow when we bill immediately.
billed_invoice_id: null,
billed_invoice_line_item_id: null,
status: DISPOSITION_DOC_STATUS[disposition] || 'categorized',
updated_at: new Date(),
};
if (disposition === 'duplikat' && payload.duplicateOfId) patch.duplicate_of_id = payload.duplicateOfId;
await trx('inbound_documents').where({ id }).update(patch);
if (customerAccountId) {
const customer = await trx('customer_accounts').where({ id: customerAccountId }).first();
if (!customer) throw new AppError('Customer not found', 404, 'CUSTOMER_NOT_FOUND');
// Monthly/manual = accumulator → bill now onto the running draft.
// Per-event → leave PENDING for bundling via billPendingRebills.
if (customer.billing_cadence === 'monthly' || customer.billing_cadence === 'manual') {
billedInvoiceId = await billInboundNow(trx, id, customerAccountId, payload.eventId || null, disposition, markup, adminId);
}
}
});
// Audit logging AFTER commit — logActivity writes via the global db and would
// deadlock if run inside the transaction above on a SQLite-backed install.
await logActivity('incoming_invoice_categorized', { inboundDocumentId: id, disposition }, null, adminActor(adminId));
if (billedInvoiceId) await logActivity('incoming_invoice_rebilled', { inboundDocumentId: id, invoiceId: billedInvoiceId }, null, adminActor(adminId));
return getInbound(id);
}
/**
* Explicit "re-bill this one now" endpoint (legacy /inbound/:id/rebill). Forces
* an immediate single-document bill regardless of cadence. Re-runnable: unwinds
* a prior re-bill first.
*/
async function rebillInbound(id, payload, adminId, trx0) {
if (!payload.customerAccountId) throw new AppError('customerAccountId is required to re-bill', 400, 'CUSTOMER_REQUIRED');
const run = async (trx) => {
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 },
payload, payload.contractId, trx,
);
await trx('inbound_documents').where({ id }).update({
disposition: 'rebill',
status: 'categorized',
customer_account_id: payload.customerAccountId,
event_id: payload.eventId || doc.eventId || null,
markup_type: markup.type,
markup_percent: markup.type === 'percent' ? markup.percent : null,
markup_flat_minor: markup.type === 'flat' ? markup.flatMinor : null,
updated_at: new Date(),
});
return billInboundNow(trx, id, payload.customerAccountId, payload.eventId || doc.eventId || null, 'rebill', markup, adminId);
};
const invoiceId = trx0 ? await run(trx0) : await db.transaction(run);
// Log after commit (global-db write — see billInboundNow). When a caller
// supplied trx0, that outer transaction owns the audit log instead.
if (!trx0) await logActivity('incoming_invoice_rebilled', { inboundDocumentId: id, invoiceId }, null, adminActor(adminId));
return { document: await getInbound(id), invoiceId };
}
/**
* Landing aggregate for the inbox "pending re-bills" card: one row per customer
* that carries categorised-but-unbilled rebill/passthrough documents, with the
* count + open amount (base + markup). In practice only per-event customers
* surface here — monthly/manual cadences bill immediately on categorise.
*/
async function listPendingRebillSummary() {
const rows = await db('inbound_documents as d')
.join('customer_accounts as c', 'd.customer_account_id', 'c.id')
.whereNotNull('d.customer_account_id')
.whereNull('d.billed_invoice_id')
.whereIn('d.disposition', CUSTOMER_DISPOSITIONS)
.where('d.status', 'categorized')
.select(
'd.customer_account_id', 'd.total_amount_minor', 'd.net_amount_minor',
'd.markup_type', 'd.markup_percent', 'd.markup_flat_minor',
'c.company_name', 'c.display_name', 'c.first_name', 'c.last_name',
'c.email', 'c.password_hash', 'c.billing_cadence',
);
const byCustomer = new Map();
for (const r of rows) {
let agg = byCustomer.get(r.customer_account_id);
if (!agg) {
agg = {
customerAccountId: r.customer_account_id,
companyName: r.company_name || null,
displayName: r.display_name || null,
firstName: r.first_name || null,
lastName: r.last_name || null,
email: r.email || null,
isPassive: r.password_hash == null,
billingCadence: r.billing_cadence || null,
itemCount: 0,
openAmountMinor: 0,
};
byCustomer.set(r.customer_account_id, agg);
}
agg.itemCount += 1;
const base = r.total_amount_minor != null ? Number(r.total_amount_minor)
: (r.net_amount_minor != null ? Number(r.net_amount_minor) : 0);
const markup = {
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,
};
agg.openAmountMinor += base + computeMarkupMinor(base, markup);
}
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
* customers (those bill immediately on categorise). Mirrors
* customerHoursService.billUnbilledEntries.
*/
async function billPendingRebills(customerId, adminId) {
const customer = await db('customer_accounts').where({ id: customerId }).first();
if (!customer) throw new AppError('Customer not found', 404);
if (customer.billing_cadence === 'monthly' || customer.billing_cadence === 'manual') {
throw new AppError(
'Monthly/manual customers consolidate automatically on categorise; bundling is for per-event customers.',
409, 'CADENCE_MISMATCH',
);
}
const result = await db.transaction(async (trx) => {
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 = rawLines.map((li, idx) => ({ ...li, position: idx + 1 }));
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]));
const lineIds = pending.map((_, i) => lineByPos.get(i + 1) || null);
await stampBilledRebills(trx, pending, invoiceId, lineIds);
return { invoiceId, count: pending.length };
});
// Audit log after commit (global-db write — see billInboundNow).
await logActivity('incoming_invoices_rebilled_bundle', { customerId: customer.id, invoiceId: result.invoiceId, count: result.count }, null, adminActor(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);
if (paymentMethod && !PAYMENT_METHODS.includes(paymentMethod)) {
throw new AppError(`paymentMethod must be one of ${PAYMENT_METHODS.join(', ')}`, 400, 'BAD_PAYMENT_METHOD');
}
await db('inbound_documents').where({ id }).update({
supplier_paid: !!paid,
supplier_paid_at: paid ? (paidAt ? new Date(paidAt) : new Date()) : null,
supplier_payment_method: paid ? (paymentMethod || null) : null,
supplier_payment_ref: paid ? (paymentReference || null) : null,
updated_at: new Date(),
});
await logActivity('incoming_invoice_supplier_payment', { inboundDocumentId: id, paid: !!paid }, null, adminActor(adminId));
return getInbound(id);
}
// ── Expenses (internal) ─────────────────────────────────────────────────────
function transformExpense(row) {
if (!row) return null;
return {
id: row.id,
kind: row.kind || 'amount',
quantity: row.quantity != null ? Number(row.quantity) : null,
rateMinor: row.rate_minor,
eventId: row.event_id, // null = company
supplierName: row.supplier_name,
description: row.description,
chfAmountMinor: row.chf_amount_minor,
categoryId: row.category_id,
receiptPath: row.receipt_path,
hasProof: !!row.receipt_path,
taxTreatment: row.tax_treatment,
// invoiced = added to a real client invoice (locks editing); paid = settled.
billedInvoiceId: row.billed_invoice_id,
billedInvoiceLineItemId: row.billed_invoice_line_item_id,
invoiced: !!row.billed_invoice_id,
customerAccountId: row.customer_account_id,
paid: !!row.supplier_paid,
paidAt: row.supplier_paid_at,
paymentMethod: row.payment_method,
status: row.status,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
/** Compute the booked amount (minor) for an internal expense. */
function computeExpenseAmount(kind, quantity, rateMinor, amountMinor) {
if (kind === 'mileage' || kind === 'per_diem') {
if (quantity != null && rateMinor != null) return Math.round(Number(quantity) * Number(rateMinor));
return null;
}
return Number.isInteger(amountMinor) ? amountMinor : null;
}
function buildExpenseInsert(payload, adminId, opts = {}) {
const now = new Date();
const kind = EXPENSE_KINDS.includes(payload.kind) ? payload.kind : 'amount';
let rateMinor = null;
if (kind === 'mileage') rateMinor = Number.isInteger(payload.rateMinor) ? payload.rateMinor : (opts.kmRateMinor ?? null);
else if (kind === 'per_diem') rateMinor = Number.isInteger(payload.rateMinor) ? payload.rateMinor : (opts.perDiemRateMinor ?? null);
const quantity = (kind === 'mileage' || kind === 'per_diem') && payload.quantity != null ? Number(payload.quantity) : null;
const chf = computeExpenseAmount(kind, quantity, rateMinor, payload.chfAmountMinor);
return {
inbound_document_id: null,
disposition: 'eigener_aufwand', // internal expenses are always own-cost
tax_treatment: TAX_TREATMENTS.includes(payload.taxTreatment) ? payload.taxTreatment : 'domestic',
event_id: payload.eventId || null, // null = company
supplier_name: payload.supplierName || null,
description: payload.description || null,
kind,
quantity,
rate_minor: rateMinor,
chf_amount_minor: chf,
gross_amount_minor: chf,
category_id: payload.categoryId || null,
receipt_path: opts.receiptPath || null,
status: 'open',
created_by_admin_id: adminId || null,
created_at: now,
updated_at: now,
};
}
async function getExpense(id) {
const row = await db('expenses').where({ id }).first();
if (!row) throw new AppError('Expense not found', 404, 'EXPENSE_NOT_FOUND');
return transformExpense(row);
}
async function createExpense(payload, adminId, { receiptPath } = {}) {
const settings = await getAccountingSettings();
if (settings.requireProof && !receiptPath) {
throw new AppError('A proof file is required for expenses', 400, 'PROOF_REQUIRED');
}
const row = buildExpenseInsert(payload, adminId, {
receiptPath,
kmRateMinor: settings.kmRateMinor,
perDiemRateMinor: settings.perDiemRateMinor,
});
const inserted = await db('expenses').insert(row).returning('id');
const id = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
await logActivity('expense_created', { expenseId: id, kind: row.kind }, null, adminActor(adminId));
return getExpense(id);
}
async function listExpenses({ kind, eventId, categoryId, page, pageSize } = {}) {
const { p, ps } = clampPage(page, pageSize);
const base = db('expenses').where({ inbound_document_id: null }); // internal only
if (kind) base.where({ kind });
if (categoryId) base.where({ category_id: categoryId });
if (eventId === 'company') base.whereNull('event_id');
else if (eventId) base.where({ event_id: eventId });
const countRow = await base.clone().count({ count: '*' }).first();
const total = parseInt(countRow?.count || 0, 10);
const rows = await base.clone().orderBy('created_at', 'desc').limit(ps).offset((p - 1) * ps);
return { items: rows.map(transformExpense), pagination: { page: p, pageSize: ps, total, totalPages: Math.ceil(total / ps) } };
}
const EXPENSE_EDITABLE = {
supplierName: 'supplier_name', description: 'description', taxTreatment: 'tax_treatment',
eventId: 'event_id', categoryId: 'category_id',
};
async function updateExpense(id, payload, adminId, { receiptPath } = {}) {
const existing = await getExpense(id);
if (existing.invoiced) {
throw new AppError('Expense is invoiced — editing is locked', 409, 'EXPENSE_LOCKED');
}
const patch = { updated_at: new Date() };
for (const [camel, snake] of Object.entries(EXPENSE_EDITABLE)) {
if (payload[camel] !== undefined) patch[snake] = payload[camel] === '' ? null : payload[camel];
}
if (receiptPath) patch.receipt_path = receiptPath;
await db('expenses').where({ id }).update(patch);
await logActivity('expense_updated', { expenseId: id }, null, adminActor(adminId));
return getExpense(id);
}
/** Add an internal expense onto a client invoice (mints a line). Marks it
* invoiced (locks editing) + links the invoice. base = chf amount + markup. */
async function rebillExpense(id, payload, adminId, trx0) {
const run = async (trx) => {
const row = await trx('expenses').where({ id }).first();
if (!row) throw new AppError('Expense not found', 404, 'EXPENSE_NOT_FOUND');
const exp = transformExpense(row);
if (exp.invoiced) throw new AppError('Expense already invoiced', 409, 'ALREADY_INVOICED');
if (!payload.customerAccountId) throw new AppError('customerAccountId is required', 400, 'CUSTOMER_REQUIRED');
const base = exp.chfAmountMinor;
if (base == null) throw new AppError('Expense has no amount to invoice', 400, 'AMOUNT_REQUIRED');
const markup = await resolveMarkup(
{ markupType: row.markup_type, markupPercent: row.markup_percent, markupFlatMinor: row.markup_flat_minor },
payload, payload.contractId, trx,
);
const lineTotal = base + computeMarkupMinor(base, markup);
const label = exp.description || exp.supplierName || 'Aufwand';
const { invoiceIds } = await invoiceService.createInvoice({
customerAccountId: payload.customerAccountId,
eventId: payload.eventId || exp.eventId || null,
lineItems: [{ description: `${label} (Weiterverrechnung)`, quantity: 1, unit_price_minor: lineTotal, discount_percent: 0, line_total_minor: lineTotal }],
}, adminId, trx);
const invoiceId = Array.isArray(invoiceIds) ? invoiceIds[0] : null;
if (!invoiceId) throw new AppError('Failed to create invoice', 500, 'INVOICE_FAILED');
const line = await trx('invoice_line_items').where({ invoice_id: invoiceId }).orderBy('id', 'desc').first('id');
await trx('expenses').where({ id }).update({
billed_invoice_id: invoiceId,
billed_invoice_line_item_id: line ? line.id : null,
billed_at: new Date(),
customer_account_id: payload.customerAccountId,
markup_type: markup.type,
markup_percent: markup.type === 'percent' ? markup.percent : null,
markup_flat_minor: markup.type === 'flat' ? markup.flatMinor : null,
status: 'invoiced',
updated_at: new Date(),
});
await logActivity('expense_invoiced', { expenseId: id, invoiceId }, null, adminActor(adminId));
return invoiceId;
};
const invoiceId = trx0 ? await run(trx0) : await db.transaction(run);
return { expense: await getExpense(id), invoiceId };
}
/** Mark an expense paid/settled (manual). */
async function markExpensePaid(id, { paid, paidAt, paymentMethod, paymentReference }, adminId) {
await getExpense(id);
if (paymentMethod && !PAYMENT_METHODS.includes(paymentMethod)) {
throw new AppError(`paymentMethod must be one of ${PAYMENT_METHODS.join(', ')}`, 400, 'BAD_PAYMENT_METHOD');
}
await db('expenses').where({ id }).update({
supplier_paid: !!paid,
supplier_paid_at: paid ? (paidAt ? new Date(paidAt) : new Date()) : null,
payment_method: paid ? (paymentMethod || null) : null,
payment_reference: paid ? (paymentReference || null) : null,
updated_at: new Date(),
});
await logActivity('expense_paid', { expenseId: id, paid: !!paid }, null, adminActor(adminId));
return getExpense(id);
}
module.exports = {
getAccountingSettings,
rebillExpense,
markExpensePaid,
// incoming invoices
recordInboundDocument,
getInbound,
listInbound,
updateInbound,
categorizeInbound,
rebillInbound,
listPendingRebillSummary,
billPendingRebills,
listCustomerRebills,
listInvoiceRebillProofs,
// Shared with the combined hours+re-bills orchestrator (#866).
buildPendingRebillLineItems,
stampBilledRebills,
markInboundSupplierPayment,
// expenses
createExpense,
getExpense,
listExpenses,
updateExpense,
// constants
DISPOSITIONS,
TAX_TREATMENTS,
MARKUP_TYPES,
PAYMENT_METHODS,
EXPENSE_KINDS,
// unit-test surface
_internal: { computeMarkupMinor, resolveMarkup, computeExpenseAmount, buildExpenseInsert, transformExpense, transformInbound, buildInboundLineItem, isInvoiceMutable, resolveTaxTreatment },
};