feat(accounting): re-bill proof attachment, CRM panel & hours↔re-bills cross-add (#979)
Closes #866. Three features, all behind the `incomingInvoices` feature flag: 1. Attach the stored supplier proof PDF to the client-invoice email when a captured invoice is re-billed/passed through, as a SEPARATE attachment so invoice immutability holds. Global default (off), per-customer tri-state override, and per-file selection in a new Send dialog. A missing proof at issue time stamps inbound_documents.proof_attach_error rather than silently dropping, and never blocks the send. Proof filename is a configurable template with {INVOICE} {SUPPLIER} {YEAR} {MONTH} {SEQ}/{SEQ:0Nd} tokens. 2. Re-bills & passthrough panel under CRM → Customer, grouped Open/Sent/Paid with status derived from the linked invoice lifecycle rather than a duplicated column. 3. Cross-add dialog rolling open hours and open re-bills into one invoice, symmetric from both entry points. The two stay distinct, contiguous line groups — never merged into shared line items. Migration 169 is additive, hasColumn-guarded and idempotent. Review (two rounds) closed two concerns: - Storno stranding: nothing cleared inbound_documents.billed_invoice_id when a covering invoice was cancelled, so a Storno'd re-bill showed as Open in the new panel while every billing path filters on that column being NULL — the supplier cost could never be re-billed. releaseRebillsForCancelledInvoice now detaches the linkage on both invoice-cancel paths, with a regression test on the issued-cancel path. - Permission gating: the new controls rendered on data presence alone while their endpoints require accounting.view / accounting.manage / customers.edit. Now gated at both the query and render layers. Known follow-up: two cross-add counter queries are gated on a permission their endpoint does not check (HoursSection.tsx:174, CustomerCrmPanels.tsx:270) — degrades safely, one line each.
This commit is contained in:
@@ -0,0 +1,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'));
|
||||
}
|
||||
};
|
||||
@@ -19,9 +19,13 @@ const { db } = require('../database/db');
|
||||
// frontend already hides the surface). Per-customer enforcement stays in
|
||||
// customerHoursService.createEntry.
|
||||
const requireHoursLogging = requireFeatureFlag('hoursLogging', 'HOURS_LOGGING_DISABLED');
|
||||
// Combined hours+re-bills billing (#866) is introduced by the re-bill feature;
|
||||
// gate it behind incoming-invoices (no re-bills to combine when it's off).
|
||||
const requireIncoming = requireFeatureFlag('incomingInvoices', 'INCOMING_INVOICES_DISABLED');
|
||||
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||||
const customerAccountsService = require('../services/customerAccountsService');
|
||||
const customerHoursService = require('../services/customerHoursService');
|
||||
const combinedBillingService = require('../services/combinedBillingService');
|
||||
const invoiceService = require('../services/invoiceService');
|
||||
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../utils/emailNormalization');
|
||||
|
||||
@@ -83,6 +87,10 @@ function transformCustomer(c) {
|
||||
// this customer's invoices qualify for an early-payment discount,
|
||||
// regardless of template / global defaults.
|
||||
skontoDisabled: c.skonto_disabled === true || c.skonto_disabled === 1,
|
||||
// Per-customer re-bill proof-attachment override (migration 169, #866).
|
||||
// Tri-state: null = inherit the global default, true = always attach,
|
||||
// false = never attach the supplier proof to the client-invoice email.
|
||||
rebillAttachProof: c.rebill_attach_proof == null ? null : (c.rebill_attach_proof === true || c.rebill_attach_proof === 1),
|
||||
lastLogin: c.last_login,
|
||||
createdAt: c.created_at,
|
||||
updatedAt: c.updated_at,
|
||||
@@ -413,6 +421,9 @@ router.put('/:id', [
|
||||
.withMessage('billing_cycle_day must be -15..-1 (days before month end) or 1..28 (day of month)'),
|
||||
// Per-customer Skonto opt-out (migration 112).
|
||||
body('skonto_disabled').optional().isBoolean(),
|
||||
// Per-customer re-bill proof-attachment override (migration 169, #866).
|
||||
// Nullable tri-state: null clears the override (inherit global default).
|
||||
body('rebill_attach_proof').optional({ nullable: true }).isBoolean(),
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const customer = await customerAccountsService.updateCustomer(
|
||||
@@ -685,6 +696,25 @@ router.post('/:id/hour-entries/bill', [
|
||||
successResponse(res, result, 201);
|
||||
}));
|
||||
|
||||
// Combined hours + re-bills → one invoice (#866, Feature 3). Used by the
|
||||
// cross-add dialog when a per-event customer has open items in both categories.
|
||||
router.post('/:id/bill-combined', [
|
||||
adminAuth,
|
||||
requireIncoming,
|
||||
requirePermission('customers.edit'),
|
||||
param('id').isInt({ min: 1 }),
|
||||
body('includeHours').optional().isBoolean(),
|
||||
body('includeRebills').optional().isBoolean(),
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const result = await combinedBillingService.billCombinedForCustomer(
|
||||
parseInt(req.params.id, 10),
|
||||
{ includeHours: req.body.includeHours !== false, includeRebills: req.body.includeRebills !== false },
|
||||
req.admin.id,
|
||||
);
|
||||
successResponse(res, result, 201);
|
||||
}));
|
||||
|
||||
function transformHourEntry(h) {
|
||||
return {
|
||||
id: h.id,
|
||||
|
||||
@@ -112,6 +112,13 @@ router.post('/inbound/bill-pending', requireIncoming, requirePermission('account
|
||||
[body('customerAccountId').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => { validateRequest(req); return successResponse(res, await expenseService.billPendingRebills(toInt(req.body.customerAccountId), req.admin.id), 201, 'Re-billed'); }));
|
||||
|
||||
// Re-bill / passthrough items for one customer, with derived status (open /
|
||||
// sent / paid) — feeds the CRM → Customer panel (#866, Feature 2). Registered
|
||||
// BEFORE /inbound/:id so the literal path wins.
|
||||
router.get('/inbound/by-customer/:customerAccountId', requireIncoming, requirePermission('accounting.view'),
|
||||
[param('customerAccountId').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => { validateRequest(req); return successResponse(res, { items: await expenseService.listCustomerRebills(toInt(req.params.customerAccountId)) }); }));
|
||||
|
||||
router.get('/inbound/:id/file', requireIncoming, requirePermission('accounting.view'),
|
||||
[param('id').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
|
||||
@@ -28,9 +28,13 @@ const { requirePermission } = require('../middleware/permissions');
|
||||
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||||
const { getStoragePath } = require('../config/storage');
|
||||
const invoiceService = require('../services/invoiceService');
|
||||
const expenseService = require('../services/expenseService');
|
||||
const { requireFeatureFlag } = require('../middleware/requireFeatureFlag');
|
||||
const { db } = require('../database/db');
|
||||
|
||||
const router = express.Router();
|
||||
// Re-bill proof endpoints (#866) are behind the incoming-invoices flag.
|
||||
const requireIncoming = requireFeatureFlag('incomingInvoices', 'INCOMING_INVOICES_DISABLED');
|
||||
|
||||
// PR #603 review follow-up #2 — bound payment dates. `isISO8601()` alone
|
||||
// accepts year 1900/9999; cash-basis revenue keys on paid_at, so a typo
|
||||
@@ -753,13 +757,36 @@ router.put(
|
||||
|
||||
// ---- send / pay / remind / cancel ------------------------------------
|
||||
|
||||
router.post(
|
||||
'/:id/send',
|
||||
requirePermission('bills.manage'),
|
||||
// Re-bill proofs attached to this (not-yet-sent) invoice + the resolved attach
|
||||
// default — powers the Send dialog's per-file proof selection (#866). Behind the
|
||||
// incoming-invoices flag; bills.view since it's part of the invoice send flow.
|
||||
router.get(
|
||||
'/:id/rebill-proofs',
|
||||
requireIncoming,
|
||||
requirePermission('bills.view'),
|
||||
[param('id').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
await invoiceService.sendInvoice(parseInt(req.params.id, 10), req.admin.id);
|
||||
return successResponse(res, await expenseService.listInvoiceRebillProofs(parseInt(req.params.id, 10)));
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id/send',
|
||||
requirePermission('bills.manage'),
|
||||
[
|
||||
param('id').isInt({ min: 1 }),
|
||||
// Optional per-file re-bill proof selection (#866). Array of inbound
|
||||
// document ids the admin chose to attach; omitted → resolved default.
|
||||
body('proofInboundIds').optional({ nullable: true }).isArray(),
|
||||
body('proofInboundIds.*').isInt({ min: 1 }),
|
||||
],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const proofInboundIds = Array.isArray(req.body.proofInboundIds)
|
||||
? req.body.proofInboundIds.map((n) => parseInt(n, 10)).filter(Number.isInteger)
|
||||
: undefined;
|
||||
await invoiceService.sendInvoice(parseInt(req.params.id, 10), req.admin.id, { proofInboundIds });
|
||||
return successResponse(res, { sent: true });
|
||||
})
|
||||
);
|
||||
|
||||
@@ -300,6 +300,28 @@ router.put('/accounting', adminAuth, requirePermission('settings.edit'), async (
|
||||
setting_type: 'accounting',
|
||||
});
|
||||
}
|
||||
// Global default for "attach the supplier proof PDF to the client-invoice
|
||||
// email when a re-bill/passthrough is issued" (issue #866). Off by default;
|
||||
// a per-customer override (customer_accounts.rebill_attach_proof) and the
|
||||
// per-file selection in the Send dialog both build on top of this default.
|
||||
if (Object.prototype.hasOwnProperty.call(req.body, 'accounting_rebill_attach_proof')) {
|
||||
updates.push({
|
||||
setting_key: 'accounting_rebill_attach_proof',
|
||||
setting_value: JSON.stringify(!!req.body.accounting_rebill_attach_proof),
|
||||
setting_type: 'accounting',
|
||||
});
|
||||
}
|
||||
// Filename template for the attached supplier proof (like the invoice/quote
|
||||
// number formats). Tokens: {INVOICE} {SUPPLIER} {YEAR} {MONTH} {SEQ}/{SEQ:0Nd}.
|
||||
// Empty falls back to the default at render time.
|
||||
if (Object.prototype.hasOwnProperty.call(req.body, 'crm_rebill_proof_filename_format')) {
|
||||
const fmt = String(req.body.crm_rebill_proof_filename_format || '').trim().slice(0, 120);
|
||||
updates.push({
|
||||
setting_key: 'crm_rebill_proof_filename_format',
|
||||
setting_value: JSON.stringify(fmt),
|
||||
setting_type: 'accounting',
|
||||
});
|
||||
}
|
||||
// VAT registration + reclaim. `registered` drives whether output VAT applies
|
||||
// + whether input VAT is deductible; `reclaim_countries` = the ISO-2 list of
|
||||
// countries whose input VAT can be reclaimed (drives cost tax-treatment +
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// Combined hours + re-bills billing (issue #866, Feature 3).
|
||||
//
|
||||
// When a per-event customer has BOTH open hour entries AND open re-bills, the
|
||||
// admin can roll them into ONE client invoice. Hours and re-bills are NEVER
|
||||
// merged into shared line items — they stay as distinct lines, grouped
|
||||
// contiguously (hours first, then re-bills) with no section headers (product
|
||||
// decision). Each source row is stamped with its own invoice line so the CRM
|
||||
// panels keep deriving status correctly.
|
||||
//
|
||||
// Reuses the extracted build/stamp helpers from customerHoursService and
|
||||
// expenseService so there is one code path for line-item construction and
|
||||
// stamping. Lives in its own module to avoid a require cycle between those two
|
||||
// services.
|
||||
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { AppError } = require('../utils/errors');
|
||||
const invoiceService = require('./invoiceService');
|
||||
const customerHoursService = require('./customerHoursService');
|
||||
const expenseService = require('./expenseService');
|
||||
|
||||
/**
|
||||
* Bundle open hours and/or open re-bills for a per-event customer into one
|
||||
* invoice. At least one side must be requested AND non-empty.
|
||||
*
|
||||
* @param customerId
|
||||
* @param opts.includeHours include unbilled hour entries
|
||||
* @param opts.includeRebills include pending rebill/passthrough documents
|
||||
* @returns { invoiceId, entriesBilled, rebillsBilled }
|
||||
*/
|
||||
async function billCombinedForCustomer(customerId, { includeHours, includeRebills }, adminId) {
|
||||
const customer = await db('customer_accounts').where({ id: customerId }).first();
|
||||
if (!customer) throw new AppError('Customer not found', 404);
|
||||
// Both underlying flows are per-event only (accumulator cadences auto-bill on
|
||||
// save/categorise); keep the combined path consistent.
|
||||
if (customer.billing_cadence === 'monthly' || customer.billing_cadence === 'manual') {
|
||||
throw new AppError(
|
||||
'Accumulator-mode customers (monthly / manual) bill automatically; combining is for per-event customers.',
|
||||
409, 'CADENCE_MISMATCH',
|
||||
);
|
||||
}
|
||||
if (!includeHours && !includeRebills) {
|
||||
throw new AppError('Nothing selected to bill', 400, 'NOTHING_SELECTED');
|
||||
}
|
||||
|
||||
let logInfo = null;
|
||||
const result = await db.transaction(async (trx) => {
|
||||
const hours = includeHours
|
||||
? await customerHoursService.buildUnbilledHourLineItems(trx, customer)
|
||||
: { entries: [], lineItems: [] };
|
||||
const rebills = includeRebills
|
||||
? await expenseService.buildPendingRebillLineItems(trx, customer)
|
||||
: { docs: [], lineItems: [] };
|
||||
|
||||
if (hours.entries.length === 0 && rebills.docs.length === 0) {
|
||||
throw new AppError('No open hours or re-bills to bill', 409, 'NO_OPEN_ITEMS');
|
||||
}
|
||||
|
||||
// Contiguous, hours first then re-bills. Positions run 1..N across both
|
||||
// groups so each source row maps to exactly one invoice line.
|
||||
const lineItems = [];
|
||||
let pos = 0;
|
||||
for (const li of hours.lineItems) { pos += 1; lineItems.push({ ...li, position: pos }); }
|
||||
const hoursCount = pos;
|
||||
for (const li of rebills.lineItems) { pos += 1; lineItems.push({ ...li, position: pos }); }
|
||||
|
||||
const { invoiceIds } = await invoiceService.createInvoice({
|
||||
customerAccountId: customer.id,
|
||||
lineItems,
|
||||
}, adminId, trx);
|
||||
const invoiceId = invoiceIds[0];
|
||||
|
||||
const insertedLines = await trx('invoice_line_items').where({ invoice_id: invoiceId }).orderBy('position', 'asc');
|
||||
const lineByPos = new Map(insertedLines.map((li) => [li.position, li.id]));
|
||||
|
||||
if (hours.entries.length) {
|
||||
const hourLineIds = hours.entries.map((_, i) => lineByPos.get(i + 1) || null);
|
||||
await customerHoursService.stampBilledEntries(trx, hours.entries, invoiceId, hourLineIds);
|
||||
}
|
||||
if (rebills.docs.length) {
|
||||
const rebillLineIds = rebills.docs.map((_, i) => lineByPos.get(hoursCount + i + 1) || null);
|
||||
await expenseService.stampBilledRebills(trx, rebills.docs, invoiceId, rebillLineIds);
|
||||
}
|
||||
|
||||
logInfo = {
|
||||
meta: { customerId: customer.id, invoiceId, entriesBilled: hours.entries.length, rebillsBilled: rebills.docs.length },
|
||||
};
|
||||
return { invoiceId, entriesBilled: hours.entries.length, rebillsBilled: rebills.docs.length };
|
||||
});
|
||||
// Audit log after commit (global-db write deadlocks inside the SQLite trx).
|
||||
if (logInfo) {
|
||||
try {
|
||||
await logActivity('combined_hours_rebills_billed', logInfo.meta, null, `admin:${adminId}`);
|
||||
} catch (_) { /* audit log is best-effort */ }
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = { billCombinedForCustomer };
|
||||
@@ -581,6 +581,11 @@ async function updateCustomer(id, updates, updatedByAdminId) {
|
||||
// Per-customer Skonto opt-out (migration 112). Boolean, coerced
|
||||
// via formatBoolean below for SQLite compatibility.
|
||||
'skonto_disabled',
|
||||
// Per-customer re-bill proof-attachment override (migration 169, #866).
|
||||
// Tri-state: null = inherit global default, true/false = force. Handled
|
||||
// in its own branch below so null survives (formatBoolean would coerce
|
||||
// it to false and silently lose the "inherit" state).
|
||||
'rebill_attach_proof',
|
||||
];
|
||||
for (const f of fields) {
|
||||
if (updates[f] !== undefined) {
|
||||
@@ -597,6 +602,10 @@ async function updateCustomer(id, updates, updatedByAdminId) {
|
||||
|| f === 'skonto_disabled'
|
||||
) {
|
||||
allowed[f] = formatBoolean(updates[f]);
|
||||
} else if (f === 'rebill_attach_proof') {
|
||||
// Tri-state override. null/'' → NULL (inherit global default);
|
||||
// otherwise a real boolean (coerced for SQLite).
|
||||
allowed[f] = (updates[f] === null || updates[f] === '') ? null : formatBoolean(updates[f]);
|
||||
} else if (f === 'hourly_rate_minor') {
|
||||
// Default hourly rate. Null clears it (forces per-entry
|
||||
// overrides); otherwise coerce to a non-negative bigint-safe
|
||||
|
||||
@@ -447,6 +447,38 @@ async function deleteEntry(entryId, adminId) {
|
||||
* onto the running draft on save, so there should be no unbilled rows.
|
||||
* Returns the new invoice id.
|
||||
*/
|
||||
// Load this customer's unbilled hour entries and build their invoice line items
|
||||
// (no `position` yet — the caller assigns it, so hours can be combined
|
||||
// contiguously with re-bills in one invoice; #866). Shared by the hours-only
|
||||
// path and the combined orchestrator.
|
||||
async function buildUnbilledHourLineItems(trx, customer) {
|
||||
const entries = await trx('customer_hour_entries')
|
||||
.where({ customer_account_id: customer.id, status: 'unbilled' })
|
||||
.orderBy('entry_date', 'asc').orderBy('start_time', 'asc');
|
||||
const installDefaultMinor = await getInstallDefaultRateMinor(trx);
|
||||
const lineItems = entries.map((entry) => {
|
||||
const rate = resolveEffectiveRate(entry, customer, installDefaultMinor);
|
||||
return buildLineItemFromEntry(entry, rate);
|
||||
});
|
||||
return { entries, lineItems };
|
||||
}
|
||||
|
||||
// Stamp each hour entry with the invoice + its specific line-item id. `lineIds`
|
||||
// is aligned to `entries` order.
|
||||
async function stampBilledEntries(trx, entries, invoiceId, lineIds) {
|
||||
const now = new Date();
|
||||
for (let i = 0; i < entries.length; i += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await trx('customer_hour_entries').where({ id: entries[i].id }).update({
|
||||
status: 'billed',
|
||||
invoice_id: invoiceId,
|
||||
invoice_line_item_id: lineIds[i] || null,
|
||||
billed_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function billUnbilledEntries(customerId, adminId) {
|
||||
const customer = await db('customer_accounts').where({ id: customerId }).first();
|
||||
if (!customer) throw new AppError('Customer not found', 404);
|
||||
@@ -460,19 +492,11 @@ async function billUnbilledEntries(customerId, adminId) {
|
||||
|
||||
let logInfo = null; // logged after commit — see createEntry note.
|
||||
const result = await db.transaction(async (trx) => {
|
||||
const unbilled = await trx('customer_hour_entries')
|
||||
.where({ customer_account_id: customer.id, status: 'unbilled' })
|
||||
.orderBy('entry_date', 'asc').orderBy('start_time', 'asc');
|
||||
const { entries: unbilled, lineItems: rawLines } = await buildUnbilledHourLineItems(trx, customer);
|
||||
if (unbilled.length === 0) {
|
||||
throw new AppError('No unbilled entries to bill', 409, 'NO_UNBILLED');
|
||||
}
|
||||
|
||||
const installDefaultMinor = await getInstallDefaultRateMinor(trx);
|
||||
const lineItems = unbilled.map((entry, idx) => {
|
||||
const rate = resolveEffectiveRate(entry, customer, installDefaultMinor);
|
||||
const li = buildLineItemFromEntry(entry, rate);
|
||||
return { ...li, position: idx + 1 };
|
||||
});
|
||||
const lineItems = rawLines.map((li, idx) => ({ ...li, position: idx + 1 }));
|
||||
|
||||
// No installment metadata — hour-billing always mints a single
|
||||
// standalone invoice. createInvoice returns `{ invoiceIds: [N] }`
|
||||
@@ -491,19 +515,8 @@ async function billUnbilledEntries(customerId, adminId) {
|
||||
.where({ invoice_id: invoiceId })
|
||||
.orderBy('position', 'asc');
|
||||
const lineByPos = new Map(insertedLines.map((li) => [li.position, li.id]));
|
||||
|
||||
const now = new Date();
|
||||
for (let i = 0; i < unbilled.length; i += 1) {
|
||||
const entry = unbilled[i];
|
||||
const lineItemId = lineByPos.get(i + 1) || null;
|
||||
await trx('customer_hour_entries').where({ id: entry.id }).update({
|
||||
status: 'billed',
|
||||
invoice_id: invoiceId,
|
||||
invoice_line_item_id: lineItemId,
|
||||
billed_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
}
|
||||
const lineIds = unbilled.map((_, i) => lineByPos.get(i + 1) || null);
|
||||
await stampBilledEntries(trx, unbilled, invoiceId, lineIds);
|
||||
|
||||
logInfo = { type: 'hour_entries_billed', meta: { customerId: customer.id, invoiceId, entryCount: unbilled.length } };
|
||||
return { invoiceId, entriesBilled: unbilled.length };
|
||||
@@ -590,6 +603,9 @@ module.exports = {
|
||||
updateEntry,
|
||||
deleteEntry,
|
||||
billUnbilledEntries,
|
||||
// Shared with the combined hours+re-bills orchestrator (#866).
|
||||
buildUnbilledHourLineItems,
|
||||
stampBilledEntries,
|
||||
getInstallDefaultRateMinor,
|
||||
_internal: {
|
||||
computeDurationMinutes,
|
||||
|
||||
@@ -21,6 +21,9 @@ const { db, logActivity } = require('../database/db');
|
||||
const { AppError } = require('../utils/errors');
|
||||
const logger = require('../utils/logger');
|
||||
const invoiceService = require('./invoiceService');
|
||||
// Tri-state proof-attach resolver — single source of truth lives with the
|
||||
// send-time attachment logic (no require cycle: rebillProofs never imports us).
|
||||
const { resolveDefaultAttach } = require('./invoice/rebillProofs');
|
||||
|
||||
/**
|
||||
* Actor for logActivity. `adminId` is legitimately absent on automated paths —
|
||||
@@ -54,7 +57,7 @@ function toIsoDate(v) {
|
||||
// ── Accounting settings (app_settings, type 'accounting') ───────────────────
|
||||
async function getAccountingSettings() {
|
||||
const keys = ['accounting_km_rate_minor', 'accounting_per_diem_rate_minor', 'accounting_require_proof',
|
||||
'accounting_vat_reclaim_countries'];
|
||||
'accounting_vat_reclaim_countries', 'accounting_rebill_attach_proof'];
|
||||
let rows = [];
|
||||
try {
|
||||
rows = await db('app_settings').whereIn('setting_key', keys).select('setting_key', 'setting_value');
|
||||
@@ -69,6 +72,8 @@ async function getAccountingSettings() {
|
||||
kmRateMinor: Number.isFinite(Number(map.accounting_km_rate_minor)) ? Number(map.accounting_km_rate_minor) : 0,
|
||||
perDiemRateMinor: Number.isFinite(Number(map.accounting_per_diem_rate_minor)) ? Number(map.accounting_per_diem_rate_minor) : 0,
|
||||
requireProof: map.accounting_require_proof === true || map.accounting_require_proof === 1 || map.accounting_require_proof === '1',
|
||||
// Global default for attaching supplier proof PDFs on re-bill invoices (#866).
|
||||
rebillAttachProof: map.accounting_rebill_attach_proof === true || map.accounting_rebill_attach_proof === 1 || map.accounting_rebill_attach_proof === '1',
|
||||
vatReclaimCountries: Array.isArray(map.accounting_vat_reclaim_countries)
|
||||
? map.accounting_vat_reclaim_countries.map((c) => String(c || '').toUpperCase()) : [],
|
||||
};
|
||||
@@ -423,6 +428,14 @@ async function categorizeInbound(id, payload, adminId) {
|
||||
if (!row) throw new AppError('Incoming invoice not found', 404, 'INBOUND_NOT_FOUND');
|
||||
const doc = transformInbound(row);
|
||||
|
||||
// A doc attached to a customer will become a client invoice line, which
|
||||
// needs an amount. Require one NOW (0 is fine — a legitimately zero-value
|
||||
// pass-through — but null is not) rather than letting a value-less item sit
|
||||
// PENDING and blow up the whole bundle later at bill time.
|
||||
if (customerAccountId && doc.totalAmountMinor == null && doc.netAmountMinor == null) {
|
||||
throw new AppError('Set the invoice amount before re-billing (0 is allowed).', 400, 'AMOUNT_REQUIRED');
|
||||
}
|
||||
|
||||
// #1: unwind any prior re-bill so the disposition can change.
|
||||
if (doc.billedInvoiceId) await unwindBilledLine(trx, doc);
|
||||
|
||||
@@ -483,6 +496,11 @@ async function rebillInbound(id, payload, adminId, trx0) {
|
||||
const row = await trx('inbound_documents').where({ id }).first();
|
||||
if (!row) throw new AppError('Incoming invoice not found', 404, 'INBOUND_NOT_FOUND');
|
||||
const doc = transformInbound(row);
|
||||
// A client invoice line needs an amount (0 allowed, null not) — fail here
|
||||
// rather than deep inside buildInboundLineItem.
|
||||
if (doc.totalAmountMinor == null && doc.netAmountMinor == null) {
|
||||
throw new AppError('Set the invoice amount before re-billing (0 is allowed).', 400, 'AMOUNT_REQUIRED');
|
||||
}
|
||||
if (doc.billedInvoiceId) await unwindBilledLine(trx, doc);
|
||||
const markup = await resolveMarkup(
|
||||
{ markupType: doc.markupType, markupPercent: doc.markupPercent, markupFlatMinor: doc.markupFlatMinor },
|
||||
@@ -559,6 +577,44 @@ async function listPendingRebillSummary() {
|
||||
return Array.from(byCustomer.values()).sort((a, b) => b.openAmountMinor - a.openAmountMinor);
|
||||
}
|
||||
|
||||
// Load this customer's pending (categorised-but-unbilled) rebill/passthrough
|
||||
// documents and build their invoice line items (no `position` yet — the caller
|
||||
// assigns it, so re-bills can be combined contiguously with hours in one
|
||||
// invoice; #866). Shared by the re-bills-only path and the combined orchestrator.
|
||||
async function buildPendingRebillLineItems(trx, customer) {
|
||||
const docs = await trx('inbound_documents')
|
||||
.where({ customer_account_id: customer.id })
|
||||
.whereNull('billed_invoice_id')
|
||||
.whereIn('disposition', CUSTOMER_DISPOSITIONS)
|
||||
.where('status', 'categorized')
|
||||
.orderBy('invoice_date', 'asc').orderBy('id', 'asc');
|
||||
const lineItems = [];
|
||||
for (let i = 0; i < docs.length; i += 1) {
|
||||
const doc = transformInbound(docs[i]);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const markup = await resolveMarkup(
|
||||
{ markupType: doc.markupType, markupPercent: doc.markupPercent, markupFlatMinor: doc.markupFlatMinor },
|
||||
null, null, trx,
|
||||
);
|
||||
lineItems.push(buildInboundLineItem(doc, doc.disposition, markup));
|
||||
}
|
||||
return { docs, lineItems };
|
||||
}
|
||||
|
||||
// Stamp each inbound document with the invoice + its specific line-item id.
|
||||
// `lineIds` is aligned to `docs` order.
|
||||
async function stampBilledRebills(trx, docs, invoiceId, lineIds) {
|
||||
const now = new Date();
|
||||
for (let i = 0; i < docs.length; i += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await trx('inbound_documents').where({ id: docs[i].id }).update({
|
||||
billed_invoice_id: invoiceId,
|
||||
billed_invoice_line_item_id: lineIds[i] || null,
|
||||
updated_at: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-event flow: bundle all pending rebill/passthrough documents for a
|
||||
* customer into ONE invoice, one line per document. Refuses for monthly/manual
|
||||
@@ -576,24 +632,9 @@ async function billPendingRebills(customerId, adminId) {
|
||||
}
|
||||
|
||||
const result = await db.transaction(async (trx) => {
|
||||
const pending = await trx('inbound_documents')
|
||||
.where({ customer_account_id: customer.id })
|
||||
.whereNull('billed_invoice_id')
|
||||
.whereIn('disposition', CUSTOMER_DISPOSITIONS)
|
||||
.where('status', 'categorized')
|
||||
.orderBy('invoice_date', 'asc').orderBy('id', 'asc');
|
||||
const { docs: pending, lineItems: rawLines } = await buildPendingRebillLineItems(trx, customer);
|
||||
if (pending.length === 0) throw new AppError('No pending re-bills to bill', 409, 'NO_PENDING');
|
||||
|
||||
const lineItems = [];
|
||||
for (let i = 0; i < pending.length; i += 1) {
|
||||
const doc = transformInbound(pending[i]);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const markup = await resolveMarkup(
|
||||
{ markupType: doc.markupType, markupPercent: doc.markupPercent, markupFlatMinor: doc.markupFlatMinor },
|
||||
null, null, trx,
|
||||
);
|
||||
lineItems.push({ ...buildInboundLineItem(doc, doc.disposition, markup), position: i + 1 });
|
||||
}
|
||||
const lineItems = rawLines.map((li, idx) => ({ ...li, position: idx + 1 }));
|
||||
|
||||
const { invoiceIds } = await invoiceService.createInvoice({
|
||||
customerAccountId: customer.id,
|
||||
@@ -603,15 +644,8 @@ async function billPendingRebills(customerId, adminId) {
|
||||
|
||||
const insertedLines = await trx('invoice_line_items').where({ invoice_id: invoiceId }).orderBy('position', 'asc');
|
||||
const lineByPos = new Map(insertedLines.map((li) => [li.position, li.id]));
|
||||
const now = new Date();
|
||||
for (let i = 0; i < pending.length; i += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await trx('inbound_documents').where({ id: pending[i].id }).update({
|
||||
billed_invoice_id: invoiceId,
|
||||
billed_invoice_line_item_id: lineByPos.get(i + 1) || null,
|
||||
updated_at: now,
|
||||
});
|
||||
}
|
||||
const lineIds = pending.map((_, i) => lineByPos.get(i + 1) || null);
|
||||
await stampBilledRebills(trx, pending, invoiceId, lineIds);
|
||||
|
||||
return { invoiceId, count: pending.length };
|
||||
});
|
||||
@@ -620,6 +654,106 @@ async function billPendingRebills(customerId, adminId) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Derive a re-bill row's status from its linked client-invoice lifecycle — no
|
||||
// duplicated status column (avoids drift, #866). A covering invoice that was
|
||||
// cancelled (Storno) drops back to 'open' so storno'd items never inflate the
|
||||
// sent/paid aggregates.
|
||||
function deriveRebillStatus(billedInvoiceId, invoiceStatus) {
|
||||
if (!billedInvoiceId) return 'open';
|
||||
if (invoiceStatus === 'paid') return 'paid';
|
||||
if (invoiceStatus === 'cancelled') return 'open';
|
||||
return 'sent'; // scheduled / sent / overdue
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-bill / passthrough items for one customer (issue #866, Feature 2). One row
|
||||
* per captured supplier invoice attached to this customer as a rebill/
|
||||
* passthrough, with cost vs re-billed amount (incl. markup), mode, linked event
|
||||
* + client invoice, and a status DERIVED from the invoice lifecycle. History-
|
||||
* only — feeds the CRM → Customer panel.
|
||||
*/
|
||||
async function listCustomerRebills(customerId) {
|
||||
const rows = await db('inbound_documents as d')
|
||||
.leftJoin('invoices as inv', 'd.billed_invoice_id', 'inv.id')
|
||||
.leftJoin('events as e', 'd.event_id', 'e.id')
|
||||
.where('d.customer_account_id', customerId)
|
||||
.whereIn('d.disposition', CUSTOMER_DISPOSITIONS)
|
||||
.where('d.status', 'categorized')
|
||||
.orderBy('d.invoice_date', 'desc').orderBy('d.id', 'desc')
|
||||
.select(
|
||||
'd.id', 'd.supplier_name', 'd.invoice_date', 'd.currency',
|
||||
'd.net_amount_minor', 'd.total_amount_minor', 'd.disposition',
|
||||
'd.markup_type', 'd.markup_percent', 'd.markup_flat_minor',
|
||||
'd.billed_invoice_id', 'd.proof_attach_error', 'd.file_path', 'd.event_id',
|
||||
'e.event_name as event_name',
|
||||
'inv.invoice_number as invoice_number', 'inv.status as invoice_status',
|
||||
);
|
||||
|
||||
return rows.map((r) => {
|
||||
const base = r.total_amount_minor != null ? Number(r.total_amount_minor)
|
||||
: (r.net_amount_minor != null ? Number(r.net_amount_minor) : 0);
|
||||
const isPassthrough = r.disposition === 'durchlaufend';
|
||||
// Passthrough is invoiced at cost (VAT-neutral, no markup); re-bill carries
|
||||
// the stored markup snapshot.
|
||||
const markup = isPassthrough ? { type: 'none', percent: null, flatMinor: null } : {
|
||||
type: MARKUP_TYPES.includes(r.markup_type) ? r.markup_type : 'none',
|
||||
percent: r.markup_percent != null ? Number(r.markup_percent) : null,
|
||||
flatMinor: Number.isInteger(r.markup_flat_minor) ? r.markup_flat_minor : null,
|
||||
};
|
||||
const status = deriveRebillStatus(r.billed_invoice_id, r.invoice_status);
|
||||
const billed = status !== 'open';
|
||||
return {
|
||||
id: r.id,
|
||||
supplierName: r.supplier_name || null,
|
||||
date: toIsoDate(r.invoice_date),
|
||||
currency: r.currency || null,
|
||||
costMinor: base,
|
||||
rebilledMinor: base + computeMarkupMinor(base, markup),
|
||||
mode: isPassthrough ? 'passthrough' : 'rebill',
|
||||
eventId: r.event_id || null,
|
||||
eventName: r.event_name || null,
|
||||
hasProof: !!r.file_path,
|
||||
proofAttachError: r.proof_attach_error || null,
|
||||
status,
|
||||
invoiceId: billed ? r.billed_invoice_id : null,
|
||||
invoiceNumber: billed ? (r.invoice_number || null) : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-bill proofs attached to ONE (not-yet-sent) client invoice, for the Send
|
||||
* dialog's per-file selection (#866, Feature 1). Also returns the resolved
|
||||
* attach default (per-customer override else global) so the dialog can
|
||||
* pre-check the boxes.
|
||||
*/
|
||||
async function listInvoiceRebillProofs(invoiceId) {
|
||||
const rows = await db('inbound_documents')
|
||||
.where({ billed_invoice_id: invoiceId })
|
||||
.whereIn('disposition', CUSTOMER_DISPOSITIONS)
|
||||
.orderBy('id', 'asc')
|
||||
.select('id', 'supplier_name', 'original_filename', 'file_path', 'currency',
|
||||
'net_amount_minor', 'total_amount_minor', 'disposition', 'proof_attach_error');
|
||||
const proofs = rows.map((r) => ({
|
||||
id: r.id,
|
||||
supplierName: r.supplier_name || null,
|
||||
filename: r.original_filename || null,
|
||||
hasProof: !!r.file_path,
|
||||
currency: r.currency || null,
|
||||
amountMinor: r.total_amount_minor != null ? Number(r.total_amount_minor)
|
||||
: (r.net_amount_minor != null ? Number(r.net_amount_minor) : 0),
|
||||
mode: r.disposition === 'durchlaufend' ? 'passthrough' : 'rebill',
|
||||
proofAttachError: r.proof_attach_error || null,
|
||||
}));
|
||||
|
||||
const inv = await db('invoices').where({ id: invoiceId }).first('customer_account_id');
|
||||
const customer = inv && inv.customer_account_id
|
||||
? await db('customer_accounts').where({ id: inv.customer_account_id }).first('rebill_attach_proof')
|
||||
: null;
|
||||
const { rebillAttachProof } = await getAccountingSettings();
|
||||
return { proofs, attachDefault: resolveDefaultAttach(customer, rebillAttachProof) };
|
||||
}
|
||||
|
||||
/** Mark the supplier paid on the incoming invoice (the payable lives here). */
|
||||
async function markInboundSupplierPayment(id, { paid, paidAt, paymentMethod, paymentReference }, adminId) {
|
||||
await getInbound(id);
|
||||
@@ -833,6 +967,11 @@ module.exports = {
|
||||
rebillInbound,
|
||||
listPendingRebillSummary,
|
||||
billPendingRebills,
|
||||
listCustomerRebills,
|
||||
listInvoiceRebillProofs,
|
||||
// Shared with the combined hours+re-bills orchestrator (#866).
|
||||
buildPendingRebillLineItems,
|
||||
stampBilledRebills,
|
||||
markInboundSupplierPayment,
|
||||
// expenses
|
||||
createExpense,
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// Re-bill / passthrough proof attachments (issue #866).
|
||||
//
|
||||
// When a client invoice re-bills one or more captured supplier invoices
|
||||
// (inbound_documents.billed_invoice_id → this invoice), the original stored
|
||||
// supplier PDF can ride along on the invoice email as a SEPARATE attachment
|
||||
// (the invoice PDF itself is never touched — invoice immutability). Whether a
|
||||
// given proof attaches is decided at ISSUE time:
|
||||
//
|
||||
// • Manual send → the admin's per-file selection from the Send dialog
|
||||
// (proofInboundIds), which defaults to the resolved toggle.
|
||||
// • Auto send → (scheduler / monthly flush, no admin present) the resolved
|
||||
// default: per-customer override (customer_accounts
|
||||
// .rebill_attach_proof, tri-state) else the global
|
||||
// accounting_rebill_attach_proof (default off).
|
||||
//
|
||||
// A selected proof whose file is missing/unreadable does NOT silently drop and
|
||||
// does NOT block the send — we stamp inbound_documents.proof_attach_error so the
|
||||
// re-bill row surfaces a recovery banner in CRM → Customer.
|
||||
//
|
||||
// Kept in its own module (not expenseService) to avoid the invoiceService ↔
|
||||
// expenseService require cycle.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { db } = require('../../database/db');
|
||||
const logger = require('../../utils/logger');
|
||||
const { isFeatureEnabled } = require('../../middleware/requireFeatureFlag');
|
||||
const { assertPathInside } = require('../../utils/safePath');
|
||||
const { getStoragePath } = require('../../config/storage');
|
||||
|
||||
// Dispositions that re-bill/pass a supplier invoice to a client (mirrors
|
||||
// expenseService.CUSTOMER_DISPOSITIONS — duplicated as a 2-item constant rather
|
||||
// than imported, to keep this module free of the require cycle).
|
||||
const CUSTOMER_DISPOSITIONS = ['rebill', 'durchlaufend'];
|
||||
|
||||
const DEFAULT_PROOF_FILENAME_FORMAT = 'Beleg-{INVOICE}';
|
||||
|
||||
// Render a proof attachment filename from the admin-configurable template.
|
||||
// Tokens: {INVOICE} (client invoice number), {SUPPLIER}, {YEAR}, {MONTH},
|
||||
// {SEQ} / {SEQ:0Nd} (per-invoice proof index). Always yields a filesystem-safe
|
||||
// name ending in .pdf. When one invoice carries several proofs but the template
|
||||
// has no {SEQ}, an index is appended so the filenames stay unique.
|
||||
function renderProofName(format, { invoiceNumber, supplierName, seq, hasMulti, issueDate }) {
|
||||
const d = issueDate ? new Date(issueDate) : new Date();
|
||||
const year = Number.isNaN(d.getTime()) ? '' : String(d.getFullYear());
|
||||
const month = Number.isNaN(d.getTime()) ? '' : String(d.getMonth() + 1).padStart(2, '0');
|
||||
let hadSeq = false;
|
||||
let name = String(format || DEFAULT_PROOF_FILENAME_FORMAT)
|
||||
.replace(/\{INVOICE\}/g, invoiceNumber || 'invoice')
|
||||
.replace(/\{SUPPLIER\}/g, supplierName || '')
|
||||
.replace(/\{YEAR\}/g, year)
|
||||
.replace(/\{MONTH\}/g, month)
|
||||
.replace(/\{SEQ:(\d+)d\}/g, (_, p) => { hadSeq = true; return String(seq).padStart(parseInt(p, 10), '0'); })
|
||||
.replace(/\{SEQ\}/g, () => { hadSeq = true; return String(seq); });
|
||||
if (hasMulti && !hadSeq) name += `-${seq}`;
|
||||
// Filesystem-safe: drop any author-supplied extension, collapse whitespace +
|
||||
// unsafe chars to '-', trim stray separators. Some German schemes use '/'.
|
||||
name = name.replace(/\.pdf$/i, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^[-.]+|[-.]+$/g, '');
|
||||
if (!name) name = 'Beleg';
|
||||
return `${name}.pdf`;
|
||||
}
|
||||
|
||||
async function readFilenameFormat() {
|
||||
try {
|
||||
const row = await db('app_settings').where({ setting_key: 'crm_rebill_proof_filename_format' }).first('setting_value');
|
||||
if (!row) return DEFAULT_PROOF_FILENAME_FORMAT;
|
||||
let v = row.setting_value;
|
||||
if (typeof v === 'string') { try { v = JSON.parse(v); } catch (_e) { /* keep raw */ } }
|
||||
return (typeof v === 'string' && v.trim()) ? v.trim() : DEFAULT_PROOF_FILENAME_FORMAT;
|
||||
} catch (_e) {
|
||||
return DEFAULT_PROOF_FILENAME_FORMAT;
|
||||
}
|
||||
}
|
||||
|
||||
async function readGlobalDefault() {
|
||||
try {
|
||||
const row = await db('app_settings').where({ setting_key: 'accounting_rebill_attach_proof' }).first('setting_value');
|
||||
if (!row) return false;
|
||||
let v = row.setting_value;
|
||||
if (typeof v === 'string') { try { v = JSON.parse(v); } catch (_e) { /* keep raw */ } }
|
||||
return v === true || v === 1 || v === '1';
|
||||
} catch (_e) {
|
||||
return false; // app_settings absent in some test harnesses → off
|
||||
}
|
||||
}
|
||||
|
||||
// Tri-state resolution: per-customer override wins; NULL/undefined inherits the
|
||||
// global default.
|
||||
function resolveDefaultAttach(customer, globalOn) {
|
||||
const ov = customer ? customer.rebill_attach_proof : null;
|
||||
if (ov === null || ov === undefined) return !!globalOn;
|
||||
return ov === true || ov === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the proof attachments for an invoice being issued, and persist per-row
|
||||
* failure markers. Returns an array of nodemailer-style attachment descriptors
|
||||
* ({ filename, contentPath, contentType }) — possibly empty. Never throws into
|
||||
* the send path.
|
||||
*
|
||||
* @param invoice the invoice row (needs id, invoice_number)
|
||||
* @param customer the customer_accounts row (for the tri-state override)
|
||||
* @param proofInboundIds optional explicit selection (manual send). When
|
||||
* omitted, the resolved default decides all-or-none.
|
||||
*/
|
||||
async function collectRebillProofAttachments(invoice, customer, proofInboundIds) {
|
||||
// Backend flag gate — no proof handling at all when incoming-invoices is off.
|
||||
if (!(await isFeatureEnabled('incomingInvoices'))) return [];
|
||||
|
||||
let rebillRows;
|
||||
try {
|
||||
rebillRows = await db('inbound_documents')
|
||||
.where({ billed_invoice_id: invoice.id })
|
||||
.whereIn('disposition', CUSTOMER_DISPOSITIONS)
|
||||
.select('id', 'file_path', 'supplier_name', 'original_filename');
|
||||
} catch (_e) {
|
||||
return []; // table absent (older install / test harness)
|
||||
}
|
||||
if (!rebillRows || rebillRows.length === 0) return [];
|
||||
|
||||
// Decide the set to attach.
|
||||
let selected;
|
||||
if (Array.isArray(proofInboundIds)) {
|
||||
// Manual send: attach exactly the admin's picks that actually belong to
|
||||
// this invoice's re-bill set (ignore anything foreign).
|
||||
const wanted = new Set(proofInboundIds.map((n) => parseInt(n, 10)).filter(Number.isInteger));
|
||||
selected = rebillRows.filter((r) => wanted.has(r.id));
|
||||
} else {
|
||||
// Auto send: all-or-none per the resolved default.
|
||||
const globalOn = await readGlobalDefault();
|
||||
selected = resolveDefaultAttach(customer, globalOn) ? rebillRows : [];
|
||||
}
|
||||
if (selected.length === 0) return [];
|
||||
|
||||
const businessDocs = path.join(getStoragePath(), 'business-docs');
|
||||
const format = await readFilenameFormat();
|
||||
const multi = selected.length > 1;
|
||||
const attachments = [];
|
||||
|
||||
for (let i = 0; i < selected.length; i += 1) {
|
||||
const row = selected[i];
|
||||
let markerErr = null;
|
||||
if (!row.file_path) {
|
||||
markerErr = 'proof file missing (no stored PDF on the supplier invoice)';
|
||||
} else {
|
||||
try {
|
||||
const safe = assertPathInside(row.file_path, [businessDocs]);
|
||||
if (!fs.existsSync(safe)) {
|
||||
markerErr = 'proof file not found on disk at issue time';
|
||||
} else {
|
||||
attachments.push({
|
||||
filename: renderProofName(format, {
|
||||
invoiceNumber: invoice.invoice_number,
|
||||
supplierName: row.supplier_name,
|
||||
seq: i + 1,
|
||||
hasMulti: multi,
|
||||
issueDate: invoice.issue_date,
|
||||
}),
|
||||
contentPath: safe,
|
||||
contentType: 'application/pdf',
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
markerErr = `proof path rejected: ${e.message}`;
|
||||
}
|
||||
}
|
||||
// Persist / clear the failure marker (best-effort; never blocks the send).
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await db('inbound_documents').where({ id: row.id })
|
||||
.update({ proof_attach_error: markerErr, updated_at: new Date() });
|
||||
} catch (_e) { /* marker column may be absent pre-migration — ignore */ }
|
||||
if (markerErr) logger.warn?.(`rebillProofs: invoice ${invoice.invoice_number} inbound ${row.id}: ${markerErr}`);
|
||||
}
|
||||
|
||||
return attachments;
|
||||
}
|
||||
|
||||
module.exports = { collectRebillProofAttachments, resolveDefaultAttach, renderProofName };
|
||||
@@ -14,12 +14,19 @@ const { computeDueDate, ensureCustomerCanBill, formatMajor, getHierarchyHelpers,
|
||||
const { getInvoiceById } = require('./queries');
|
||||
const { createInvoice } = require('./create');
|
||||
const { buildInvoiceRenderContext } = require('./render');
|
||||
const { collectRebillProofAttachments } = require('./rebillProofs');
|
||||
|
||||
|
||||
/**
|
||||
* Send an invoice email + PDF. Flips status scheduled → sent.
|
||||
*
|
||||
* @param options.proofInboundIds optional explicit re-bill proof selection
|
||||
* (issue #866). Set by the manual Send dialog so the admin picks which
|
||||
* supplier proofs ride the email — all, some, or none. When omitted
|
||||
* (auto-send / scheduler) the resolved per-customer/global default
|
||||
* decides all-or-none.
|
||||
*/
|
||||
async function sendInvoice(id, adminId) {
|
||||
async function sendInvoice(id, adminId, options = {}) {
|
||||
const data = await getInvoiceById(id);
|
||||
if (!data) throw new AppError('Invoice not found', 404);
|
||||
const { invoice, lineItems } = data;
|
||||
@@ -118,6 +125,23 @@ async function sendInvoice(id, adminId) {
|
||||
});
|
||||
|
||||
const { to: invoiceTo, cc: invoiceCc } = resolveBillingRecipients(customer, invoice.cc_pdf_email);
|
||||
|
||||
// Re-bill/passthrough proof attachments (#866). Separate attachments — the
|
||||
// invoice PDF above is never touched. Selection comes from the Send dialog on
|
||||
// a manual send; auto-sends fall back to the resolved default. Best-effort:
|
||||
// a missing proof marks the re-bill row but never blocks the send.
|
||||
const invoiceAttachments = [{
|
||||
filename: `${invoice.invoice_number}.pdf`,
|
||||
contentPath: pdfPath,
|
||||
contentType: 'application/pdf',
|
||||
}];
|
||||
try {
|
||||
const proofs = await collectRebillProofAttachments(invoice, customer, options.proofInboundIds);
|
||||
if (proofs.length) invoiceAttachments.push(...proofs);
|
||||
} catch (e) {
|
||||
logger.warn?.(`sendInvoice: proof attachment collection failed for ${invoice.invoice_number}: ${e.message}`);
|
||||
}
|
||||
|
||||
await emailProcessor.queueEmail(invoice.event_id || null, invoiceTo, 'invoice_sent', {
|
||||
invoice_number: invoice.invoice_number,
|
||||
customer_name: customer.display_name || customer.first_name || customer.email.split('@')[0],
|
||||
@@ -131,11 +155,7 @@ async function sendInvoice(id, adminId) {
|
||||
// above) rather than the event-first default resolution.
|
||||
__language: ctx.locale,
|
||||
cc: invoiceCc,
|
||||
attachments: [{
|
||||
filename: `${invoice.invoice_number}.pdf`,
|
||||
contentPath: pdfPath,
|
||||
contentType: 'application/pdf',
|
||||
}],
|
||||
attachments: invoiceAttachments,
|
||||
});
|
||||
|
||||
try { await logActivity('invoice_sent', { invoiceId: id }, invoice.event_id || null, `admin:${adminId}`); } catch (_) {}
|
||||
@@ -185,6 +205,26 @@ async function sendInvoice(id, adminId) {
|
||||
* cancellation itself; the storno sits in `status='scheduled'`
|
||||
* and the cron picks it up.
|
||||
*/
|
||||
/**
|
||||
* When an invoice is cancelled, detach any re-billed/passed-through supplier
|
||||
* invoices linked to it (#866 review). Nothing else clears
|
||||
* inbound_documents.billed_invoice_id, so without this a Storno'd cover would
|
||||
* strand the supplier cost: the CRM panel shows it as Open but every billing
|
||||
* path filters on billed_invoice_id IS NULL, so it could never be re-billed.
|
||||
* Mirrors the categorise-time reset; returns the item to the billable pool.
|
||||
* Best-effort + schema-guarded (no-op on non-accounting installs).
|
||||
*/
|
||||
async function releaseRebillsForCancelledInvoice(conn, invoiceId) {
|
||||
try {
|
||||
if (!(await conn.schema.hasTable('inbound_documents'))) return;
|
||||
await conn('inbound_documents')
|
||||
.where({ billed_invoice_id: invoiceId })
|
||||
.update({ billed_invoice_id: null, billed_invoice_line_item_id: null, updated_at: new Date() });
|
||||
} catch (e) {
|
||||
logger.warn?.(`releaseRebillsForCancelledInvoice failed for invoice ${invoiceId}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function createStorno(originalId, adminId, trx = db) {
|
||||
const original = await trx('invoices').where({ id: originalId }).first();
|
||||
if (!original) throw new AppError('Invoice not found', 404);
|
||||
@@ -305,6 +345,8 @@ async function createStorno(originalId, adminId, trx = db) {
|
||||
cancellation_storno_id: stornoId,
|
||||
updated_at: now,
|
||||
});
|
||||
// Free any re-billed supplier invoices so the cost isn't stranded (#866 review).
|
||||
await releaseRebillsForCancelledInvoice(trx, originalId);
|
||||
|
||||
try {
|
||||
// Pass `trx` so the audit insert rides the transaction's connection;
|
||||
@@ -597,6 +639,8 @@ async function cancelInvoice(id, adminId) {
|
||||
await db('invoices').where({ id }).update({
|
||||
status: 'cancelled', updated_at: new Date(),
|
||||
});
|
||||
// Free any re-billed supplier invoices so the cost isn't stranded (#866 review).
|
||||
await releaseRebillsForCancelledInvoice(db, id);
|
||||
try {
|
||||
await logActivity('invoice_cancelled',
|
||||
{ invoiceId: id, viaStorno: false },
|
||||
|
||||
@@ -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
|
||||
* 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 { Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { FileText, Plus, Receipt, ScrollText } from 'lucide-react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'react-toastify';
|
||||
import { FileText, Plus, Receipt, ScrollText, Repeat2, AlertTriangle } from 'lucide-react';
|
||||
import { Card, Button, Loading } from '../common';
|
||||
import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import { quotesService } from '../../services/quotes.service';
|
||||
import { billsService, isDraftInvoice } from '../../services/bills.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 { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
|
||||
@@ -36,6 +41,7 @@ export const CustomerCrmPanels: React.FC<Props> = ({ customerAccountId }) => {
|
||||
{flags.quotes && <QuotesPanel customerAccountId={customerAccountId} />}
|
||||
{flags.contracts && <ContractsPanel customerAccountId={customerAccountId} />}
|
||||
{flags.bills && <InvoicesPanel customerAccountId={customerAccountId} />}
|
||||
{flags.incomingInvoices && <RebillsPanel customerAccountId={customerAccountId} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -225,3 +231,167 @@ const InvoicesPanel: React.FC<Props> = ({ customerAccountId }) => {
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -22,10 +22,14 @@ import { Button, Card, LocalizedDateInput, TimeField } from '../common';
|
||||
import { DecimalInput } from '../common/DecimalInput';
|
||||
import { parseLocaleDecimal, parseDuration } from '../../utils/parsers';
|
||||
import { customerAdminService } from '../../services/customerAdmin.service';
|
||||
import { accountingService } from '../../services/accounting.service';
|
||||
import { businessProfileService } from '../../services/businessProfile.service';
|
||||
import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
import { useMutationWithToast } from '../../hooks';
|
||||
import { ProjectSelect } from './ProjectSelect';
|
||||
import { CrossAddInvoiceDialog } from './CrossAddInvoiceDialog';
|
||||
|
||||
export interface HoursSectionProps {
|
||||
customerId: number;
|
||||
@@ -49,6 +53,9 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
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 [entryDate, setEntryDate] = useState(() => new Date().toISOString().slice(0, 10));
|
||||
const [startTime, setStartTime] = useState('09:00');
|
||||
@@ -159,20 +166,51 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
|
||||
errorMessage: 'Failed to delete entry',
|
||||
});
|
||||
|
||||
const billMutation = useMutation({
|
||||
mutationFn: () => customerAdminService.billUnbilledHourEntries(customerId),
|
||||
onSuccess: ({ invoiceId }) => {
|
||||
qc.invalidateQueries({ queryKey: ['admin-customer-hour-entries', customerId] });
|
||||
qc.invalidateQueries({ queryKey: ['admin-customer', customerId] });
|
||||
toast.success(t('customers.hours.toast.billed', 'Hours billed'));
|
||||
// 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`);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err?.response?.data?.error || 'Failed to bill hours');
|
||||
},
|
||||
// Open re-bills count for the cross-add offer (#866) — only when the
|
||||
// incoming-invoices feature is on and the admin can create the invoice.
|
||||
const { data: openRebills = 0 } = useQuery({
|
||||
queryKey: ['customer-open-rebills-count', customerId],
|
||||
queryFn: async () => (await accountingService.listCustomerRebills(customerId)).filter((r) => r.status === 'open').length,
|
||||
enabled: !!flags.incomingInvoices && canBill,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
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
|
||||
// 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
|
||||
visible in compact mode so the customer-detail page can
|
||||
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">
|
||||
<span className="text-sm">
|
||||
{t('customers.hours.unbilledCount',
|
||||
@@ -395,15 +433,24 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
|
||||
</span>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={billMutation.isPending}
|
||||
isLoading={billMutation.isPending}
|
||||
onClick={() => billMutation.mutate()}
|
||||
disabled={billBusy}
|
||||
isLoading={billBusy}
|
||||
onClick={handleBillHours}
|
||||
>
|
||||
{t('customers.hours.billButton', 'Create draft invoice')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CrossAddInvoiceDialog
|
||||
open={crossAddOpen}
|
||||
primary="hours"
|
||||
otherCount={openRebills}
|
||||
busy={billBusy}
|
||||
onConfirm={runBill}
|
||||
onClose={() => setCrossAddOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Entry list table. */}
|
||||
{isLoading ? (
|
||||
<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 { DecimalInput } from '../../../components/common/DecimalInput';
|
||||
import { accountingService } from '../../../services/accounting.service';
|
||||
import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
|
||||
import { businessProfileService } from '../../../services/businessProfile.service';
|
||||
import { vatCodesService } from '../../../services/vatCodes.service';
|
||||
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 = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const { flags } = useFeatureFlags();
|
||||
const { data, isLoading } = useQuery({ queryKey: ['accounting-settings'], queryFn: () => accountingService.getSettings() });
|
||||
const { data: outputVatCodes = [] } = useQuery({ queryKey: ['vat-codes', 'output'], queryFn: () => vatCodesService.listOutput() });
|
||||
// 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 [hourlyMajor, setHourlyMajor] = useState<number>(NaN);
|
||||
const [requireProof, setRequireProof] = useState(false);
|
||||
const [rebillAttachProof, setRebillAttachProof] = useState(false);
|
||||
const [rebillProofNameFormat, setRebillProofNameFormat] = useState('');
|
||||
const [vatRegistered, setVatRegistered] = useState(false);
|
||||
const [reclaimCountries, setReclaimCountries] = useState<string[]>([]);
|
||||
const [defaultOutputVatCode, setDefaultOutputVatCode] = useState('');
|
||||
@@ -43,6 +47,8 @@ export const AccountingTab: React.FC = () => {
|
||||
setKmMajor(data.accounting_km_rate_minor / 100);
|
||||
setPerDiemMajor(data.accounting_per_diem_rate_minor / 100);
|
||||
setRequireProof(data.accounting_require_proof);
|
||||
setRebillAttachProof(data.accounting_rebill_attach_proof);
|
||||
setRebillProofNameFormat(data.crm_rebill_proof_filename_format || '');
|
||||
setVatRegistered(data.accounting_vat_registered);
|
||||
setReclaimCountries(data.accounting_vat_reclaim_countries || []);
|
||||
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_per_diem_rate_minor: Number.isFinite(perDiemMajor) ? Math.round(perDiemMajor * 100) : 0,
|
||||
accounting_require_proof: requireProof,
|
||||
accounting_rebill_attach_proof: rebillAttachProof,
|
||||
crm_rebill_proof_filename_format: rebillProofNameFormat.trim(),
|
||||
accounting_vat_registered: vatRegistered,
|
||||
accounting_vat_reclaim_countries: reclaimCountries,
|
||||
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" />
|
||||
{t('settings.accounting.requireProof', 'Require a proof file on every expense')}
|
||||
</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 isn’t 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>
|
||||
</CardContent></Card>
|
||||
|
||||
|
||||
@@ -2042,7 +2042,11 @@
|
||||
"hourlyRate": "Standard-Stundensatz",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"title": "Diashow",
|
||||
@@ -4119,7 +4123,8 @@
|
||||
"toast": {
|
||||
"created": "Eintrag erfasst",
|
||||
"deleted": "Eintrag gelöscht",
|
||||
"billed": "Stunden verrechnet"
|
||||
"billed": "Stunden verrechnet",
|
||||
"billedCombined": "Rechnung aus Stunden und Weiterverrechnungen erstellt."
|
||||
},
|
||||
"noRate": {
|
||||
"title": "Kein Stundensatz hinterlegt",
|
||||
@@ -4272,7 +4277,9 @@
|
||||
"contractsSection": "Verträge",
|
||||
"noContracts": "Noch keine Verträge für diesen Kunden.",
|
||||
"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": {
|
||||
"section": "Abrechnungsrhythmus",
|
||||
@@ -4297,7 +4304,12 @@
|
||||
"title": "Offen für die Rechnung dieses Monats",
|
||||
"titleManual": "Offen – wird auf manuelle Auslösung versendet",
|
||||
"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": {
|
||||
"button": "Reaktivieren",
|
||||
@@ -4483,7 +4495,8 @@
|
||||
"pendingCount_other": "{{count}} Posten",
|
||||
"billPending": "Verrechnen",
|
||||
"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": {
|
||||
"kind": "Art",
|
||||
@@ -5213,6 +5226,18 @@
|
||||
"swiss": "Swiss QR-Bill",
|
||||
"epc": "EPC QR (SEPA)",
|
||||
"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": {
|
||||
@@ -5784,5 +5809,34 @@
|
||||
"syncBusy": "Es läuft bereits eine Synchronisierung.",
|
||||
"syncFailed": "Synchronisierung 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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1587,7 +1587,11 @@
|
||||
"hourlyRate": "Default hourly rate",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"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 isn’t named identically to the invoice PDF."
|
||||
},
|
||||
"slideshow": {
|
||||
"title": "Slideshow",
|
||||
@@ -4119,7 +4123,8 @@
|
||||
"toast": {
|
||||
"created": "Entry logged",
|
||||
"deleted": "Entry deleted",
|
||||
"billed": "Hours billed"
|
||||
"billed": "Hours billed",
|
||||
"billedCombined": "Invoice created from hours and re-bills."
|
||||
},
|
||||
"noRate": {
|
||||
"title": "No hourly rate configured",
|
||||
@@ -4272,7 +4277,9 @@
|
||||
"noContracts": "No contracts for this customer yet.",
|
||||
"billsSection": "Invoices",
|
||||
"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": {
|
||||
"section": "Billing cadence",
|
||||
@@ -4297,7 +4304,12 @@
|
||||
"title": "Pending in this month's bill",
|
||||
"titleManual": "Pending — ships on manual trigger",
|
||||
"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": {
|
||||
"button": "Reactivate",
|
||||
@@ -4483,7 +4495,8 @@
|
||||
"pendingCount_other": "{{count}} items",
|
||||
"billPending": "Bill these",
|
||||
"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": {
|
||||
"kind": "Type",
|
||||
@@ -5211,6 +5224,18 @@
|
||||
"overdue": "Overdue",
|
||||
"cancelled": "Cancelled",
|
||||
"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": {
|
||||
@@ -5782,5 +5807,34 @@
|
||||
"syncBusy": "A sync is already running.",
|
||||
"syncFailed": "Sync 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'
|
||||
| 'countryCode' | 'countryName' | 'preferredLanguage' | 'notes'
|
||||
| 'featureCalendar' | 'featureQuotes' | 'featureBills' | 'featureHoursLogging' | 'featureContracts'
|
||||
| 'hourlyRateMinor' | 'billingCadence' | 'billingCycleDay' | 'skontoDisabled';
|
||||
| 'hourlyRateMinor' | 'billingCadence' | 'billingCycleDay' | 'skontoDisabled' | 'rebillAttachProof';
|
||||
|
||||
// `fmtDate` (from useLocalizedDate, below) is the single canonical date
|
||||
// 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',
|
||||
billingCycleDay: customer.billingCycleDay ?? 1,
|
||||
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);
|
||||
}
|
||||
}, [customer, form]);
|
||||
@@ -751,6 +754,32 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
</span>
|
||||
</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
|
||||
every line item queued for the customer's current billing
|
||||
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];
|
||||
// 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 (
|
||||
<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 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>
|
||||
{/* #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 onClick={() => save.mutate(false)} disabled={save.isPending || rebillNeedsCustomer}>{save.isPending ? t('common.saving', 'Saving…') : t('accounting.inbox.saveCategorize', 'Save')}</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 || cannotSave}>{save.isPending ? t('common.saving', 'Saving…') : t('accounting.inbox.saveCategorize', 'Save')}</Button>
|
||||
</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 { DocumentLineageCard } from '../../../components/admin/DocumentLineageCard';
|
||||
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 { formatMoneyMinor } from '../../../utils/money';
|
||||
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
@@ -20,6 +23,7 @@ export const BillDetailPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { format: fmtDate } = useLocalizedDate();
|
||||
const { flags } = useFeatureFlags();
|
||||
const qc = useQueryClient();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['invoice', id],
|
||||
@@ -41,6 +45,12 @@ export const BillDetailPage: React.FC = () => {
|
||||
// still override it (e.g. partial Skonto + partial waive).
|
||||
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
|
||||
// was an inline IIFE inside the JSX, rebuilding the array (and N
|
||||
// <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');
|
||||
}
|
||||
};
|
||||
// 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 () => {
|
||||
// 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;
|
||||
try { await billsService.send(inv.id); toast.success(t('bills.sentToast', 'Invoice sent.')); qc.invalidateQueries({ queryKey: ['invoice', id] }); }
|
||||
catch (e: any) { toast.error(e?.response?.data?.error || 'Send failed'); }
|
||||
await doSend(undefined);
|
||||
};
|
||||
const handleReminder = async () => {
|
||||
if (!window.confirm(t('bills.confirmReminder', 'Send a reminder now?'))) return;
|
||||
@@ -466,6 +504,73 @@ export const BillDetailPage: React.FC = () => {
|
||||
)}
|
||||
</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 && (
|
||||
<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"
|
||||
|
||||
@@ -116,6 +116,44 @@ export interface AccountingSettings {
|
||||
accounting_vat_reclaim_countries: string[];
|
||||
/** Output VAT code stamped onto NEW invoices/quotes ('' = none). */
|
||||
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 {
|
||||
@@ -176,6 +214,10 @@ export const accountingService = {
|
||||
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. */
|
||||
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 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; },
|
||||
@@ -216,6 +258,9 @@ export const accountingService = {
|
||||
? data.accounting_vat_reclaim_countries : [],
|
||||
accounting_default_output_vat_code: typeof data.accounting_default_output_vat_code === 'string'
|
||||
? 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[] }> {
|
||||
|
||||
@@ -272,8 +272,14 @@ export const billsService = {
|
||||
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;
|
||||
},
|
||||
|
||||
|
||||
@@ -67,6 +67,10 @@ export interface CustomerAccountDetail extends CustomerAccountSummary {
|
||||
* this customer's invoices qualify for an early-payment discount,
|
||||
* regardless of template / global defaults. */
|
||||
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;
|
||||
events: Array<{
|
||||
id: number;
|
||||
@@ -168,6 +172,8 @@ export const customerAdminService = {
|
||||
billingCycleDay: 'billing_cycle_day',
|
||||
// Per-customer Skonto opt-out (migration 112).
|
||||
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)) {
|
||||
if (k in map) snake[map[k]] = v;
|
||||
@@ -344,6 +350,19 @@ export const customerAdminService = {
|
||||
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
|
||||
* currently carries unbilled hour entries, with open hours + open
|
||||
* amount (install default currency). Sorted by open amount desc. */
|
||||
|
||||
Reference in New Issue
Block a user