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:
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 });
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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 +
|
||||
|
||||
Reference in New Issue
Block a user