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:
Luca
2026-08-03 22:03:31 +02:00
committed by GitHub
parent d66425c8ee
commit 165cebdb5c
25 changed files with 1645 additions and 99 deletions
+30
View File
@@ -19,9 +19,13 @@ const { db } = require('../database/db');
// frontend already hides the surface). Per-customer enforcement stays in
// customerHoursService.createEntry.
const requireHoursLogging = requireFeatureFlag('hoursLogging', 'HOURS_LOGGING_DISABLED');
// Combined hours+re-bills billing (#866) is introduced by the re-bill feature;
// gate it behind incoming-invoices (no re-bills to combine when it's off).
const requireIncoming = requireFeatureFlag('incomingInvoices', 'INCOMING_INVOICES_DISABLED');
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const customerAccountsService = require('../services/customerAccountsService');
const customerHoursService = require('../services/customerHoursService');
const combinedBillingService = require('../services/combinedBillingService');
const invoiceService = require('../services/invoiceService');
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../utils/emailNormalization');
@@ -83,6 +87,10 @@ function transformCustomer(c) {
// this customer's invoices qualify for an early-payment discount,
// regardless of template / global defaults.
skontoDisabled: c.skonto_disabled === true || c.skonto_disabled === 1,
// Per-customer re-bill proof-attachment override (migration 169, #866).
// Tri-state: null = inherit the global default, true = always attach,
// false = never attach the supplier proof to the client-invoice email.
rebillAttachProof: c.rebill_attach_proof == null ? null : (c.rebill_attach_proof === true || c.rebill_attach_proof === 1),
lastLogin: c.last_login,
createdAt: c.created_at,
updatedAt: c.updated_at,
@@ -413,6 +421,9 @@ router.put('/:id', [
.withMessage('billing_cycle_day must be -15..-1 (days before month end) or 1..28 (day of month)'),
// Per-customer Skonto opt-out (migration 112).
body('skonto_disabled').optional().isBoolean(),
// Per-customer re-bill proof-attachment override (migration 169, #866).
// Nullable tri-state: null clears the override (inherit global default).
body('rebill_attach_proof').optional({ nullable: true }).isBoolean(),
], handleAsync(async (req, res) => {
validateRequest(req);
const customer = await customerAccountsService.updateCustomer(
@@ -685,6 +696,25 @@ router.post('/:id/hour-entries/bill', [
successResponse(res, result, 201);
}));
// Combined hours + re-bills → one invoice (#866, Feature 3). Used by the
// cross-add dialog when a per-event customer has open items in both categories.
router.post('/:id/bill-combined', [
adminAuth,
requireIncoming,
requirePermission('customers.edit'),
param('id').isInt({ min: 1 }),
body('includeHours').optional().isBoolean(),
body('includeRebills').optional().isBoolean(),
], handleAsync(async (req, res) => {
validateRequest(req);
const result = await combinedBillingService.billCombinedForCustomer(
parseInt(req.params.id, 10),
{ includeHours: req.body.includeHours !== false, includeRebills: req.body.includeRebills !== false },
req.admin.id,
);
successResponse(res, result, 201);
}));
function transformHourEntry(h) {
return {
id: h.id,
+7
View File
@@ -112,6 +112,13 @@ router.post('/inbound/bill-pending', requireIncoming, requirePermission('account
[body('customerAccountId').isInt({ min: 1 })],
handleAsync(async (req, res) => { validateRequest(req); return successResponse(res, await expenseService.billPendingRebills(toInt(req.body.customerAccountId), req.admin.id), 201, 'Re-billed'); }));
// Re-bill / passthrough items for one customer, with derived status (open /
// sent / paid) — feeds the CRM → Customer panel (#866, Feature 2). Registered
// BEFORE /inbound/:id so the literal path wins.
router.get('/inbound/by-customer/:customerAccountId', requireIncoming, requirePermission('accounting.view'),
[param('customerAccountId').isInt({ min: 1 })],
handleAsync(async (req, res) => { validateRequest(req); return successResponse(res, { items: await expenseService.listCustomerRebills(toInt(req.params.customerAccountId)) }); }));
router.get('/inbound/:id/file', requireIncoming, requirePermission('accounting.view'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
+31 -4
View File
@@ -28,9 +28,13 @@ const { requirePermission } = require('../middleware/permissions');
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const { getStoragePath } = require('../config/storage');
const invoiceService = require('../services/invoiceService');
const expenseService = require('../services/expenseService');
const { requireFeatureFlag } = require('../middleware/requireFeatureFlag');
const { db } = require('../database/db');
const router = express.Router();
// Re-bill proof endpoints (#866) are behind the incoming-invoices flag.
const requireIncoming = requireFeatureFlag('incomingInvoices', 'INCOMING_INVOICES_DISABLED');
// PR #603 review follow-up #2 — bound payment dates. `isISO8601()` alone
// accepts year 1900/9999; cash-basis revenue keys on paid_at, so a typo
@@ -753,13 +757,36 @@ router.put(
// ---- send / pay / remind / cancel ------------------------------------
router.post(
'/:id/send',
requirePermission('bills.manage'),
// Re-bill proofs attached to this (not-yet-sent) invoice + the resolved attach
// default — powers the Send dialog's per-file proof selection (#866). Behind the
// incoming-invoices flag; bills.view since it's part of the invoice send flow.
router.get(
'/:id/rebill-proofs',
requireIncoming,
requirePermission('bills.view'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
await invoiceService.sendInvoice(parseInt(req.params.id, 10), req.admin.id);
return successResponse(res, await expenseService.listInvoiceRebillProofs(parseInt(req.params.id, 10)));
})
);
router.post(
'/:id/send',
requirePermission('bills.manage'),
[
param('id').isInt({ min: 1 }),
// Optional per-file re-bill proof selection (#866). Array of inbound
// document ids the admin chose to attach; omitted → resolved default.
body('proofInboundIds').optional({ nullable: true }).isArray(),
body('proofInboundIds.*').isInt({ min: 1 }),
],
handleAsync(async (req, res) => {
validateRequest(req);
const proofInboundIds = Array.isArray(req.body.proofInboundIds)
? req.body.proofInboundIds.map((n) => parseInt(n, 10)).filter(Number.isInteger)
: undefined;
await invoiceService.sendInvoice(parseInt(req.params.id, 10), req.admin.id, { proofInboundIds });
return successResponse(res, { sent: true });
})
);
+22
View File
@@ -300,6 +300,28 @@ router.put('/accounting', adminAuth, requirePermission('settings.edit'), async (
setting_type: 'accounting',
});
}
// Global default for "attach the supplier proof PDF to the client-invoice
// email when a re-bill/passthrough is issued" (issue #866). Off by default;
// a per-customer override (customer_accounts.rebill_attach_proof) and the
// per-file selection in the Send dialog both build on top of this default.
if (Object.prototype.hasOwnProperty.call(req.body, 'accounting_rebill_attach_proof')) {
updates.push({
setting_key: 'accounting_rebill_attach_proof',
setting_value: JSON.stringify(!!req.body.accounting_rebill_attach_proof),
setting_type: 'accounting',
});
}
// Filename template for the attached supplier proof (like the invoice/quote
// number formats). Tokens: {INVOICE} {SUPPLIER} {YEAR} {MONTH} {SEQ}/{SEQ:0Nd}.
// Empty falls back to the default at render time.
if (Object.prototype.hasOwnProperty.call(req.body, 'crm_rebill_proof_filename_format')) {
const fmt = String(req.body.crm_rebill_proof_filename_format || '').trim().slice(0, 120);
updates.push({
setting_key: 'crm_rebill_proof_filename_format',
setting_value: JSON.stringify(fmt),
setting_type: 'accounting',
});
}
// VAT registration + reclaim. `registered` drives whether output VAT applies
// + whether input VAT is deductible; `reclaim_countries` = the ISO-2 list of
// countries whose input VAT can be reclaimed (drives cost tax-treatment +