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
@@ -0,0 +1,256 @@
/**
* Issue #866 — the createInvoice-free halves of the re-bill proof + CRM panel
* feature, against a real SQLite schema:
*
* • listCustomerRebills — status DERIVED from the linked invoice lifecycle
* (open / sent / paid; a cancelled/Storno'd cover drops back to open) plus
* cost-vs-rebilled math and mode.
* • collectRebillProofAttachments — the Send-dialog per-file selection, the
* all-or-none default resolution (per-customer override else global), the
* Beleg-<inv#> filename (suffix only when >1), and the missing-file marker.
*
* The invoice-MINTING paths (billCombinedForCustomer / billPendingRebills) call
* createInvoice inside a db.transaction, which deadlocks on the SQLite harness
* (global-db sequence write vs. held write lock) — same limitation the sibling
* incomingInvoiceRebill.test.js documents. They're covered by the existing
* billPendingRebills / billUnbilledEntries suites; here we hand-craft billed
* state instead.
*/
const fs = require('fs');
const path = require('path');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
jest.setTimeout(120000);
describe('#866 re-bill proof attachment + CRM panel', () => {
let db;
let cleanup;
let adminId;
let expenseService;
let rebillProofs;
let flagCache;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
const dbModule = require('../../src/database/db');
dbModule.logActivity = async () => {};
({ adminId } = await seedMinimal(db));
expenseService = require('../../src/services/expenseService');
rebillProofs = require('../../src/services/invoice/rebillProofs');
flagCache = require('../../src/middleware/requireFeatureFlag');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
const unwrapId = (ins) => (typeof ins[0] === 'object' ? ins[0].id : ins[0]);
let seq = 0;
async function makeCustomer(overrides = {}) {
seq += 1;
const ins = await db('customer_accounts').insert({
email: `c866-${seq}@example.com`,
display_name: `C866 ${seq}`,
password_hash: 'x',
preferred_language: 'de',
is_active: 1,
billing_cadence: 'per_event',
created_at: new Date(),
...overrides,
}).returning('id');
return unwrapId(ins);
}
async function makeDoc(customerId, overrides = {}) {
const ins = await db('inbound_documents').insert({
source: 'upload', status: 'categorized', parse_status: 'parsed', parse_method: 'none',
supplier_name: 'ACME AG', currency: 'CHF', total_amount_minor: 10000,
invoice_date: '2026-06-01', disposition: 'rebill', customer_account_id: customerId,
created_at: new Date(), updated_at: new Date(),
...overrides,
}).returning('id');
return unwrapId(ins);
}
async function makeInvoice(customerId, status, number) {
const ins = await db('invoices').insert({
invoice_number: number,
customer_account_id: customerId,
status,
currency: 'CHF',
issue_date: '2026-06-01', due_date: '2026-07-01',
vat_rate: 0, net_amount_minor: 10000, vat_amount_minor: 0, total_amount_minor: 10000,
created_at: new Date(), updated_at: new Date(),
}).returning('id');
return unwrapId(ins);
}
describe('listCustomerRebills', () => {
it('derives open / sent / paid and open→cost==rebilled for passthrough, +markup for rebill', async () => {
const customerId = await makeCustomer();
// Open re-bill (10% markup): rebilled = 11000.
await makeDoc(customerId, { total_amount_minor: 10000, markup_type: 'percent', markup_percent: 10 });
// Open passthrough: no markup, rebilled == cost.
await makeDoc(customerId, { disposition: 'durchlaufend', total_amount_minor: 5000, markup_type: 'none' });
// Sent (on a 'sent' invoice).
const sentInv = await makeInvoice(customerId, 'sent', 'R-2026-0001');
await makeDoc(customerId, { total_amount_minor: 8000, markup_type: 'none', billed_invoice_id: sentInv });
// Paid.
const paidInv = await makeInvoice(customerId, 'paid', 'R-2026-0002');
await makeDoc(customerId, { total_amount_minor: 8000, markup_type: 'none', billed_invoice_id: paidInv });
// Cancelled cover → drops back to 'open', no invoice link surfaced.
const cancInv = await makeInvoice(customerId, 'cancelled', 'R-2026-0003');
await makeDoc(customerId, { total_amount_minor: 8000, markup_type: 'none', billed_invoice_id: cancInv });
const items = await expenseService.listCustomerRebills(customerId);
const byStatus = (s) => items.filter((r) => r.status === s);
expect(items).toHaveLength(5);
expect(byStatus('open')).toHaveLength(3); // 2 genuinely-open + 1 cancelled-cover
expect(byStatus('sent')).toHaveLength(1);
expect(byStatus('paid')).toHaveLength(1);
const rebill = items.find((r) => r.mode === 'rebill' && r.costMinor === 10000);
expect(rebill.rebilledMinor).toBe(11000);
const passthrough = items.find((r) => r.mode === 'passthrough');
expect(passthrough.rebilledMinor).toBe(passthrough.costMinor);
const sent = byStatus('sent')[0];
expect(sent.invoiceNumber).toBe('R-2026-0001');
expect(sent.invoiceId).toBe(sentInv);
const cancelledCover = items.find((r) => r.status === 'open' && r.invoiceNumber === null && r.costMinor === 8000);
expect(cancelledCover).toBeDefined(); // cancelled cover isn't shown as a live invoice link
});
});
describe('storno releases the re-bill linkage (#866 review)', () => {
it("clears billed_invoice_id so a Storno'd cover returns to the billable pool", async () => {
const invoiceService = require('../../src/services/invoiceService');
const customerId = await makeCustomer();
const invId = await makeInvoice(customerId, 'sent', 'R-2026-9000');
const lineIns = await db('invoice_line_items').insert({
invoice_id: invId, position: 1, quantity: 1, description: 'Rebill',
unit_price_minor: 8000, discount_percent: 0, line_total_minor: 8000,
}).returning('id');
const lineId = unwrapId(lineIns);
const docId = await makeDoc(customerId, {
total_amount_minor: 8000, markup_type: 'none', billed_invoice_id: invId, billed_invoice_line_item_id: lineId,
});
// Storno claims a fresh number from document_sequences; the other tests
// seed explicit R-2026-000x numbers without advancing it, so push the
// counter past them to avoid a number collision (a test artifact — real
// invoices always claim through the sequence).
await db('document_sequences').insert({ kind: 'invoice', year: 2026, current_value: 9000, created_at: new Date(), updated_at: new Date() })
.onConflict(['kind', 'year']).ignore();
await db('document_sequences').where({ kind: 'invoice', year: 2026 }).update({ current_value: 9000 });
// Storno the covering invoice (the issued-cancel path).
await db.transaction(async (trx) => invoiceService.createStorno(invId, adminId, trx));
const doc = await db('inbound_documents').where({ id: docId }).first();
expect(doc.billed_invoice_id).toBeNull();
expect(doc.billed_invoice_line_item_id).toBeNull();
// It now surfaces as a genuinely-open item AND the pending pool picks it up.
const items = await expenseService.listCustomerRebills(customerId);
const row = items.find((r) => r.id === docId);
expect(row.status).toBe('open');
expect(row.invoiceId).toBeNull();
const pending = await db('inbound_documents')
.where({ customer_account_id: customerId }).whereNull('billed_invoice_id')
.whereIn('disposition', ['rebill', 'durchlaufend']).where('status', 'categorized');
expect(pending.map((p) => p.id)).toContain(docId);
});
});
describe('collectRebillProofAttachments', () => {
const businessDocs = () => path.join(process.env.STORAGE_PATH, 'business-docs', 'inbound', '2026');
async function enableIncoming() {
const existing = await db('feature_flags').where({ key: 'incomingInvoices' }).first();
if (existing) await db('feature_flags').where({ key: 'incomingInvoices' }).update({ value: 1 });
else await db('feature_flags').insert({ key: 'incomingInvoices', value: 1 });
flagCache.invalidateFeatureFlagCache();
}
function writeProof(name) {
fs.mkdirSync(businessDocs(), { recursive: true });
const p = path.join(businessDocs(), name);
fs.writeFileSync(p, '%PDF-1.4\n% test proof\n');
return p;
}
it('honours explicit selection, names Beleg-<inv#>, and marks a missing file', async () => {
await enableIncoming();
const customerId = await makeCustomer();
const invId = await makeInvoice(customerId, 'scheduled', 'R-2026-1000');
const invoice = await db('invoices').where({ id: invId }).first();
const good1 = await makeDoc(customerId, { billed_invoice_id: invId, file_path: writeProof('p1.pdf') });
const good2 = await makeDoc(customerId, { billed_invoice_id: invId, file_path: writeProof('p2.pdf') });
const missing = await makeDoc(customerId, { billed_invoice_id: invId, file_path: path.join(businessDocs(), 'nope.pdf') });
// Select the two good proofs → two attachments, suffixed because >1.
const both = await rebillProofs.collectRebillProofAttachments(invoice, null, [good1, good2]);
expect(both.map((a) => a.filename).sort()).toEqual(['Beleg-R-2026-1000-1.pdf', 'Beleg-R-2026-1000-2.pdf']);
// Select exactly one → single, unsuffixed.
const one = await rebillProofs.collectRebillProofAttachments(invoice, null, [good1]);
expect(one).toHaveLength(1);
expect(one[0].filename).toBe('Beleg-R-2026-1000.pdf');
// Select the missing-file doc → no attachment, but a marker is persisted.
const none = await rebillProofs.collectRebillProofAttachments(invoice, null, [missing]);
expect(none).toHaveLength(0);
const markerRow = await db('inbound_documents').where({ id: missing }).first('proof_attach_error');
expect(markerRow.proof_attach_error).toBeTruthy();
// A successful attach clears any prior marker.
await rebillProofs.collectRebillProofAttachments(invoice, null, [good1]);
const cleared = await db('inbound_documents').where({ id: good1 }).first('proof_attach_error');
expect(cleared.proof_attach_error).toBeNull();
});
it('resolves the all-or-none default from the per-customer override then global', async () => {
await enableIncoming();
const customerId = await makeCustomer();
const invId = await makeInvoice(customerId, 'scheduled', 'R-2026-2000');
const invoice = await db('invoices').where({ id: invId }).first();
await makeDoc(customerId, { billed_invoice_id: invId, file_path: writeProof('d1.pdf') });
// Global default off, no override → none.
const off = await rebillProofs.collectRebillProofAttachments(invoice, { rebill_attach_proof: null }, undefined);
expect(off).toHaveLength(0);
// Per-customer override ON → all, regardless of the (off) global.
const on = await rebillProofs.collectRebillProofAttachments(invoice, { rebill_attach_proof: true }, undefined);
expect(on).toHaveLength(1);
// Global ON (no override) → all.
await db('app_settings').insert({ setting_key: 'accounting_rebill_attach_proof', setting_value: JSON.stringify(true), setting_type: 'accounting' });
const globalOn = await rebillProofs.collectRebillProofAttachments(invoice, { rebill_attach_proof: null }, undefined);
expect(globalOn).toHaveLength(1);
// Override OFF beats global ON.
const overrideOff = await rebillProofs.collectRebillProofAttachments(invoice, { rebill_attach_proof: false }, undefined);
expect(overrideOff).toHaveLength(0);
});
it('attaches nothing when the incoming-invoices flag is off', async () => {
const existing = await db('feature_flags').where({ key: 'incomingInvoices' }).first();
if (existing) await db('feature_flags').where({ key: 'incomingInvoices' }).update({ value: 0 });
else await db('feature_flags').insert({ key: 'incomingInvoices', value: 0 });
flagCache.invalidateFeatureFlagCache();
const customerId = await makeCustomer();
const invId = await makeInvoice(customerId, 'scheduled', 'R-2026-3000');
const invoice = await db('invoices').where({ id: invId }).first();
const doc = await makeDoc(customerId, { billed_invoice_id: invId, file_path: writeProof('f1.pdf') });
const res = await rebillProofs.collectRebillProofAttachments(invoice, { rebill_attach_proof: true }, [doc]);
expect(res).toHaveLength(0);
});
});
});
@@ -0,0 +1,38 @@
/**
* renderProofName — the configurable Beleg proof-attachment filename template.
* Pure function; no DB. Covers token substitution, the multi-proof index
* fallback, padding, and filesystem-safe sanitisation.
*/
const { renderProofName } = require('../../src/services/invoice/rebillProofs');
describe('renderProofName', () => {
const base = { invoiceNumber: 'R-2026-0042', supplierName: 'ACME AG', seq: 1, hasMulti: false, issueDate: '2026-08-03' };
it('defaults to Beleg-<invoice>.pdf', () => {
expect(renderProofName('Beleg-{INVOICE}', base)).toBe('Beleg-R-2026-0042.pdf');
expect(renderProofName('', base)).toBe('Beleg-R-2026-0042.pdf');
expect(renderProofName(null, base)).toBe('Beleg-R-2026-0042.pdf');
});
it('substitutes every token incl. padded SEQ and date parts', () => {
expect(renderProofName('{SUPPLIER}-{INVOICE}-{YEAR}{MONTH}-{SEQ:03d}', { ...base, seq: 7 }))
.toBe('ACME-AG-R-2026-0042-202608-007.pdf');
});
it('appends an index for multiple proofs only when the template has no {SEQ}', () => {
// No {SEQ} + multi → auto-suffixed with the index.
expect(renderProofName('Beleg-{INVOICE}', { ...base, seq: 2, hasMulti: true })).toBe('Beleg-R-2026-0042-2.pdf');
// Single proof → no suffix.
expect(renderProofName('Beleg-{INVOICE}', { ...base, seq: 1, hasMulti: false })).toBe('Beleg-R-2026-0042.pdf');
// Explicit {SEQ} → no double index even when multi.
expect(renderProofName('Beleg-{INVOICE}-{SEQ}', { ...base, seq: 2, hasMulti: true })).toBe('Beleg-R-2026-0042-2.pdf');
});
it('sanitises unsafe characters and slashes, and always ends in a single .pdf', () => {
expect(renderProofName('Beleg {INVOICE}', { ...base, invoiceNumber: '2026/0042' })).toBe('Beleg-2026-0042.pdf');
// Author-supplied extension is stripped and re-added (no double .pdf).
expect(renderProofName('{INVOICE}.pdf', base)).toBe('R-2026-0042.pdf');
// Falls back to 'Beleg' if the template renders empty after sanitising.
expect(renderProofName('{SUPPLIER}', { ...base, supplierName: '///' })).toBe('Beleg.pdf');
});
});
@@ -0,0 +1,50 @@
/**
* Migration 169: re-bill proof-attachment support (issue #866).
*
* - inbound_documents.proof_attach_error : best-effort failure marker. When a
* re-billed supplier invoice's stored
* proof PDF is missing/unreadable at
* the moment the client invoice is
* issued, we DON'T silently drop it —
* we stamp the reason here so the
* re-bill row in CRM → Customer shows
* a recovery banner.
* - customer_accounts.rebill_attach_proof: per-customer tri-state override for
* "attach the supplier proof to the
* client-invoice email".
* NULL = inherit the global default
* true = always attach
* false = never attach
* The global default itself lives in
* app_settings (accounting_rebill_
* attach_proof, default off) and needs
* no seed row — an absent key coerces
* to false, exactly like
* accounting_require_proof.
*
* Additive + hasColumn-guarded so re-runs are safe.
*/
async function addColumn(knex, table, column, builder) {
if (!(await knex.schema.hasColumn(table, column))) {
await knex.schema.alterTable(table, builder);
}
}
exports.up = async function (knex) {
if (await knex.schema.hasTable('inbound_documents')) {
await addColumn(knex, 'inbound_documents', 'proof_attach_error', (t) => t.text('proof_attach_error'));
}
if (await knex.schema.hasTable('customer_accounts')) {
// Nullable boolean = tri-state (NULL inherit / true on / false off).
await addColumn(knex, 'customer_accounts', 'rebill_attach_proof', (t) => t.boolean('rebill_attach_proof').nullable());
}
};
exports.down = async function (knex) {
if (await knex.schema.hasTable('inbound_documents') && await knex.schema.hasColumn('inbound_documents', 'proof_attach_error')) {
await knex.schema.alterTable('inbound_documents', (t) => t.dropColumn('proof_attach_error'));
}
if (await knex.schema.hasTable('customer_accounts') && await knex.schema.hasColumn('customer_accounts', 'rebill_attach_proof')) {
await knex.schema.alterTable('customer_accounts', (t) => t.dropColumn('rebill_attach_proof'));
}
};
+30
View File
@@ -19,9 +19,13 @@ const { db } = require('../database/db');
// frontend already hides the surface). Per-customer enforcement stays in // frontend already hides the surface). Per-customer enforcement stays in
// customerHoursService.createEntry. // customerHoursService.createEntry.
const requireHoursLogging = requireFeatureFlag('hoursLogging', 'HOURS_LOGGING_DISABLED'); 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 { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const customerAccountsService = require('../services/customerAccountsService'); const customerAccountsService = require('../services/customerAccountsService');
const customerHoursService = require('../services/customerHoursService'); const customerHoursService = require('../services/customerHoursService');
const combinedBillingService = require('../services/combinedBillingService');
const invoiceService = require('../services/invoiceService'); const invoiceService = require('../services/invoiceService');
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../utils/emailNormalization'); 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, // this customer's invoices qualify for an early-payment discount,
// regardless of template / global defaults. // regardless of template / global defaults.
skontoDisabled: c.skonto_disabled === true || c.skonto_disabled === 1, 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, lastLogin: c.last_login,
createdAt: c.created_at, createdAt: c.created_at,
updatedAt: c.updated_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)'), .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). // Per-customer Skonto opt-out (migration 112).
body('skonto_disabled').optional().isBoolean(), 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) => { ], handleAsync(async (req, res) => {
validateRequest(req); validateRequest(req);
const customer = await customerAccountsService.updateCustomer( const customer = await customerAccountsService.updateCustomer(
@@ -685,6 +696,25 @@ router.post('/:id/hour-entries/bill', [
successResponse(res, result, 201); 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) { function transformHourEntry(h) {
return { return {
id: h.id, id: h.id,
+7
View File
@@ -112,6 +112,13 @@ router.post('/inbound/bill-pending', requireIncoming, requirePermission('account
[body('customerAccountId').isInt({ min: 1 })], [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'); })); 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'), router.get('/inbound/:id/file', requireIncoming, requirePermission('accounting.view'),
[param('id').isInt({ min: 1 })], [param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => { 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 { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const { getStoragePath } = require('../config/storage'); const { getStoragePath } = require('../config/storage');
const invoiceService = require('../services/invoiceService'); const invoiceService = require('../services/invoiceService');
const expenseService = require('../services/expenseService');
const { requireFeatureFlag } = require('../middleware/requireFeatureFlag');
const { db } = require('../database/db'); const { db } = require('../database/db');
const router = express.Router(); 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 // 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 // accepts year 1900/9999; cash-basis revenue keys on paid_at, so a typo
@@ -753,13 +757,36 @@ router.put(
// ---- send / pay / remind / cancel ------------------------------------ // ---- send / pay / remind / cancel ------------------------------------
router.post( // Re-bill proofs attached to this (not-yet-sent) invoice + the resolved attach
'/:id/send', // default — powers the Send dialog's per-file proof selection (#866). Behind the
requirePermission('bills.manage'), // 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 })], [param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => { handleAsync(async (req, res) => {
validateRequest(req); 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 }); return successResponse(res, { sent: true });
}) })
); );
+22
View File
@@ -300,6 +300,28 @@ router.put('/accounting', adminAuth, requirePermission('settings.edit'), async (
setting_type: 'accounting', 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 // VAT registration + reclaim. `registered` drives whether output VAT applies
// + whether input VAT is deductible; `reclaim_countries` = the ISO-2 list of // + whether input VAT is deductible; `reclaim_countries` = the ISO-2 list of
// countries whose input VAT can be reclaimed (drives cost tax-treatment + // countries whose input VAT can be reclaimed (drives cost tax-treatment +
@@ -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 // Per-customer Skonto opt-out (migration 112). Boolean, coerced
// via formatBoolean below for SQLite compatibility. // via formatBoolean below for SQLite compatibility.
'skonto_disabled', '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) { for (const f of fields) {
if (updates[f] !== undefined) { if (updates[f] !== undefined) {
@@ -597,6 +602,10 @@ async function updateCustomer(id, updates, updatedByAdminId) {
|| f === 'skonto_disabled' || f === 'skonto_disabled'
) { ) {
allowed[f] = formatBoolean(updates[f]); 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') { } else if (f === 'hourly_rate_minor') {
// Default hourly rate. Null clears it (forces per-entry // Default hourly rate. Null clears it (forces per-entry
// overrides); otherwise coerce to a non-negative bigint-safe // overrides); otherwise coerce to a non-negative bigint-safe
+39 -23
View File
@@ -447,6 +447,38 @@ async function deleteEntry(entryId, adminId) {
* onto the running draft on save, so there should be no unbilled rows. * onto the running draft on save, so there should be no unbilled rows.
* Returns the new invoice id. * 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) { async function billUnbilledEntries(customerId, adminId) {
const customer = await db('customer_accounts').where({ id: customerId }).first(); const customer = await db('customer_accounts').where({ id: customerId }).first();
if (!customer) throw new AppError('Customer not found', 404); 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. let logInfo = null; // logged after commit — see createEntry note.
const result = await db.transaction(async (trx) => { const result = await db.transaction(async (trx) => {
const unbilled = await trx('customer_hour_entries') const { entries: unbilled, lineItems: rawLines } = await buildUnbilledHourLineItems(trx, customer);
.where({ customer_account_id: customer.id, status: 'unbilled' })
.orderBy('entry_date', 'asc').orderBy('start_time', 'asc');
if (unbilled.length === 0) { if (unbilled.length === 0) {
throw new AppError('No unbilled entries to bill', 409, 'NO_UNBILLED'); throw new AppError('No unbilled entries to bill', 409, 'NO_UNBILLED');
} }
const lineItems = rawLines.map((li, idx) => ({ ...li, position: idx + 1 }));
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 };
});
// No installment metadata — hour-billing always mints a single // No installment metadata — hour-billing always mints a single
// standalone invoice. createInvoice returns `{ invoiceIds: [N] }` // standalone invoice. createInvoice returns `{ invoiceIds: [N] }`
@@ -491,19 +515,8 @@ async function billUnbilledEntries(customerId, adminId) {
.where({ invoice_id: invoiceId }) .where({ invoice_id: invoiceId })
.orderBy('position', 'asc'); .orderBy('position', 'asc');
const lineByPos = new Map(insertedLines.map((li) => [li.position, li.id])); const lineByPos = new Map(insertedLines.map((li) => [li.position, li.id]));
const lineIds = unbilled.map((_, i) => lineByPos.get(i + 1) || null);
const now = new Date(); await stampBilledEntries(trx, unbilled, invoiceId, lineIds);
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,
});
}
logInfo = { type: 'hour_entries_billed', meta: { customerId: customer.id, invoiceId, entryCount: unbilled.length } }; logInfo = { type: 'hour_entries_billed', meta: { customerId: customer.id, invoiceId, entryCount: unbilled.length } };
return { invoiceId, entriesBilled: unbilled.length }; return { invoiceId, entriesBilled: unbilled.length };
@@ -590,6 +603,9 @@ module.exports = {
updateEntry, updateEntry,
deleteEntry, deleteEntry,
billUnbilledEntries, billUnbilledEntries,
// Shared with the combined hours+re-bills orchestrator (#866).
buildUnbilledHourLineItems,
stampBilledEntries,
getInstallDefaultRateMinor, getInstallDefaultRateMinor,
_internal: { _internal: {
computeDurationMinutes, computeDurationMinutes,
+166 -27
View File
@@ -21,6 +21,9 @@ const { db, logActivity } = require('../database/db');
const { AppError } = require('../utils/errors'); const { AppError } = require('../utils/errors');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const invoiceService = require('./invoiceService'); 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 * Actor for logActivity. `adminId` is legitimately absent on automated paths
@@ -54,7 +57,7 @@ function toIsoDate(v) {
// ── Accounting settings (app_settings, type 'accounting') ─────────────────── // ── Accounting settings (app_settings, type 'accounting') ───────────────────
async function getAccountingSettings() { async function getAccountingSettings() {
const keys = ['accounting_km_rate_minor', 'accounting_per_diem_rate_minor', 'accounting_require_proof', 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 = []; let rows = [];
try { try {
rows = await db('app_settings').whereIn('setting_key', keys).select('setting_key', 'setting_value'); 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, 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, 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', 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) vatReclaimCountries: Array.isArray(map.accounting_vat_reclaim_countries)
? map.accounting_vat_reclaim_countries.map((c) => String(c || '').toUpperCase()) : [], ? 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'); if (!row) throw new AppError('Incoming invoice not found', 404, 'INBOUND_NOT_FOUND');
const doc = transformInbound(row); 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. // #1: unwind any prior re-bill so the disposition can change.
if (doc.billedInvoiceId) await unwindBilledLine(trx, doc); 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(); const row = await trx('inbound_documents').where({ id }).first();
if (!row) throw new AppError('Incoming invoice not found', 404, 'INBOUND_NOT_FOUND'); if (!row) throw new AppError('Incoming invoice not found', 404, 'INBOUND_NOT_FOUND');
const doc = transformInbound(row); 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); if (doc.billedInvoiceId) await unwindBilledLine(trx, doc);
const markup = await resolveMarkup( const markup = await resolveMarkup(
{ markupType: doc.markupType, markupPercent: doc.markupPercent, markupFlatMinor: doc.markupFlatMinor }, { 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); 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 * Per-event flow: bundle all pending rebill/passthrough documents for a
* customer into ONE invoice, one line per document. Refuses for monthly/manual * 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 result = await db.transaction(async (trx) => {
const pending = await trx('inbound_documents') const { docs: pending, lineItems: rawLines } = await buildPendingRebillLineItems(trx, customer);
.where({ customer_account_id: customer.id })
.whereNull('billed_invoice_id')
.whereIn('disposition', CUSTOMER_DISPOSITIONS)
.where('status', 'categorized')
.orderBy('invoice_date', 'asc').orderBy('id', 'asc');
if (pending.length === 0) throw new AppError('No pending re-bills to bill', 409, 'NO_PENDING'); 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 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 { invoiceIds } = await invoiceService.createInvoice({ const { invoiceIds } = await invoiceService.createInvoice({
customerAccountId: customer.id, 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 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 lineByPos = new Map(insertedLines.map((li) => [li.position, li.id]));
const now = new Date(); const lineIds = pending.map((_, i) => lineByPos.get(i + 1) || null);
for (let i = 0; i < pending.length; i += 1) { await stampBilledRebills(trx, pending, invoiceId, lineIds);
// 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,
});
}
return { invoiceId, count: pending.length }; return { invoiceId, count: pending.length };
}); });
@@ -620,6 +654,106 @@ async function billPendingRebills(customerId, adminId) {
return result; 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). */ /** Mark the supplier paid on the incoming invoice (the payable lives here). */
async function markInboundSupplierPayment(id, { paid, paidAt, paymentMethod, paymentReference }, adminId) { async function markInboundSupplierPayment(id, { paid, paidAt, paymentMethod, paymentReference }, adminId) {
await getInbound(id); await getInbound(id);
@@ -833,6 +967,11 @@ module.exports = {
rebillInbound, rebillInbound,
listPendingRebillSummary, listPendingRebillSummary,
billPendingRebills, billPendingRebills,
listCustomerRebills,
listInvoiceRebillProofs,
// Shared with the combined hours+re-bills orchestrator (#866).
buildPendingRebillLineItems,
stampBilledRebills,
markInboundSupplierPayment, markInboundSupplierPayment,
// expenses // expenses
createExpense, 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 };
+50 -6
View File
@@ -14,12 +14,19 @@ const { computeDueDate, ensureCustomerCanBill, formatMajor, getHierarchyHelpers,
const { getInvoiceById } = require('./queries'); const { getInvoiceById } = require('./queries');
const { createInvoice } = require('./create'); const { createInvoice } = require('./create');
const { buildInvoiceRenderContext } = require('./render'); const { buildInvoiceRenderContext } = require('./render');
const { collectRebillProofAttachments } = require('./rebillProofs');
/** /**
* Send an invoice email + PDF. Flips status scheduled sent. * 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); const data = await getInvoiceById(id);
if (!data) throw new AppError('Invoice not found', 404); if (!data) throw new AppError('Invoice not found', 404);
const { invoice, lineItems } = data; const { invoice, lineItems } = data;
@@ -118,6 +125,23 @@ async function sendInvoice(id, adminId) {
}); });
const { to: invoiceTo, cc: invoiceCc } = resolveBillingRecipients(customer, invoice.cc_pdf_email); 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', { await emailProcessor.queueEmail(invoice.event_id || null, invoiceTo, 'invoice_sent', {
invoice_number: invoice.invoice_number, invoice_number: invoice.invoice_number,
customer_name: customer.display_name || customer.first_name || customer.email.split('@')[0], 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. // above) rather than the event-first default resolution.
__language: ctx.locale, __language: ctx.locale,
cc: invoiceCc, cc: invoiceCc,
attachments: [{ attachments: invoiceAttachments,
filename: `${invoice.invoice_number}.pdf`,
contentPath: pdfPath,
contentType: 'application/pdf',
}],
}); });
try { await logActivity('invoice_sent', { invoiceId: id }, invoice.event_id || null, `admin:${adminId}`); } catch (_) {} 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'` * cancellation itself; the storno sits in `status='scheduled'`
* and the cron picks it up. * 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) { async function createStorno(originalId, adminId, trx = db) {
const original = await trx('invoices').where({ id: originalId }).first(); const original = await trx('invoices').where({ id: originalId }).first();
if (!original) throw new AppError('Invoice not found', 404); if (!original) throw new AppError('Invoice not found', 404);
@@ -305,6 +345,8 @@ async function createStorno(originalId, adminId, trx = db) {
cancellation_storno_id: stornoId, cancellation_storno_id: stornoId,
updated_at: now, updated_at: now,
}); });
// Free any re-billed supplier invoices so the cost isn't stranded (#866 review).
await releaseRebillsForCancelledInvoice(trx, originalId);
try { try {
// Pass `trx` so the audit insert rides the transaction's connection; // 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({ await db('invoices').where({ id }).update({
status: 'cancelled', updated_at: new Date(), 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 { try {
await logActivity('invoice_cancelled', await logActivity('invoice_cancelled',
{ invoiceId: id, viaStorno: false }, { invoiceId: id, viaStorno: false },
@@ -0,0 +1,65 @@
/**
* Cross-add dialog (issue #866, Feature 3).
*
* Shown when the admin bills ONE category (hours or re-bills) for a customer
* who also has open items in the OTHER category. Offers to roll both into the
* same invoice. Hours and re-bills are never merged into shared line items
* they stay as distinct, contiguous groups on the invoice.
*/
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '../common';
interface Props {
open: boolean;
/** The category the admin clicked "create invoice" on. */
primary: 'hours' | 'rebills';
/** How many OPEN items exist in the OTHER category. */
otherCount: number;
busy?: boolean;
/** includeOther = true → combine both; false → bill only the primary. */
onConfirm: (includeOther: boolean) => void;
onClose: () => void;
}
export const CrossAddInvoiceDialog: React.FC<Props> = ({ open, primary, otherCount, busy, onConfirm, onClose }) => {
const { t } = useTranslation();
// Escape closes without billing (mirrors the explicit Cancel below).
useEffect(() => {
if (!open) return undefined;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && !busy) onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, busy, onClose]);
if (!open) return null;
const other = primary === 'hours' ? 'rebills' : 'hours';
const otherLabel = other === 'hours'
? t('crossAdd.hours', 'open hours')
: t('crossAdd.rebills', 'open re-bills');
const primaryOnlyLabel = primary === 'hours'
? t('crossAdd.hoursOnly', 'Just the hours')
: t('crossAdd.rebillsOnly', 'Just the re-bills');
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => !busy && onClose()}>
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-xl w-full max-w-md mx-4 p-5" onClick={(e) => e.stopPropagation()}>
<h3 className="font-semibold mb-2 text-lg text-neutral-900 dark:text-neutral-100">
{t('crossAdd.title', 'Add other open items?')}
</h3>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t('crossAdd.body',
'This customer also has {{count}} {{label}}. Add them to the same invoice? They stay as a separate group — hours and re-bills are never mixed into one line.',
{ count: otherCount, label: otherLabel })}
</p>
<div className="flex flex-col-reverse sm:flex-row sm:items-center sm:justify-between gap-2">
<Button variant="ghost" disabled={busy} onClick={onClose}>{t('common.cancel', 'Cancel')}</Button>
<div className="flex flex-col-reverse sm:flex-row gap-2">
<Button variant="outline" disabled={busy} onClick={() => onConfirm(false)}>{primaryOnlyLabel}</Button>
<Button disabled={busy} onClick={() => onConfirm(true)}>{t('crossAdd.addBoth', 'Add both')}</Button>
</div>
</div>
</div>
</div>
);
};
@@ -11,16 +11,21 @@
* Lives as a separate component so CustomerDetailPage doesn't need to * Lives as a separate component so CustomerDetailPage doesn't need to
* know about CRM types; the panels handle their own data fetching. * know about CRM types; the panels handle their own data fetching.
*/ */
import React from 'react'; import React, { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { FileText, Plus, Receipt, ScrollText } from 'lucide-react'; import { toast } from 'react-toastify';
import { FileText, Plus, Receipt, ScrollText, Repeat2, AlertTriangle } from 'lucide-react';
import { Card, Button, Loading } from '../common'; import { Card, Button, Loading } from '../common';
import { useFeatureFlags } from '../../contexts/FeatureFlagsContext'; import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
import { usePermission } from '../../hooks/usePermission';
import { quotesService } from '../../services/quotes.service'; import { quotesService } from '../../services/quotes.service';
import { billsService, isDraftInvoice } from '../../services/bills.service'; import { billsService, isDraftInvoice } from '../../services/bills.service';
import { contractsService } from '../../services/contracts.service'; import { contractsService } from '../../services/contracts.service';
import { accountingService, type CustomerRebillItem } from '../../services/accounting.service';
import { customerAdminService } from '../../services/customerAdmin.service';
import { CrossAddInvoiceDialog } from './CrossAddInvoiceDialog';
import { formatMoney } from './LineItemsTable'; import { formatMoney } from './LineItemsTable';
import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { useLocalizedDate } from '../../hooks/useLocalizedDate';
@@ -36,6 +41,7 @@ export const CustomerCrmPanels: React.FC<Props> = ({ customerAccountId }) => {
{flags.quotes && <QuotesPanel customerAccountId={customerAccountId} />} {flags.quotes && <QuotesPanel customerAccountId={customerAccountId} />}
{flags.contracts && <ContractsPanel customerAccountId={customerAccountId} />} {flags.contracts && <ContractsPanel customerAccountId={customerAccountId} />}
{flags.bills && <InvoicesPanel customerAccountId={customerAccountId} />} {flags.bills && <InvoicesPanel customerAccountId={customerAccountId} />}
{flags.incomingInvoices && <RebillsPanel customerAccountId={customerAccountId} />}
</> </>
); );
}; };
@@ -225,3 +231,167 @@ const InvoicesPanel: React.FC<Props> = ({ customerAccountId }) => {
</Card> </Card>
); );
}; };
// ── Re-bills / passthrough panel (issue #866, Feature 2) ─────────────────────
// History-only, mirrors the Hours section pattern: grouped by derived status
// (Open / Sent / Paid) with a "Create invoice from re-bills" button up top for
// the open pool. When the customer also has open hours, the button opens the
// cross-add dialog first (Feature 3).
const REBILL_STATUS_ORDER: Array<CustomerRebillItem['status']> = ['open', 'sent', 'paid'];
const RebillsPanel: React.FC<Props> = ({ customerAccountId }) => {
const { t } = useTranslation();
const { flags } = useFeatureFlags();
const { format: fmtDate } = useLocalizedDate();
const navigate = useNavigate();
const qc = useQueryClient();
// Permission gating (#866 review). The endpoints require, respectively:
// view the panel → accounting.view
// "Create invoice from re-bills" (billPendingRebills) → accounting.manage
// cross-add "Add both" (billCombined) → customers.edit
const canView = usePermission('accounting.view');
const canManage = usePermission('accounting.manage');
const canCombine = usePermission('customers.edit');
const [crossAddOpen, setCrossAddOpen] = useState(false);
const [busy, setBusy] = useState(false);
const { data: items = [], isLoading } = useQuery({
queryKey: ['customer-rebills', customerAccountId],
queryFn: () => accountingService.listCustomerRebills(customerAccountId),
enabled: canView,
staleTime: 30_000,
});
// Open hours count for the cross-add offer — only when hours logging is on
// AND the admin can actually create the combined invoice.
const { data: openHours = 0 } = useQuery({
queryKey: ['customer-open-hours-count', customerAccountId],
queryFn: async () => (await customerAdminService.listHourEntries(customerAccountId, 'unbilled')).length,
enabled: !!flags.hoursLogging && canCombine,
staleTime: 30_000,
});
const openItems = items.filter((r) => r.status === 'open');
const onSuccess = (invoiceId: number, msg: string) => {
toast.success(msg);
qc.invalidateQueries({ queryKey: ['customer-rebills', customerAccountId] });
qc.invalidateQueries({ queryKey: ['customer-invoices', customerAccountId] });
qc.invalidateQueries({ queryKey: ['admin-customer-hour-entries', customerAccountId] });
qc.invalidateQueries({ queryKey: ['customer-open-hours-count', customerAccountId] });
if (invoiceId) navigate(`/admin/clients/bills/${invoiceId}/edit`);
};
const runBill = async (includeHours: boolean) => {
setBusy(true);
try {
if (includeHours) {
const { invoiceId } = await customerAdminService.billCombined(customerAccountId, { includeHours: true, includeRebills: true });
onSuccess(invoiceId, t('rebills.toast.billedCombined', 'Invoice created from re-bills and hours.'));
} else {
const { invoiceId } = await accountingService.billPendingRebills(customerAccountId);
onSuccess(invoiceId, t('rebills.toast.billed', 'Invoice created from re-bills.'));
}
setCrossAddOpen(false);
} catch (e: any) {
toast.error(e?.response?.data?.error || t('rebills.toast.billFailed', 'Failed to create invoice'));
} finally {
setBusy(false);
}
};
const handleCreateInvoice = () => {
// Offer to fold in open hours only when the customer has both AND the admin
// can create the combined invoice (customers.edit); otherwise bill the
// re-bills directly.
if (openHours > 0 && canCombine) setCrossAddOpen(true);
else runBill(false);
};
// No accounting.view → don't render an empty card (query is disabled too).
if (!canView) return null;
return (
<Card padding="lg">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
<Repeat2 className="w-5 h-5" /> {t('customers.detail.rebillsSection', 'Re-bills & passthrough')}
</h2>
{openItems.length > 0 && canManage && (
<Button size="sm" disabled={busy} onClick={handleCreateInvoice}>
<Plus className="w-4 h-4 mr-1" />{t('rebills.createInvoice', 'Create invoice from re-bills')}
</Button>
)}
</div>
{isLoading ? <Loading /> : items.length === 0 ? (
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('customers.detail.noRebills', 'No re-billed or passed-through supplier invoices for this customer yet.')}
</p>
) : (
<div className="space-y-4">
{REBILL_STATUS_ORDER.map((status) => {
const group = items.filter((r) => r.status === status);
if (group.length === 0) return null;
return (
<div key={status}>
<h3 className="text-xs font-medium uppercase tracking-wider text-neutral-500 dark:text-neutral-400 mb-1">
{t(`rebills.status.${status}`, status)} · {group.length}
</h3>
<ul className="divide-y divide-neutral-200 dark:divide-neutral-700">
{group.map((r) => (
<li key={r.id} className="py-2 flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="text-sm text-neutral-900 dark:text-neutral-100 truncate">
{r.supplierName || t('rebills.unknownSupplier', 'Supplier')}
<span className="ml-2 text-xs px-1.5 py-0.5 rounded bg-neutral-100 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-300">
{r.mode === 'passthrough' ? t('rebills.mode.passthrough', 'Passthrough') : t('rebills.mode.rebill', 'Re-bill')}
</span>
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400 truncate">
{r.date ? fmtDate(r.date) : ''}
{r.eventName ? ` · ${r.eventName}` : ''}
{r.invoiceNumber ? (
<>
{' · '}
<Link to={`/admin/clients/bills/${r.invoiceId}`} className="hover:underline font-mono">{r.invoiceNumber}</Link>
</>
) : ''}
</div>
{r.proofAttachError && (
<div className="mt-0.5 flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
<AlertTriangle className="w-3 h-3 shrink-0" />
{t('rebills.proofError', 'Proof not attached: {{err}}', { err: r.proofAttachError })}
</div>
)}
</div>
<div className="text-right shrink-0">
<div className="text-sm tabular-nums text-neutral-900 dark:text-neutral-100">
{formatMoney(r.rebilledMinor / 100, r.currency)}
</div>
{r.rebilledMinor !== r.costMinor && (
<div className="text-xs text-neutral-400 dark:text-neutral-500 tabular-nums">
{t('rebills.costLabel', 'cost {{amount}}', { amount: formatMoney(r.costMinor / 100, r.currency) })}
</div>
)}
</div>
</li>
))}
</ul>
</div>
);
})}
</div>
)}
<CrossAddInvoiceDialog
open={crossAddOpen}
primary="rebills"
otherCount={openHours}
busy={busy}
onConfirm={runBill}
onClose={() => setCrossAddOpen(false)}
/>
</Card>
);
};
+64 -17
View File
@@ -22,10 +22,14 @@ import { Button, Card, LocalizedDateInput, TimeField } from '../common';
import { DecimalInput } from '../common/DecimalInput'; import { DecimalInput } from '../common/DecimalInput';
import { parseLocaleDecimal, parseDuration } from '../../utils/parsers'; import { parseLocaleDecimal, parseDuration } from '../../utils/parsers';
import { customerAdminService } from '../../services/customerAdmin.service'; import { customerAdminService } from '../../services/customerAdmin.service';
import { accountingService } from '../../services/accounting.service';
import { businessProfileService } from '../../services/businessProfile.service'; import { businessProfileService } from '../../services/businessProfile.service';
import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
import { usePermission } from '../../hooks/usePermission';
import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { useMutationWithToast } from '../../hooks'; import { useMutationWithToast } from '../../hooks';
import { ProjectSelect } from './ProjectSelect'; import { ProjectSelect } from './ProjectSelect';
import { CrossAddInvoiceDialog } from './CrossAddInvoiceDialog';
export interface HoursSectionProps { export interface HoursSectionProps {
customerId: number; customerId: number;
@@ -49,6 +53,9 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
const { flags } = useFeatureFlags();
// Billing hours (and the combined path) go through customers.edit (#866 review).
const canBill = usePermission('customers.edit');
const { format: fmtDate, formatTime: fmtTime } = useLocalizedDate(); const { format: fmtDate, formatTime: fmtTime } = useLocalizedDate();
const [entryDate, setEntryDate] = useState(() => new Date().toISOString().slice(0, 10)); const [entryDate, setEntryDate] = useState(() => new Date().toISOString().slice(0, 10));
const [startTime, setStartTime] = useState('09:00'); const [startTime, setStartTime] = useState('09:00');
@@ -159,20 +166,51 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
errorMessage: 'Failed to delete entry', errorMessage: 'Failed to delete entry',
}); });
const billMutation = useMutation({ // Open re-bills count for the cross-add offer (#866) — only when the
mutationFn: () => customerAdminService.billUnbilledHourEntries(customerId), // incoming-invoices feature is on and the admin can create the invoice.
onSuccess: ({ invoiceId }) => { const { data: openRebills = 0 } = useQuery({
qc.invalidateQueries({ queryKey: ['admin-customer-hour-entries', customerId] }); queryKey: ['customer-open-rebills-count', customerId],
qc.invalidateQueries({ queryKey: ['admin-customer', customerId] }); queryFn: async () => (await accountingService.listCustomerRebills(customerId)).filter((r) => r.status === 'open').length,
toast.success(t('customers.hours.toast.billed', 'Hours billed')); enabled: !!flags.incomingInvoices && canBill,
// Open the new scheduled invoice so the admin can add other line staleTime: 30_000,
// items in addition to the hours before it ships.
if (invoiceId) navigate(`/admin/clients/bills/${invoiceId}/edit`);
},
onError: (err: any) => {
toast.error(err?.response?.data?.error || 'Failed to bill hours');
},
}); });
const [crossAddOpen, setCrossAddOpen] = useState(false);
const [billBusy, setBillBusy] = useState(false);
const onBilled = (invoiceId: number, msg: string) => {
qc.invalidateQueries({ queryKey: ['admin-customer-hour-entries', customerId] });
qc.invalidateQueries({ queryKey: ['admin-customer', customerId] });
qc.invalidateQueries({ queryKey: ['customer-rebills', customerId] });
qc.invalidateQueries({ queryKey: ['customer-open-rebills-count', customerId] });
toast.success(msg);
// Open the new scheduled invoice so the admin can add other line
// items in addition to the hours before it ships.
if (invoiceId) navigate(`/admin/clients/bills/${invoiceId}/edit`);
};
const runBill = async (includeRebills: boolean) => {
setBillBusy(true);
try {
if (includeRebills) {
const { invoiceId } = await customerAdminService.billCombined(customerId, { includeHours: true, includeRebills: true });
onBilled(invoiceId, t('customers.hours.toast.billedCombined', 'Invoice created from hours and re-bills.'));
} else {
const { invoiceId } = await customerAdminService.billUnbilledHourEntries(customerId);
onBilled(invoiceId, t('customers.hours.toast.billed', 'Hours billed'));
}
setCrossAddOpen(false);
} catch (err: any) {
toast.error(err?.response?.data?.error || 'Failed to bill hours');
} finally {
setBillBusy(false);
}
};
const handleBillHours = () => {
// Offer to fold in open re-bills when the customer has both (Feature 3).
if (openRebills > 0) setCrossAddOpen(true);
else runBill(false);
};
// Single pass — both the count and the money total live behind the // Single pass — both the count and the money total live behind the
// same filter. Memoised so a parent re-render (e.g. the // same filter. Memoised so a parent re-render (e.g. the
@@ -383,7 +421,7 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
{/* Bill-these-hours button for per-event customers only. Stays {/* Bill-these-hours button for per-event customers only. Stays
visible in compact mode so the customer-detail page can visible in compact mode so the customer-detail page can
still trigger the on-demand billing action. */} still trigger the on-demand billing action. */}
{!isMonthly && unbilledCount > 0 && ( {!isMonthly && unbilledCount > 0 && canBill && (
<div className="mb-4 flex items-center justify-between bg-blue-50 dark:bg-blue-900/20 rounded p-3"> <div className="mb-4 flex items-center justify-between bg-blue-50 dark:bg-blue-900/20 rounded p-3">
<span className="text-sm"> <span className="text-sm">
{t('customers.hours.unbilledCount', {t('customers.hours.unbilledCount',
@@ -395,15 +433,24 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
</span> </span>
<Button <Button
variant="primary" variant="primary"
disabled={billMutation.isPending} disabled={billBusy}
isLoading={billMutation.isPending} isLoading={billBusy}
onClick={() => billMutation.mutate()} onClick={handleBillHours}
> >
{t('customers.hours.billButton', 'Create draft invoice')} {t('customers.hours.billButton', 'Create draft invoice')}
</Button> </Button>
</div> </div>
)} )}
<CrossAddInvoiceDialog
open={crossAddOpen}
primary="hours"
otherCount={openRebills}
busy={billBusy}
onConfirm={runBill}
onClose={() => setCrossAddOpen(false)}
/>
{/* Entry list table. */} {/* Entry list table. */}
{isLoading ? ( {isLoading ? (
<p className="text-sm text-muted-theme">{t('common.loading', 'Loading…')}</p> <p className="text-sm text-muted-theme">{t('common.loading', 'Loading…')}</p>
@@ -11,6 +11,7 @@ import { Save } from 'lucide-react';
import { Button, Card, CardContent, Input, Loading } from '../../../components/common'; import { Button, Card, CardContent, Input, Loading } from '../../../components/common';
import { DecimalInput } from '../../../components/common/DecimalInput'; import { DecimalInput } from '../../../components/common/DecimalInput';
import { accountingService } from '../../../services/accounting.service'; import { accountingService } from '../../../services/accounting.service';
import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
import { businessProfileService } from '../../../services/businessProfile.service'; import { businessProfileService } from '../../../services/businessProfile.service';
import { vatCodesService } from '../../../services/vatCodes.service'; import { vatCodesService } from '../../../services/vatCodes.service';
import { sortedCountryOptions } from '../../../constants/countries'; import { sortedCountryOptions } from '../../../constants/countries';
@@ -23,6 +24,7 @@ const inputCls = 'w-full max-w-xs rounded-md border border-neutral-300 dark:bord
export const AccountingTab: React.FC = () => { export const AccountingTab: React.FC = () => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const { flags } = useFeatureFlags();
const { data, isLoading } = useQuery({ queryKey: ['accounting-settings'], queryFn: () => accountingService.getSettings() }); const { data, isLoading } = useQuery({ queryKey: ['accounting-settings'], queryFn: () => accountingService.getSettings() });
const { data: outputVatCodes = [] } = useQuery({ queryKey: ['vat-codes', 'output'], queryFn: () => vatCodesService.listOutput() }); const { data: outputVatCodes = [] } = useQuery({ queryKey: ['vat-codes', 'output'], queryFn: () => vatCodesService.listOutput() });
// VAT label + default hourly rate live on business_profile, surfaced here so // VAT label + default hourly rate live on business_profile, surfaced here so
@@ -33,6 +35,8 @@ export const AccountingTab: React.FC = () => {
const [perDiemMajor, setPerDiemMajor] = useState<number>(NaN); const [perDiemMajor, setPerDiemMajor] = useState<number>(NaN);
const [hourlyMajor, setHourlyMajor] = useState<number>(NaN); const [hourlyMajor, setHourlyMajor] = useState<number>(NaN);
const [requireProof, setRequireProof] = useState(false); const [requireProof, setRequireProof] = useState(false);
const [rebillAttachProof, setRebillAttachProof] = useState(false);
const [rebillProofNameFormat, setRebillProofNameFormat] = useState('');
const [vatRegistered, setVatRegistered] = useState(false); const [vatRegistered, setVatRegistered] = useState(false);
const [reclaimCountries, setReclaimCountries] = useState<string[]>([]); const [reclaimCountries, setReclaimCountries] = useState<string[]>([]);
const [defaultOutputVatCode, setDefaultOutputVatCode] = useState(''); const [defaultOutputVatCode, setDefaultOutputVatCode] = useState('');
@@ -43,6 +47,8 @@ export const AccountingTab: React.FC = () => {
setKmMajor(data.accounting_km_rate_minor / 100); setKmMajor(data.accounting_km_rate_minor / 100);
setPerDiemMajor(data.accounting_per_diem_rate_minor / 100); setPerDiemMajor(data.accounting_per_diem_rate_minor / 100);
setRequireProof(data.accounting_require_proof); setRequireProof(data.accounting_require_proof);
setRebillAttachProof(data.accounting_rebill_attach_proof);
setRebillProofNameFormat(data.crm_rebill_proof_filename_format || '');
setVatRegistered(data.accounting_vat_registered); setVatRegistered(data.accounting_vat_registered);
setReclaimCountries(data.accounting_vat_reclaim_countries || []); setReclaimCountries(data.accounting_vat_reclaim_countries || []);
setDefaultOutputVatCode(data.accounting_default_output_vat_code || ''); setDefaultOutputVatCode(data.accounting_default_output_vat_code || '');
@@ -66,6 +72,8 @@ export const AccountingTab: React.FC = () => {
accounting_km_rate_minor: Number.isFinite(kmMajor) ? Math.round(kmMajor * 100) : 0, accounting_km_rate_minor: Number.isFinite(kmMajor) ? Math.round(kmMajor * 100) : 0,
accounting_per_diem_rate_minor: Number.isFinite(perDiemMajor) ? Math.round(perDiemMajor * 100) : 0, accounting_per_diem_rate_minor: Number.isFinite(perDiemMajor) ? Math.round(perDiemMajor * 100) : 0,
accounting_require_proof: requireProof, accounting_require_proof: requireProof,
accounting_rebill_attach_proof: rebillAttachProof,
crm_rebill_proof_filename_format: rebillProofNameFormat.trim(),
accounting_vat_registered: vatRegistered, accounting_vat_registered: vatRegistered,
accounting_vat_reclaim_countries: reclaimCountries, accounting_vat_reclaim_countries: reclaimCountries,
accounting_default_output_vat_code: defaultOutputVatCode, accounting_default_output_vat_code: defaultOutputVatCode,
@@ -112,6 +120,20 @@ export const AccountingTab: React.FC = () => {
<input type="checkbox" checked={requireProof} onChange={(e) => setRequireProof(e.target.checked)} className="rounded border-neutral-300" /> <input type="checkbox" checked={requireProof} onChange={(e) => setRequireProof(e.target.checked)} className="rounded border-neutral-300" />
{t('settings.accounting.requireProof', 'Require a proof file on every expense')} {t('settings.accounting.requireProof', 'Require a proof file on every expense')}
</label> </label>
{flags.incomingInvoices && (
<div>
<label className="flex items-start gap-2 text-sm text-neutral-800 dark:text-neutral-200">
<input type="checkbox" checked={rebillAttachProof} onChange={(e) => setRebillAttachProof(e.target.checked)} className="mt-0.5 rounded border-neutral-300" />
<span>{t('settings.accounting.rebillAttachProof', 'Attach the supplier proof to re-billed invoices by default')}</span>
</label>
<p className="mt-1 ml-6 text-xs text-neutral-500 dark:text-neutral-400">{t('settings.accounting.rebillAttachProofHint', 'When a captured supplier invoice is re-billed or passed through, attach its stored PDF to the client-invoice email as a separate proof. This is the default — a per-customer override and a per-file choice in the Send dialog can change it each time.')}</p>
<div className="mt-3 ml-6">
<label className={labelCls}>{t('settings.accounting.rebillProofNameFormat', 'Proof filename format')}</label>
<Input value={rebillProofNameFormat} onChange={(e) => setRebillProofNameFormat(e.target.value)} placeholder="Beleg-{INVOICE}" className="max-w-xs" />
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{t('settings.accounting.rebillProofNameFormatHint', 'Filename for the attached proof PDF. Tokens: {INVOICE}, {SUPPLIER}, {YEAR}, {MONTH}, {SEQ} (or {SEQ:03d}). Leave blank for the default “Beleg-{INVOICE}”. When several proofs ride one invoice, an index is appended automatically. Keep a prefix like “Beleg-” so the proof isnt named identically to the invoice PDF.')}</p>
</div>
</div>
)}
<p className="text-xs text-amber-600 dark:text-amber-400">{t('settings.accounting.disclaimer', 'Rates and VAT/tax treatment are guidance only — verify with your Treuhaender.')}</p> <p className="text-xs text-amber-600 dark:text-amber-400">{t('settings.accounting.disclaimer', 'Rates and VAT/tax treatment are guidance only — verify with your Treuhaender.')}</p>
</CardContent></Card> </CardContent></Card>
+59 -5
View File
@@ -2042,7 +2042,11 @@
"hourlyRate": "Standard-Stundensatz", "hourlyRate": "Standard-Stundensatz",
"hourlyRatePlaceholder": "z. B. 120.00", "hourlyRatePlaceholder": "z. B. 120.00",
"hourlyRateHint": "Verrechnungs-Fallback, wenn ein Kunde keinen eigenen Satz hat (Stundenerfassung). In {{currency}}, in Hauptwährungseinheiten. Leer lassen, um einen Satz pro Kunde oder pro Eintrag zu verlangen." "hourlyRateHint": "Verrechnungs-Fallback, wenn ein Kunde keinen eigenen Satz hat (Stundenerfassung). In {{currency}}, in Hauptwährungseinheiten. Leer lassen, um einen Satz pro Kunde oder pro Eintrag zu verlangen."
} },
"rebillAttachProof": "Lieferantenbeleg bei Weiterverrechnungen standardmäßig anhängen",
"rebillAttachProofHint": "Wenn eine erfasste Lieferantenrechnung weiterverrechnet oder durchlaufend berechnet wird, wird ihr gespeichertes PDF der Rechnungs-E-Mail als separater Beleg beigefügt. Dies ist die Voreinstellung — eine kundenspezifische Einstellung und eine Auswahl pro Datei im Senden-Dialog können sie jederzeit ändern.",
"rebillProofNameFormat": "Dateiname-Format für Belege",
"rebillProofNameFormatHint": "Dateiname des angehängten Beleg-PDFs. Platzhalter: {INVOICE}, {SUPPLIER}, {YEAR}, {MONTH}, {SEQ} (oder {SEQ:03d}). Leer lassen für den Standard „Beleg-{INVOICE}“. Bei mehreren Belegen pro Rechnung wird automatisch ein Index angehängt. Behalten Sie ein Präfix wie „Beleg-“, damit der Beleg nicht genauso heißt wie das Rechnungs-PDF."
}, },
"slideshow": { "slideshow": {
"title": "Diashow", "title": "Diashow",
@@ -4119,7 +4123,8 @@
"toast": { "toast": {
"created": "Eintrag erfasst", "created": "Eintrag erfasst",
"deleted": "Eintrag gelöscht", "deleted": "Eintrag gelöscht",
"billed": "Stunden verrechnet" "billed": "Stunden verrechnet",
"billedCombined": "Rechnung aus Stunden und Weiterverrechnungen erstellt."
}, },
"noRate": { "noRate": {
"title": "Kein Stundensatz hinterlegt", "title": "Kein Stundensatz hinterlegt",
@@ -4272,7 +4277,9 @@
"contractsSection": "Verträge", "contractsSection": "Verträge",
"noContracts": "Noch keine Verträge für diesen Kunden.", "noContracts": "Noch keine Verträge für diesen Kunden.",
"billsSection": "Rechnungen", "billsSection": "Rechnungen",
"noBills": "Noch keine Rechnungen für diesen Kunden." "noBills": "Noch keine Rechnungen für diesen Kunden.",
"rebillsSection": "Weiterverrechnungen & Durchlaufposten",
"noRebills": "Noch keine weiterverrechneten oder durchlaufenden Lieferantenrechnungen für diesen Kunden."
}, },
"billing": { "billing": {
"section": "Abrechnungsrhythmus", "section": "Abrechnungsrhythmus",
@@ -4297,7 +4304,12 @@
"title": "Offen für die Rechnung dieses Monats", "title": "Offen für die Rechnung dieses Monats",
"titleManual": "Offen wird auf manuelle Auslösung versendet", "titleManual": "Offen wird auf manuelle Auslösung versendet",
"periodRange": "{{number}} · {{from}} {{to}}" "periodRange": "{{number}} · {{from}} {{to}}"
} },
"rebillAttachProof": "Lieferantenbeleg bei Weiterverrechnungen anhängen",
"rebillAttachProofInherit": "Mandanten-Standard verwenden",
"rebillAttachProofOn": "Immer anhängen",
"rebillAttachProofOff": "Nie anhängen",
"rebillAttachProofHint": "Überschreibt die globale Voreinstellung für diesen Kunden. Der Senden-Dialog erlaubt weiterhin die Auswahl einzelner Belege bei jedem Versand."
}, },
"reactivate": { "reactivate": {
"button": "Reaktivieren", "button": "Reaktivieren",
@@ -4483,7 +4495,8 @@
"pendingCount_other": "{{count}} Posten", "pendingCount_other": "{{count}} Posten",
"billPending": "Verrechnen", "billPending": "Verrechnen",
"bundledToast": "{{count}} Weiterverrechnung zu einer Rechnung gebündelt.", "bundledToast": "{{count}} Weiterverrechnung zu einer Rechnung gebündelt.",
"bundledToast_other": "{{count}} Weiterverrechnungen zu einer Rechnung gebündelt." "bundledToast_other": "{{count}} Weiterverrechnungen zu einer Rechnung gebündelt.",
"amountRequired": "Rechnungsbetrag vor der Weiterverrechnung eingeben (0 ist erlaubt)."
}, },
"expense": { "expense": {
"kind": "Art", "kind": "Art",
@@ -5213,6 +5226,18 @@
"swiss": "Swiss QR-Bill", "swiss": "Swiss QR-Bill",
"epc": "EPC QR (SEPA)", "epc": "EPC QR (SEPA)",
"profileDefault": "Standard aus Geschäftsprofil verwenden" "profileDefault": "Standard aus Geschäftsprofil verwenden"
},
"send": {
"title": "Rechnung senden",
"proofIntro": "Diese Rechnung verrechnet erfasste Lieferantenrechnungen weiter. Wählen Sie, welche Lieferantenbelege der E-Mail beigefügt werden — die Rechnung als PDF wird immer angehängt.",
"proofsLabel": "Lieferantenbelege",
"selectAll": "Alle auswählen",
"selectNone": "Keine",
"unknownSupplier": "Lieferant",
"modePassthrough": "durchlaufend",
"modeRebill": "Weiterverrechnung",
"noProofFile": "Keine gespeicherte Belegdatei",
"sendWithCount": "Mit {{count}} Beleg(en) senden"
} }
}, },
"businessProfile": { "businessProfile": {
@@ -5784,5 +5809,34 @@
"syncBusy": "Es läuft bereits eine Synchronisierung.", "syncBusy": "Es läuft bereits eine Synchronisierung.",
"syncFailed": "Synchronisierung fehlgeschlagen.", "syncFailed": "Synchronisierung fehlgeschlagen.",
"actionFailed": "Aktion fehlgeschlagen." "actionFailed": "Aktion fehlgeschlagen."
},
"rebills": {
"createInvoice": "Rechnung aus Weiterverrechnungen erstellen",
"unknownSupplier": "Lieferant",
"costLabel": "Kosten {{amount}}",
"proofError": "Beleg nicht angehängt: {{err}}",
"status": {
"open": "Offen",
"sent": "Versendet",
"paid": "Bezahlt"
},
"mode": {
"passthrough": "Durchlaufend",
"rebill": "Weiterverrechnung"
},
"toast": {
"billed": "Rechnung aus Weiterverrechnungen erstellt.",
"billedCombined": "Rechnung aus Weiterverrechnungen und Stunden erstellt.",
"billFailed": "Rechnung konnte nicht erstellt werden"
}
},
"crossAdd": {
"title": "Weitere offene Posten hinzufügen?",
"body": "Dieser Kunde hat außerdem {{count}} {{label}}. Zur selben Rechnung hinzufügen? Sie bleiben eine separate Gruppe — Stunden und Weiterverrechnungen werden nie in einer Position vermischt.",
"addBoth": "Beide hinzufügen",
"hours": "offene Stunden",
"rebills": "offene Weiterverrechnungen",
"hoursOnly": "Nur die Stunden",
"rebillsOnly": "Nur die Weiterverrechnungen"
} }
} }
+59 -5
View File
@@ -1587,7 +1587,11 @@
"hourlyRate": "Default hourly rate", "hourlyRate": "Default hourly rate",
"hourlyRatePlaceholder": "e.g. 120.00", "hourlyRatePlaceholder": "e.g. 120.00",
"hourlyRateHint": "Billing fallback used when a customer has no own rate (hours logging). In {{currency}}, major units. Leave blank to require a per-customer or per-entry rate." "hourlyRateHint": "Billing fallback used when a customer has no own rate (hours logging). In {{currency}}, major units. Leave blank to require a per-customer or per-entry rate."
} },
"rebillAttachProof": "Attach the supplier proof to re-billed invoices by default",
"rebillAttachProofHint": "When a captured supplier invoice is re-billed or passed through, attach its stored PDF to the client-invoice email as a separate proof. This is the default — a per-customer override and a per-file choice in the Send dialog can change it each time.",
"rebillProofNameFormat": "Proof filename format",
"rebillProofNameFormatHint": "Filename for the attached proof PDF. Tokens: {INVOICE}, {SUPPLIER}, {YEAR}, {MONTH}, {SEQ} (or {SEQ:03d}). Leave blank for the default “Beleg-{INVOICE}”. When several proofs ride one invoice, an index is appended automatically. Keep a prefix like “Beleg-” so the proof isnt named identically to the invoice PDF."
}, },
"slideshow": { "slideshow": {
"title": "Slideshow", "title": "Slideshow",
@@ -4119,7 +4123,8 @@
"toast": { "toast": {
"created": "Entry logged", "created": "Entry logged",
"deleted": "Entry deleted", "deleted": "Entry deleted",
"billed": "Hours billed" "billed": "Hours billed",
"billedCombined": "Invoice created from hours and re-bills."
}, },
"noRate": { "noRate": {
"title": "No hourly rate configured", "title": "No hourly rate configured",
@@ -4272,7 +4277,9 @@
"noContracts": "No contracts for this customer yet.", "noContracts": "No contracts for this customer yet.",
"billsSection": "Invoices", "billsSection": "Invoices",
"noBills": "No invoices for this customer yet.", "noBills": "No invoices for this customer yet.",
"manageEvents": "Manage galleries" "manageEvents": "Manage galleries",
"rebillsSection": "Re-bills & passthrough",
"noRebills": "No re-billed or passed-through supplier invoices for this customer yet."
}, },
"billing": { "billing": {
"section": "Billing cadence", "section": "Billing cadence",
@@ -4297,7 +4304,12 @@
"title": "Pending in this month's bill", "title": "Pending in this month's bill",
"titleManual": "Pending — ships on manual trigger", "titleManual": "Pending — ships on manual trigger",
"periodRange": "{{number}} · {{from}} {{to}}" "periodRange": "{{number}} · {{from}} {{to}}"
} },
"rebillAttachProof": "Attach supplier proof to re-billed invoices",
"rebillAttachProofInherit": "Use tenant default",
"rebillAttachProofOn": "Always attach",
"rebillAttachProofOff": "Never attach",
"rebillAttachProofHint": "Overrides the global default for this customer. The Send dialog still lets you pick individual proofs each time an invoice goes out."
}, },
"reactivate": { "reactivate": {
"button": "Reactivate", "button": "Reactivate",
@@ -4483,7 +4495,8 @@
"pendingCount_other": "{{count}} items", "pendingCount_other": "{{count}} items",
"billPending": "Bill these", "billPending": "Bill these",
"bundledToast": "Bundled {{count}} re-bill into one invoice.", "bundledToast": "Bundled {{count}} re-bill into one invoice.",
"bundledToast_other": "Bundled {{count}} re-bills into one invoice." "bundledToast_other": "Bundled {{count}} re-bills into one invoice.",
"amountRequired": "Enter the invoice amount before re-billing (0 is allowed)."
}, },
"expense": { "expense": {
"kind": "Type", "kind": "Type",
@@ -5211,6 +5224,18 @@
"overdue": "Overdue", "overdue": "Overdue",
"cancelled": "Cancelled", "cancelled": "Cancelled",
"skipped": "Skipped (empty month)" "skipped": "Skipped (empty month)"
},
"send": {
"title": "Send invoice",
"proofIntro": "This invoice re-bills captured supplier invoices. Choose which supplier proofs to attach to the email — the invoice PDF is always attached.",
"proofsLabel": "Supplier proofs",
"selectAll": "Select all",
"selectNone": "None",
"unknownSupplier": "Supplier",
"modePassthrough": "passthrough",
"modeRebill": "re-bill",
"noProofFile": "No stored proof file",
"sendWithCount": "Send with {{count}} proof(s)"
} }
}, },
"businessProfile": { "businessProfile": {
@@ -5782,5 +5807,34 @@
"syncBusy": "A sync is already running.", "syncBusy": "A sync is already running.",
"syncFailed": "Sync failed.", "syncFailed": "Sync failed.",
"actionFailed": "Action failed." "actionFailed": "Action failed."
},
"rebills": {
"createInvoice": "Create invoice from re-bills",
"unknownSupplier": "Supplier",
"costLabel": "cost {{amount}}",
"proofError": "Proof not attached: {{err}}",
"status": {
"open": "Open",
"sent": "Sent",
"paid": "Paid"
},
"mode": {
"passthrough": "Passthrough",
"rebill": "Re-bill"
},
"toast": {
"billed": "Invoice created from re-bills.",
"billedCombined": "Invoice created from re-bills and hours.",
"billFailed": "Failed to create invoice"
}
},
"crossAdd": {
"title": "Add other open items?",
"body": "This customer also has {{count}} {{label}}. Add them to the same invoice? They stay as a separate group — hours and re-bills are never mixed into one line.",
"addBoth": "Add both",
"hours": "open hours",
"rebills": "open re-bills",
"hoursOnly": "Just the hours",
"rebillsOnly": "Just the re-bills"
} }
} }
@@ -41,7 +41,7 @@ type EditableFields =
| 'addressLine1' | 'addressLine2' | 'postalCode' | 'city' | 'state' | 'addressLine1' | 'addressLine2' | 'postalCode' | 'city' | 'state'
| 'countryCode' | 'countryName' | 'preferredLanguage' | 'notes' | 'countryCode' | 'countryName' | 'preferredLanguage' | 'notes'
| 'featureCalendar' | 'featureQuotes' | 'featureBills' | 'featureHoursLogging' | 'featureContracts' | 'featureCalendar' | 'featureQuotes' | 'featureBills' | 'featureHoursLogging' | 'featureContracts'
| 'hourlyRateMinor' | 'billingCadence' | 'billingCycleDay' | 'skontoDisabled'; | 'hourlyRateMinor' | 'billingCadence' | 'billingCycleDay' | 'skontoDisabled' | 'rebillAttachProof';
// `fmtDate` (from useLocalizedDate, below) is the single canonical date // `fmtDate` (from useLocalizedDate, below) is the single canonical date
// formatter. It honors the admin's `general_date_format` setting AND // formatter. It honors the admin's `general_date_format` setting AND
@@ -145,6 +145,9 @@ export const CustomerDetailPage: React.FC = () => {
billingCadence: customer.billingCadence ?? 'per_event', billingCadence: customer.billingCadence ?? 'per_event',
billingCycleDay: customer.billingCycleDay ?? 1, billingCycleDay: customer.billingCycleDay ?? 1,
skontoDisabled: customer.skontoDisabled ?? false, skontoDisabled: customer.skontoDisabled ?? false,
// Tri-state (null = inherit global). Kept as-is so the select can show
// "Inherit" distinctly from an explicit on/off (#866).
rebillAttachProof: customer.rebillAttachProof ?? null,
} as any); } as any);
} }
}, [customer, form]); }, [customer, form]);
@@ -751,6 +754,32 @@ export const CustomerDetailPage: React.FC = () => {
</span> </span>
</label> </label>
{/* Per-customer re-bill proof-attachment override (#866). Tri-state:
inherit the tenant default, or force on/off for this client. */}
{flags.incomingInvoices && (
<div className="mt-4">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('customers.billing.rebillAttachProof', 'Attach supplier proof to re-billed invoices')}
</label>
<select
value={form.rebillAttachProof == null ? 'inherit' : (form.rebillAttachProof ? 'on' : 'off')}
onChange={(e) => {
const v = e.target.value;
setForm((prev) => ({ ...prev, rebillAttachProof: v === 'inherit' ? null : v === 'on' } as any));
}}
className="w-full max-w-xs rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-neutral-900 dark:text-neutral-100"
>
<option value="inherit">{t('customers.billing.rebillAttachProofInherit', 'Use tenant default')}</option>
<option value="on">{t('customers.billing.rebillAttachProofOn', 'Always attach')}</option>
<option value="off">{t('customers.billing.rebillAttachProofOff', 'Never attach')}</option>
</select>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
{t('customers.billing.rebillAttachProofHint',
'Overrides the global default for this customer. The Send dialog still lets you pick individual proofs each time an invoice goes out.')}
</p>
</div>
)}
{/* Preview of the open monthly draft (migration 128). Shows {/* Preview of the open monthly draft (migration 128). Shows
every line item queued for the customer's current billing every line item queued for the customer's current billing
period so admin sees exactly what "Trigger invoice now" period so admin sees exactly what "Trigger invoice now"
@@ -229,6 +229,12 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[
}); });
const rebillNeedsCustomer = disposition === 'rebill' && !customer[0]; const rebillNeedsCustomer = disposition === 'rebill' && !customer[0];
// A doc attached to a customer becomes a client invoice line, which needs an
// amount. 0 is a valid amount (a zero-value pass-through); an EMPTY field
// (totalMinor === null) is not — block it here so it can't dead-end later at
// billing. Only enforced when it's actually being billed to a customer.
const rebillNeedsAmount = BOOKING_DISPOSITIONS.includes(disposition) && !!customer[0] && totalMinor == null;
const cannotSave = rebillNeedsCustomer || rebillNeedsAmount;
return ( return (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4"> <div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4">
@@ -308,11 +314,16 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[
)} )}
</div> </div>
</div> </div>
<div className="flex flex-wrap justify-end gap-2 border-t border-neutral-200 dark:border-neutral-700 px-5 py-3"> <div className="flex flex-wrap items-center justify-end gap-2 border-t border-neutral-200 dark:border-neutral-700 px-5 py-3">
{rebillNeedsAmount && (
<span className="mr-auto text-xs text-amber-600 dark:text-amber-400">
{t('accounting.incoming.amountRequired', 'Enter the invoice amount before re-billing (0 is allowed).')}
</span>
)}
<Button variant="outline" onClick={onClose}>{t('common.cancel', 'Cancel')}</Button> <Button variant="outline" onClick={onClose}>{t('common.cancel', 'Cancel')}</Button>
{/* #5: categorize-only OR categorize then continue to mark paid. */} {/* #5: categorize-only OR categorize then continue to mark paid. */}
<Button variant="outline" onClick={() => save.mutate(true)} disabled={save.isPending || rebillNeedsCustomer}>{t('accounting.inbox.saveCategorizePay', 'Save & mark paid')}</Button> <Button variant="outline" onClick={() => save.mutate(true)} disabled={save.isPending || cannotSave}>{t('accounting.inbox.saveCategorizePay', 'Save & mark paid')}</Button>
<Button onClick={() => save.mutate(false)} disabled={save.isPending || rebillNeedsCustomer}>{save.isPending ? t('common.saving', 'Saving…') : t('accounting.inbox.saveCategorize', 'Save')}</Button> <Button onClick={() => save.mutate(false)} disabled={save.isPending || cannotSave}>{save.isPending ? t('common.saving', 'Saving…') : t('accounting.inbox.saveCategorize', 'Save')}</Button>
</div> </div>
</div> </div>
</div> </div>
@@ -11,7 +11,10 @@ import { ArrowLeft, Eye, Send, CheckCircle, BellRing, XCircle, Truck, Edit2, Ref
import { Button, Card, Loading, Input, LocalizedDateInput } from '../../../components/common'; import { Button, Card, Loading, Input, LocalizedDateInput } from '../../../components/common';
import { DocumentLineageCard } from '../../../components/admin/DocumentLineageCard'; import { DocumentLineageCard } from '../../../components/admin/DocumentLineageCard';
import { billsService, isDraftInvoice } from '../../../services/bills.service'; import { billsService, isDraftInvoice } from '../../../services/bills.service';
import { accountingService, type InvoiceRebillProof } from '../../../services/accounting.service';
import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
import { formatMoney } from '../../../components/admin/LineItemsTable'; import { formatMoney } from '../../../components/admin/LineItemsTable';
import { formatMoneyMinor } from '../../../utils/money';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate'; import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -20,6 +23,7 @@ export const BillDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { format: fmtDate } = useLocalizedDate(); const { format: fmtDate } = useLocalizedDate();
const { flags } = useFeatureFlags();
const qc = useQueryClient(); const qc = useQueryClient();
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['invoice', id], queryKey: ['invoice', id],
@@ -41,6 +45,12 @@ export const BillDetailPage: React.FC = () => {
// still override it (e.g. partial Skonto + partial waive). // still override it (e.g. partial Skonto + partial waive).
const [payWithSkonto, setPayWithSkonto] = useState(false); const [payWithSkonto, setPayWithSkonto] = useState(false);
// Send dialog with per-file re-bill proof selection (#866).
const [sendDialogOpen, setSendDialogOpen] = useState(false);
const [sendProofs, setSendProofs] = useState<InvoiceRebillProof[]>([]);
const [selectedProofIds, setSelectedProofIds] = useState<Set<number>>(new Set());
const [sending, setSending] = useState(false);
// Pre-build the line-item rows once per data change. Previously this // Pre-build the line-item rows once per data change. Previously this
// was an inline IIFE inside the JSX, rebuilding the array (and N // was an inline IIFE inside the JSX, rebuilding the array (and N
// <tr> elements) on every render of the page — every payment-dialog // <tr> elements) on every render of the page — every payment-dialog
@@ -124,10 +134,38 @@ export const BillDetailPage: React.FC = () => {
toast.error(err?.response?.data?.error || err.message || 'Preview failed'); toast.error(err?.response?.data?.error || err.message || 'Preview failed');
} }
}; };
// Actually dispatch the send. `proofInboundIds` = the admin's explicit
// re-bill proof picks (empty array = attach none); undefined = no selection,
// let the resolved default decide.
const doSend = async (proofInboundIds?: number[]) => {
setSending(true);
try {
await billsService.send(inv.id, proofInboundIds);
toast.success(t('bills.sentToast', 'Invoice sent.'));
qc.invalidateQueries({ queryKey: ['invoice', id] });
setSendDialogOpen(false);
} catch (e: any) {
toast.error(e?.response?.data?.error || 'Send failed');
} finally {
setSending(false);
}
};
const handleSend = async () => { const handleSend = async () => {
// If this invoice re-bills captured supplier invoices, open the Send dialog
// so the admin can pick which proofs ride the email. Otherwise, plain send.
if (flags.incomingInvoices) {
try {
const { proofs, attachDefault } = await accountingService.getInvoiceRebillProofs(inv.id);
if (proofs.length > 0) {
setSendProofs(proofs);
setSelectedProofIds(new Set(attachDefault ? proofs.filter((p) => p.hasProof).map((p) => p.id) : []));
setSendDialogOpen(true);
return;
}
} catch { /* fall through to the plain confirm+send */ }
}
if (!window.confirm(t('bills.confirmSend', 'Send invoice to customer now?'))) return; if (!window.confirm(t('bills.confirmSend', 'Send invoice to customer now?'))) return;
try { await billsService.send(inv.id); toast.success(t('bills.sentToast', 'Invoice sent.')); qc.invalidateQueries({ queryKey: ['invoice', id] }); } await doSend(undefined);
catch (e: any) { toast.error(e?.response?.data?.error || 'Send failed'); }
}; };
const handleReminder = async () => { const handleReminder = async () => {
if (!window.confirm(t('bills.confirmReminder', 'Send a reminder now?'))) return; if (!window.confirm(t('bills.confirmReminder', 'Send a reminder now?'))) return;
@@ -466,6 +504,73 @@ export const BillDetailPage: React.FC = () => {
)} )}
</Card> </Card>
{sendDialogOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => !sending && setSendDialogOpen(false)}>
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-xl w-full max-w-lg mx-4 p-5"
onClick={(e) => e.stopPropagation()}>
<h3 className="font-semibold mb-1 text-lg text-neutral-900 dark:text-neutral-100">{t('bills.send.title', 'Send invoice')}</h3>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-3">
{t('bills.send.proofIntro', 'This invoice re-bills captured supplier invoices. Choose which supplier proofs to attach to the email — the invoice PDF is always attached.')}
</p>
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium uppercase tracking-wider text-neutral-500 dark:text-neutral-400">
{t('bills.send.proofsLabel', 'Supplier proofs')}
</span>
<div className="flex gap-3 text-xs">
<button type="button" className="text-primary-600 hover:underline"
onClick={() => setSelectedProofIds(new Set(sendProofs.filter((p) => p.hasProof).map((p) => p.id)))}>
{t('bills.send.selectAll', 'Select all')}
</button>
<button type="button" className="text-neutral-500 hover:underline"
onClick={() => setSelectedProofIds(new Set())}>
{t('bills.send.selectNone', 'None')}
</button>
</div>
</div>
<ul className="max-h-64 overflow-y-auto divide-y divide-neutral-200 dark:divide-neutral-700 border border-neutral-200 dark:border-neutral-700 rounded-md">
{sendProofs.map((p) => (
<li key={p.id} className="flex items-center gap-3 px-3 py-2">
<input
type="checkbox"
className="rounded border-neutral-300 dark:border-neutral-600"
disabled={!p.hasProof}
checked={selectedProofIds.has(p.id)}
onChange={(e) => setSelectedProofIds((prev) => {
const next = new Set(prev);
if (e.target.checked) next.add(p.id); else next.delete(p.id);
return next;
})}
/>
<div className="min-w-0 flex-1">
<div className="text-sm text-neutral-900 dark:text-neutral-100 truncate">
{p.supplierName || t('bills.send.unknownSupplier', 'Supplier')}
<span className="ml-2 text-xs text-neutral-500 dark:text-neutral-400">
{p.mode === 'passthrough' ? t('bills.send.modePassthrough', 'passthrough') : t('bills.send.modeRebill', 're-bill')}
</span>
</div>
{p.hasProof ? (
<div className="text-xs text-neutral-500 dark:text-neutral-400 truncate">{p.filename || 'proof.pdf'}</div>
) : (
<div className="text-xs text-amber-600 dark:text-amber-400">{t('bills.send.noProofFile', 'No stored proof file')}</div>
)}
</div>
<span className="text-sm tabular-nums text-neutral-700 dark:text-neutral-300">{formatMoneyMinor(p.amountMinor, p.currency || inv.currency)}</span>
</li>
))}
</ul>
<div className="flex justify-end gap-2 pt-4">
<Button variant="outline" disabled={sending} onClick={() => setSendDialogOpen(false)}>{t('common.cancel', 'Cancel')}</Button>
<Button
disabled={sending}
onClick={() => doSend(sendProofs.filter((p) => p.hasProof && selectedProofIds.has(p.id)).map((p) => p.id))}
>
{t('bills.send.sendWithCount', 'Send with {{count}} proof(s)', { count: sendProofs.filter((p) => p.hasProof && selectedProofIds.has(p.id)).length })}
</Button>
</div>
</div>
</div>
)}
{payDialogOpen && ( {payDialogOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setPayDialogOpen(false)}> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setPayDialogOpen(false)}>
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-xl w-full max-w-md mx-4 p-5" <div className="bg-white dark:bg-neutral-900 rounded-lg shadow-xl w-full max-w-md mx-4 p-5"
@@ -116,6 +116,44 @@ export interface AccountingSettings {
accounting_vat_reclaim_countries: string[]; accounting_vat_reclaim_countries: string[];
/** Output VAT code stamped onto NEW invoices/quotes ('' = none). */ /** Output VAT code stamped onto NEW invoices/quotes ('' = none). */
accounting_default_output_vat_code: string; accounting_default_output_vat_code: string;
/** Global default: attach the stored supplier proof PDF to the client-invoice
* email when a re-bill/passthrough is issued (#866). Off by default; a
* per-customer override and the Send dialog's per-file selection build on it. */
accounting_rebill_attach_proof: boolean;
/** Filename template for the attached proof. Tokens: {INVOICE} {SUPPLIER}
* {YEAR} {MONTH} {SEQ}/{SEQ:0Nd}. '' default 'Beleg-{INVOICE}'. */
crm_rebill_proof_filename_format: string;
}
/** Re-bill / passthrough item for one customer (CRM panel, #866). Status is
* derived from the linked client-invoice lifecycle. */
export interface CustomerRebillItem {
id: number;
supplierName: string | null;
date: string | null;
currency: string | null;
costMinor: number;
rebilledMinor: number;
mode: 'passthrough' | 'rebill';
eventId: number | null;
eventName: string | null;
hasProof: boolean;
proofAttachError: string | null;
status: 'open' | 'sent' | 'paid';
invoiceId: number | null;
invoiceNumber: string | null;
}
/** Re-bill proof attached to a not-yet-sent invoice (Send dialog, #866). */
export interface InvoiceRebillProof {
id: number;
supplierName: string | null;
filename: string | null;
hasProof: boolean;
currency: string | null;
amountMinor: number;
mode: 'passthrough' | 'rebill';
proofAttachError: string | null;
} }
export interface CategorizePayload { export interface CategorizePayload {
@@ -176,6 +214,10 @@ export const accountingService = {
async listPendingRebills(): Promise<PendingRebillSummary[]> { const { data } = await api.get('/admin/expenses/inbound/pending-summary'); return data.items; }, async listPendingRebills(): Promise<PendingRebillSummary[]> { const { data } = await api.get('/admin/expenses/inbound/pending-summary'); return data.items; },
/** Bundle one customer's pending re-bills into a single invoice. */ /** Bundle one customer's pending re-bills into a single invoice. */
async billPendingRebills(customerAccountId: number): Promise<{ invoiceId: number; count: number }> { const { data } = await api.post('/admin/expenses/inbound/bill-pending', { customerAccountId }); return data; }, async billPendingRebills(customerAccountId: number): Promise<{ invoiceId: number; count: number }> { const { data } = await api.post('/admin/expenses/inbound/bill-pending', { customerAccountId }); return data; },
/** Re-bill / passthrough items for a customer, with derived status (#866). */
async listCustomerRebills(customerAccountId: number): Promise<CustomerRebillItem[]> { const { data } = await api.get(`/admin/expenses/inbound/by-customer/${customerAccountId}`); return data.items; },
/** Re-bill proofs on a not-yet-sent invoice + the resolved attach default (#866). */
async getInvoiceRebillProofs(invoiceId: number): Promise<{ proofs: InvoiceRebillProof[]; attachDefault: boolean }> { const { data } = await api.get(`/admin/invoices/${invoiceId}/rebill-proofs`); return data; },
async markInboundPaid(id: number, payload: { paid: boolean; paidAt?: string; paymentMethod?: PaymentMethod; paymentReference?: string }): Promise<InboundDocument> { const { data } = await api.post(`/admin/expenses/inbound/${id}/supplier-payment`, payload); return data.document; }, async markInboundPaid(id: number, payload: { paid: boolean; paidAt?: string; paymentMethod?: PaymentMethod; paymentReference?: string }): Promise<InboundDocument> { const { data } = await api.post(`/admin/expenses/inbound/${id}/supplier-payment`, payload); return data.document; },
async getInboundFileBlob(id: number): Promise<Blob> { const { data } = await api.get(`/admin/expenses/inbound/${id}/file`, { responseType: 'blob' }); return data; }, async getInboundFileBlob(id: number): Promise<Blob> { const { data } = await api.get(`/admin/expenses/inbound/${id}/file`, { responseType: 'blob' }); return data; },
async getInboundPageBlob(id: number, page: number): Promise<Blob> { const { data } = await api.get(`/admin/expenses/inbound/${id}/page/${page}`, { responseType: 'blob' }); return data; }, async getInboundPageBlob(id: number, page: number): Promise<Blob> { const { data } = await api.get(`/admin/expenses/inbound/${id}/page/${page}`, { responseType: 'blob' }); return data; },
@@ -216,6 +258,9 @@ export const accountingService = {
? data.accounting_vat_reclaim_countries : [], ? data.accounting_vat_reclaim_countries : [],
accounting_default_output_vat_code: typeof data.accounting_default_output_vat_code === 'string' accounting_default_output_vat_code: typeof data.accounting_default_output_vat_code === 'string'
? data.accounting_default_output_vat_code : '', ? data.accounting_default_output_vat_code : '',
accounting_rebill_attach_proof: data.accounting_rebill_attach_proof === true,
crm_rebill_proof_filename_format: typeof data.crm_rebill_proof_filename_format === 'string'
? data.crm_rebill_proof_filename_format : '',
}; };
}, },
async updateSettings(payload: Partial<AccountingSettings>): Promise<{ updated: string[] }> { async updateSettings(payload: Partial<AccountingSettings>): Promise<{ updated: string[] }> {
+8 -2
View File
@@ -272,8 +272,14 @@ export const billsService = {
return data.data || data; return data.data || data;
}, },
async send(id: number): Promise<{ sent: true }> { /**
const { data } = await api.post(`/admin/invoices/${id}/send`); * Send the invoice now. `proofInboundIds` (issue #866) is the admin's per-file
* re-bill proof selection from the Send dialog; omit to let the resolved
* per-customer/global default decide all-or-none.
*/
async send(id: number, proofInboundIds?: number[]): Promise<{ sent: true }> {
const { data } = await api.post(`/admin/invoices/${id}/send`,
proofInboundIds !== undefined ? { proofInboundIds } : undefined);
return data.data || data; return data.data || data;
}, },
@@ -67,6 +67,10 @@ export interface CustomerAccountDetail extends CustomerAccountSummary {
* this customer's invoices qualify for an early-payment discount, * this customer's invoices qualify for an early-payment discount,
* regardless of template / global defaults. */ * regardless of template / global defaults. */
skontoDisabled?: boolean; skontoDisabled?: boolean;
/** Per-customer re-bill proof-attachment override (#866). Tri-state:
* null = inherit the global default, true = always attach the supplier
* proof to re-billed invoices, false = never. */
rebillAttachProof?: boolean | null;
notes: string | null; notes: string | null;
events: Array<{ events: Array<{
id: number; id: number;
@@ -168,6 +172,8 @@ export const customerAdminService = {
billingCycleDay: 'billing_cycle_day', billingCycleDay: 'billing_cycle_day',
// Per-customer Skonto opt-out (migration 112). // Per-customer Skonto opt-out (migration 112).
skontoDisabled: 'skonto_disabled', skontoDisabled: 'skonto_disabled',
// Per-customer re-bill proof-attachment override (#866). null clears it.
rebillAttachProof: 'rebill_attach_proof',
}; };
for (const [k, v] of Object.entries(payload)) { for (const [k, v] of Object.entries(payload)) {
if (k in map) snake[map[k]] = v; if (k in map) snake[map[k]] = v;
@@ -344,6 +350,19 @@ export const customerAdminService = {
return (response.data as any).data ?? response.data; return (response.data as any).data ?? response.data;
}, },
/** Combine open hours and/or open re-bills into ONE invoice (#866, Feature 3).
* Hours and re-bills stay as distinct, contiguous line groups. */
async billCombined(
customerId: number,
opts: { includeHours: boolean; includeRebills: boolean },
): Promise<{ invoiceId: number; entriesBilled: number; rebillsBilled: number }> {
const response = await api.post(
`/admin/customers/${customerId}/bill-combined`,
opts,
);
return (response.data as any).data ?? response.data;
},
/** Landing aggregate for /admin/clients/hours every customer that /** Landing aggregate for /admin/clients/hours every customer that
* currently carries unbilled hour entries, with open hours + open * currently carries unbilled hour entries, with open hours + open
* amount (install default currency). Sorted by open amount desc. */ * amount (install default currency). Sorted by open amount desc. */