feat(accounting): expense invoiced/paid lifecycle + edit-until-invoiced; decouple tax report from bills flag

- transformExpense surfaces invoiced (billed_invoice_id), paid
  (supplier_paid), paidAt, paymentMethod, customerAccountId
- updateExpense throws EXPENSE_LOCKED once invoiced (edit until then)
- rebillExpense mints a client invoice line + locks the expense
- markExpensePaid toggles manual paid state
- adminExpenses: POST /:id/invoice (rebill) + POST /:id/paid
- adminTaxReport now gated by accounting master + taxReport sub-flag
  (independent of bills; tax export moved out of CRM into Accounting)
This commit is contained in:
Luca
2026-06-11 17:33:35 +02:00
parent 31867efcb9
commit 2e8e4a0f86
3 changed files with 107 additions and 13 deletions
+19
View File
@@ -220,4 +220,23 @@ router.patch('/:id', requireExpenses, requirePermission('accounting.manage'),
return successResponse(res, { expense });
}));
// Add an expense onto a client invoice -> marks it invoiced (locks editing).
router.post('/:id/invoice', requireExpenses, requirePermission('accounting.manage'),
[param('id').isInt({ min: 1 }), body('customerAccountId').isInt({ min: 1 }),
body('eventId').optional({ nullable: true }).isInt({ min: 1 }), body('contractId').optional({ nullable: true }).isInt({ min: 1 }),
body('markupType').optional().isIn(expenseService.MARKUP_TYPES)],
handleAsync(async (req, res) => {
validateRequest(req);
return successResponse(res, await expenseService.rebillExpense(toInt(req.params.id), req.body, req.admin.id), 201, 'Expense invoiced');
}));
// Mark an expense paid / settled (manual).
router.post('/:id/paid', requireExpenses, requirePermission('accounting.manage'),
[param('id').isInt({ min: 1 }), body('paid').isBoolean(), body('paymentMethod').optional({ nullable: true }).isIn(expenseService.PAYMENT_METHODS)],
handleAsync(async (req, res) => {
validateRequest(req);
const expense = await expenseService.markExpensePaid(toInt(req.params.id), req.body, req.admin.id);
return successResponse(res, { expense });
}));
module.exports = router;
+14 -12
View File
@@ -8,9 +8,11 @@
* GET /pdf → landscape A4 PDF, Content-Disposition: attachment
* GET /csv → RFC-4180 CSV, Content-Disposition: attachment
*
* Reuses the existing `bills` feature flag + `bills.view` permission.
* Tax data is just a different lens on invoice data — admins who can
* read invoices can read the tax report; no new RBAC surface needed.
* Gated by the Accounting master flag + the `taxReport` sub-flag
* (independent of `bills` — Tax export was moved out of CRM into
* Accounting). Still uses the `bills.view` permission: tax data is just
* a different lens on invoice data, so admins who can read invoices can
* read the tax report; no new RBAC surface needed.
*/
const express = require('express');
@@ -23,19 +25,19 @@ const { db } = require('../database/db');
const router = express.Router();
// The tax report has its own dedicated flag (taxReport) — independent
// from `bills` so admins can leave it off until they actually need to
// run the export. The frontend mirrors the dependency rule (bills off
// → taxReport off) but we re-check both server-side for defence in
// depth.
// The tax report now lives under the Accounting master flag and has its
// own dedicated `taxReport` sub-flag — it is INDEPENDENT of `bills`
// (Tax export was moved permanently out of CRM into Accounting). The
// frontend mirrors the dependency rule (accounting off → taxReport off)
// but we re-check both server-side for defence in depth.
async function requireTaxReportFlag(req, res, next) {
try {
const rows = await db('feature_flags').whereIn('key', ['bills', 'taxReport']).select('key', 'value');
const rows = await db('feature_flags').whereIn('key', ['accounting', 'taxReport']).select('key', 'value');
const isOn = (row) => row && (row.value === true || row.value === 1 || row.value === '1');
const bills = isOn(rows.find((r) => r.key === 'bills'));
const accounting = isOn(rows.find((r) => r.key === 'accounting'));
const taxReport = isOn(rows.find((r) => r.key === 'taxReport'));
if (!bills) {
return res.status(403).json({ error: 'Bills feature is disabled', code: 'BILLS_DISABLED' });
if (!accounting) {
return res.status(403).json({ error: 'Accounting feature is disabled', code: 'ACCOUNTING_DISABLED' });
}
if (!taxReport) {
return res.status(403).json({ error: 'Tax report feature is disabled', code: 'TAX_REPORT_DISABLED' });
+74 -1
View File
@@ -337,6 +337,14 @@ function transformExpense(row) {
receiptPath: row.receipt_path,
hasProof: !!row.receipt_path,
taxTreatment: row.tax_treatment,
// invoiced = added to a real client invoice (locks editing); paid = settled.
billedInvoiceId: row.billed_invoice_id,
billedInvoiceLineItemId: row.billed_invoice_line_item_id,
invoiced: !!row.billed_invoice_id,
customerAccountId: row.customer_account_id,
paid: !!row.supplier_paid,
paidAt: row.supplier_paid_at,
paymentMethod: row.payment_method,
status: row.status,
createdAt: row.created_at,
updatedAt: row.updated_at,
@@ -422,7 +430,10 @@ const EXPENSE_EDITABLE = {
};
async function updateExpense(id, payload, adminId, { receiptPath } = {}) {
await getExpense(id);
const existing = await getExpense(id);
if (existing.invoiced) {
throw new AppError('Expense is invoiced — editing is locked', 409, 'EXPENSE_LOCKED');
}
const patch = { updated_at: new Date() };
for (const [camel, snake] of Object.entries(EXPENSE_EDITABLE)) {
if (payload[camel] !== undefined) patch[snake] = payload[camel] === '' ? null : payload[camel];
@@ -433,8 +444,70 @@ async function updateExpense(id, payload, adminId, { receiptPath } = {}) {
return getExpense(id);
}
/** Add an internal expense onto a client invoice (mints a line). Marks it
* invoiced (locks editing) + links the invoice. base = chf amount + markup. */
async function rebillExpense(id, payload, adminId, trx0) {
const run = async (trx) => {
const row = await trx('expenses').where({ id }).first();
if (!row) throw new AppError('Expense not found', 404, 'EXPENSE_NOT_FOUND');
const exp = transformExpense(row);
if (exp.invoiced) throw new AppError('Expense already invoiced', 409, 'ALREADY_INVOICED');
if (!payload.customerAccountId) throw new AppError('customerAccountId is required', 400, 'CUSTOMER_REQUIRED');
const base = exp.chfAmountMinor;
if (base == null) throw new AppError('Expense has no amount to invoice', 400, 'AMOUNT_REQUIRED');
const markup = await resolveMarkup(
{ markupType: row.markup_type, markupPercent: row.markup_percent, markupFlatMinor: row.markup_flat_minor },
payload, payload.contractId, trx,
);
const lineTotal = base + computeMarkupMinor(base, markup);
const label = exp.description || exp.supplierName || 'Aufwand';
const { invoiceIds } = await invoiceService.createInvoice({
customerAccountId: payload.customerAccountId,
eventId: payload.eventId || exp.eventId || null,
lineItems: [{ description: `${label} (Weiterverrechnung)`, quantity: 1, unit_price_minor: lineTotal, discount_percent: 0, line_total_minor: lineTotal }],
}, adminId, trx);
const invoiceId = Array.isArray(invoiceIds) ? invoiceIds[0] : null;
if (!invoiceId) throw new AppError('Failed to create invoice', 500, 'INVOICE_FAILED');
const line = await trx('invoice_line_items').where({ invoice_id: invoiceId }).orderBy('id', 'desc').first('id');
await trx('expenses').where({ id }).update({
billed_invoice_id: invoiceId,
billed_invoice_line_item_id: line ? line.id : null,
billed_at: new Date(),
customer_account_id: payload.customerAccountId,
markup_type: markup.type,
markup_percent: markup.type === 'percent' ? markup.percent : null,
markup_flat_minor: markup.type === 'flat' ? markup.flatMinor : null,
status: 'invoiced',
updated_at: new Date(),
});
await logActivity('expense_invoiced', { expenseId: id, invoiceId }, adminId);
return invoiceId;
};
const invoiceId = trx0 ? await run(trx0) : await db.transaction(run);
return { expense: await getExpense(id), invoiceId };
}
/** Mark an expense paid/settled (manual). */
async function markExpensePaid(id, { paid, paidAt, paymentMethod, paymentReference }, adminId) {
await getExpense(id);
if (paymentMethod && !PAYMENT_METHODS.includes(paymentMethod)) {
throw new AppError(`paymentMethod must be one of ${PAYMENT_METHODS.join(', ')}`, 400, 'BAD_PAYMENT_METHOD');
}
await db('expenses').where({ id }).update({
supplier_paid: !!paid,
supplier_paid_at: paid ? (paidAt ? new Date(paidAt) : new Date()) : null,
payment_method: paid ? (paymentMethod || null) : null,
payment_reference: paid ? (paymentReference || null) : null,
updated_at: new Date(),
});
await logActivity('expense_paid', { expenseId: id, paid: !!paid }, adminId);
return getExpense(id);
}
module.exports = {
getAccountingSettings,
rebillExpense,
markExpensePaid,
// incoming invoices
recordInboundDocument,
getInbound,