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