diff --git a/backend/src/routes/adminExpenses.js b/backend/src/routes/adminExpenses.js index 8c6c761a..a79923f5 100644 --- a/backend/src/routes/adminExpenses.js +++ b/backend/src/routes/adminExpenses.js @@ -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; diff --git a/backend/src/routes/adminTaxReport.js b/backend/src/routes/adminTaxReport.js index af3a29fd..dff08597 100644 --- a/backend/src/routes/adminTaxReport.js +++ b/backend/src/routes/adminTaxReport.js @@ -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' }); diff --git a/backend/src/services/expenseService.js b/backend/src/services/expenseService.js index 0157d0b8..fdff8ab6 100644 --- a/backend/src/services/expenseService.js +++ b/backend/src/services/expenseService.js @@ -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,