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 });
|
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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -8,9 +8,11 @@
|
|||||||
* GET /pdf → landscape A4 PDF, Content-Disposition: attachment
|
* GET /pdf → landscape A4 PDF, Content-Disposition: attachment
|
||||||
* GET /csv → RFC-4180 CSV, Content-Disposition: attachment
|
* GET /csv → RFC-4180 CSV, Content-Disposition: attachment
|
||||||
*
|
*
|
||||||
* Reuses the existing `bills` feature flag + `bills.view` permission.
|
* Gated by the Accounting master flag + the `taxReport` sub-flag
|
||||||
* Tax data is just a different lens on invoice data — admins who can
|
* (independent of `bills` — Tax export was moved out of CRM into
|
||||||
* read invoices can read the tax report; no new RBAC surface needed.
|
* 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');
|
const express = require('express');
|
||||||
@@ -23,19 +25,19 @@ const { db } = require('../database/db');
|
|||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// The tax report has its own dedicated flag (taxReport) — independent
|
// The tax report now lives under the Accounting master flag and has its
|
||||||
// from `bills` so admins can leave it off until they actually need to
|
// own dedicated `taxReport` sub-flag — it is INDEPENDENT of `bills`
|
||||||
// run the export. The frontend mirrors the dependency rule (bills off
|
// (Tax export was moved permanently out of CRM into Accounting). The
|
||||||
// → taxReport off) but we re-check both server-side for defence in
|
// frontend mirrors the dependency rule (accounting off → taxReport off)
|
||||||
// depth.
|
// but we re-check both server-side for defence in depth.
|
||||||
async function requireTaxReportFlag(req, res, next) {
|
async function requireTaxReportFlag(req, res, next) {
|
||||||
try {
|
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 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'));
|
const taxReport = isOn(rows.find((r) => r.key === 'taxReport'));
|
||||||
if (!bills) {
|
if (!accounting) {
|
||||||
return res.status(403).json({ error: 'Bills feature is disabled', code: 'BILLS_DISABLED' });
|
return res.status(403).json({ error: 'Accounting feature is disabled', code: 'ACCOUNTING_DISABLED' });
|
||||||
}
|
}
|
||||||
if (!taxReport) {
|
if (!taxReport) {
|
||||||
return res.status(403).json({ error: 'Tax report feature is disabled', code: 'TAX_REPORT_DISABLED' });
|
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,
|
receiptPath: row.receipt_path,
|
||||||
hasProof: !!row.receipt_path,
|
hasProof: !!row.receipt_path,
|
||||||
taxTreatment: row.tax_treatment,
|
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,
|
status: row.status,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
@@ -422,7 +430,10 @@ const EXPENSE_EDITABLE = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function updateExpense(id, payload, adminId, { receiptPath } = {}) {
|
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() };
|
const patch = { updated_at: new Date() };
|
||||||
for (const [camel, snake] of Object.entries(EXPENSE_EDITABLE)) {
|
for (const [camel, snake] of Object.entries(EXPENSE_EDITABLE)) {
|
||||||
if (payload[camel] !== undefined) patch[snake] = payload[camel] === '' ? null : payload[camel];
|
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);
|
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 = {
|
module.exports = {
|
||||||
getAccountingSettings,
|
getAccountingSettings,
|
||||||
|
rebillExpense,
|
||||||
|
markExpensePaid,
|
||||||
// incoming invoices
|
// incoming invoices
|
||||||
recordInboundDocument,
|
recordInboundDocument,
|
||||||
getInbound,
|
getInbound,
|
||||||
|
|||||||
Reference in New Issue
Block a user