feat(accounting): backend rework - incoming invoices vs internal expenses (stage 2)
Implements the split decided in review: Incoming invoices (external) - the inbound_documents row IS the payable: - categorizeInbound now UPDATES the document (disposition + tax_treatment + booking event_id (null=company) + category), no derived expense row, so a supplier invoice appears only in the incoming-invoices surface. - rebillInbound mints the client invoice from the document (base = invoice total + markup) and links it on the doc. - markInboundSupplierPayment records supplier payment ON the incoming invoice (mark-paid lives here now). Expenses (internal) - own costs only: - createExpense: kind = amount|mileage|per_diem; amount = quantity x rate (rate from accounting settings, per-entry override; snapshotted); optional proof file; booked to an event or the company; require-proof enforced from settings. No supplier payment, always own-cost. - listExpenses returns internal rows only (inbound_document_id IS NULL). Routes: per-flag gating (incomingInvoices vs expenses; categories on the accounting master); supplier-payment + re-bill moved under /inbound/:id/*; POST/PATCH expenses accept a multipart proof upload; GET /:id/proof streams it (PDF download-only, image inline). getAccountingSettings reads app_settings. Verified: node -c, require-graph, 12 unit tests (markup + expense amount/build). Frontend rework (service + the two UIs + settings tab + category i18n) follows.
This commit is contained in:
@@ -1,80 +1,87 @@
|
||||
/**
|
||||
* Unit tests for the expense money/markup logic — the silently-regressable
|
||||
* bits of the re-bill flow. Pure functions only (no DB), via _internal.
|
||||
* Unit tests for the accounting money logic — re-bill markup (incoming
|
||||
* invoices) and internal-expense amount/build. Pure functions via _internal.
|
||||
*/
|
||||
const expenseService = require('../../src/services/expenseService');
|
||||
|
||||
const { computeMarkupMinor, resolveMarkup, buildExpenseInsert } = expenseService._internal;
|
||||
const { computeMarkupMinor, resolveMarkup, computeExpenseAmount, buildExpenseInsert } = expenseService._internal;
|
||||
|
||||
describe('computeMarkupMinor', () => {
|
||||
it('percent of base, rounded to integer minor units', () => {
|
||||
it('percent of base, rounded', () => {
|
||||
expect(computeMarkupMinor(10000, { type: 'percent', percent: 10 })).toBe(1000);
|
||||
expect(computeMarkupMinor(333, { type: 'percent', percent: 10 })).toBe(33); // 33.3 -> 33
|
||||
expect(computeMarkupMinor(335, { type: 'percent', percent: 10 })).toBe(34); // 33.5 -> 34
|
||||
expect(computeMarkupMinor(333, { type: 'percent', percent: 10 })).toBe(33);
|
||||
expect(computeMarkupMinor(335, { type: 'percent', percent: 10 })).toBe(34);
|
||||
});
|
||||
|
||||
it('flat adds the flat minor amount', () => {
|
||||
it('flat / none', () => {
|
||||
expect(computeMarkupMinor(10000, { type: 'flat', flatMinor: 500 })).toBe(500);
|
||||
});
|
||||
|
||||
it('none / missing values add nothing', () => {
|
||||
expect(computeMarkupMinor(10000, { type: 'none' })).toBe(0);
|
||||
expect(computeMarkupMinor(10000, { type: 'percent', percent: null })).toBe(0);
|
||||
expect(computeMarkupMinor(10000, { type: 'flat', flatMinor: null })).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveMarkup precedence (no contract / no DB)', () => {
|
||||
it('explicit override wins over the expense clause', async () => {
|
||||
const expense = { markupType: 'flat', markupFlatMinor: 999 };
|
||||
const override = { markupType: 'percent', markupPercent: 5 };
|
||||
await expect(resolveMarkup(expense, override, null, null))
|
||||
it('override > source clause', async () => {
|
||||
await expect(resolveMarkup({ markupType: 'flat', markupFlatMinor: 999 }, { markupType: 'percent', markupPercent: 5 }, null, null))
|
||||
.resolves.toEqual({ type: 'percent', percent: 5, flatMinor: null });
|
||||
});
|
||||
|
||||
it("falls back to the expense's own clause when no override", async () => {
|
||||
const expense = { markupType: 'flat', markupFlatMinor: 200 };
|
||||
await expect(resolveMarkup(expense, {}, null, null))
|
||||
it("source clause when no override", async () => {
|
||||
await expect(resolveMarkup({ markupType: 'flat', markupFlatMinor: 200 }, {}, null, null))
|
||||
.resolves.toEqual({ type: 'flat', percent: null, flatMinor: 200 });
|
||||
});
|
||||
|
||||
it('defaults to none when nothing is set', async () => {
|
||||
it('none when nothing set', async () => {
|
||||
await expect(resolveMarkup({ markupType: 'none' }, {}, null, null))
|
||||
.resolves.toEqual({ type: 'none', percent: null, flatMinor: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildExpenseInsert', () => {
|
||||
it('rejects an unknown disposition', () => {
|
||||
expect(() => buildExpenseInsert({ disposition: 'bogus' }, 1)).toThrow(/disposition/);
|
||||
describe('computeExpenseAmount', () => {
|
||||
it('mileage / per-diem = quantity x rate, rounded', () => {
|
||||
expect(computeExpenseAmount('mileage', 42, 70, null)).toBe(2940); // 42 km x CHF 0.70
|
||||
expect(computeExpenseAmount('per_diem', 3, 8000, null)).toBe(24000); // 3 days x CHF 80
|
||||
expect(computeExpenseAmount('mileage', 10.5, 71, null)).toBe(746); // 745.5 -> 746
|
||||
});
|
||||
it('amount = the entered minor amount', () => {
|
||||
expect(computeExpenseAmount('amount', null, null, 5000)).toBe(5000);
|
||||
});
|
||||
it('null when quantity or rate missing', () => {
|
||||
expect(computeExpenseAmount('mileage', null, 70, null)).toBeNull();
|
||||
expect(computeExpenseAmount('mileage', 42, null, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults tax_treatment to domestic and status to open', () => {
|
||||
const row = buildExpenseInsert({ disposition: 'eigener_aufwand' }, 7);
|
||||
describe('buildExpenseInsert (internal expense)', () => {
|
||||
it('defaults: kind=amount, disposition=eigener_aufwand, tax=domestic, status=open', () => {
|
||||
const row = buildExpenseInsert({ chfAmountMinor: 5000 }, 7);
|
||||
expect(row.kind).toBe('amount');
|
||||
expect(row.disposition).toBe('eigener_aufwand');
|
||||
expect(row.tax_treatment).toBe('domestic');
|
||||
expect(row.status).toBe('open');
|
||||
expect(row.chf_amount_minor).toBe(5000);
|
||||
expect(row.created_by_admin_id).toBe(7);
|
||||
expect(row.inbound_document_id).toBeNull();
|
||||
});
|
||||
|
||||
it('declined disposition sets status=declined + keeps the reason', () => {
|
||||
const row = buildExpenseInsert({ disposition: 'abgelehnt', declineReason: 'not ours' }, 1);
|
||||
expect(row.status).toBe('declined');
|
||||
expect(row.decline_reason).toBe('not ours');
|
||||
it('mileage uses the override rate, else the settings km rate', () => {
|
||||
const withDefault = buildExpenseInsert({ kind: 'mileage', quantity: 42 }, 1, { kmRateMinor: 70 });
|
||||
expect(withDefault.rate_minor).toBe(70);
|
||||
expect(withDefault.chf_amount_minor).toBe(2940);
|
||||
|
||||
const withOverride = buildExpenseInsert({ kind: 'mileage', quantity: 42, rateMinor: 100 }, 1, { kmRateMinor: 70 });
|
||||
expect(withOverride.rate_minor).toBe(100);
|
||||
expect(withOverride.chf_amount_minor).toBe(4200);
|
||||
});
|
||||
|
||||
it('only persists the markup field that matches the markup type', () => {
|
||||
const pct = buildExpenseInsert({ disposition: 'rebill', markupType: 'percent', markupPercent: 12, markupFlatMinor: 500 }, 1);
|
||||
expect(pct.markup_percent).toBe(12);
|
||||
expect(pct.markup_flat_minor).toBeNull();
|
||||
|
||||
const flat = buildExpenseInsert({ disposition: 'rebill', markupType: 'flat', markupPercent: 12, markupFlatMinor: 500 }, 1);
|
||||
expect(flat.markup_flat_minor).toBe(500);
|
||||
expect(flat.markup_percent).toBeNull();
|
||||
it('per_diem uses days x per-diem rate', () => {
|
||||
const row = buildExpenseInsert({ kind: 'per_diem', quantity: 2 }, 1, { perDiemRateMinor: 8000 });
|
||||
expect(row.rate_minor).toBe(8000);
|
||||
expect(row.chf_amount_minor).toBe(16000);
|
||||
});
|
||||
|
||||
it('parked flag maps to status=parked', () => {
|
||||
const row = buildExpenseInsert({ disposition: 'rebill', unbilledParked: true }, 1);
|
||||
expect(row.status).toBe('parked');
|
||||
expect(row.unbilled_parked).toBe(true);
|
||||
it('event_id null = booked to company; proof path carried', () => {
|
||||
const company = buildExpenseInsert({ kind: 'amount', chfAmountMinor: 100 }, 1, { receiptPath: '/p/x.pdf' });
|
||||
expect(company.event_id).toBeNull();
|
||||
expect(company.receipt_path).toBe('/p/x.pdf');
|
||||
const evt = buildExpenseInsert({ kind: 'amount', chfAmountMinor: 100, eventId: 9 }, 1);
|
||||
expect(evt.event_id).toBe(9);
|
||||
});
|
||||
});
|
||||
|
||||
+119
-129
@@ -1,134 +1,119 @@
|
||||
/**
|
||||
* Admin Accounting routes — inbound supplier invoices + expenses + re-bill.
|
||||
* Admin Accounting routes.
|
||||
*
|
||||
* Gated by the `accounting` feature flag and `accounting.view` /
|
||||
* `accounting.manage` permissions. camelCase API ↔ camelCase service payloads
|
||||
* (the service maps to snake_case columns). Money is integer minor units.
|
||||
* /inbound/* → Incoming invoices (external supplier invoices). Gated by the
|
||||
* `incomingInvoices` flag. Disposition, supplier-payment and
|
||||
* re-bill all act on the document itself.
|
||||
* / → Expenses (internal). Gated by the `expenses` flag. Create
|
||||
* accepts an optional proof upload (required when the accounting
|
||||
* setting says so).
|
||||
* /categories → expense categories. Gated by the `accounting` master.
|
||||
*
|
||||
* camelCase API; money in integer minor units.
|
||||
*/
|
||||
const express = require('express');
|
||||
const { body, param, query } = require('express-validator');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { createReadStream } = require('fs');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||||
const { getStoragePath } = require('../config/storage');
|
||||
const { createReadStream } = require('fs');
|
||||
const { assertPathInside } = require('../utils/safePath');
|
||||
const { db } = require('../database/db');
|
||||
const expenseService = require('../services/expenseService');
|
||||
const rasterizeService = require('../services/rasterizeService');
|
||||
const expenseCategoriesService = require('../services/expenseCategoriesService');
|
||||
const rasterizeService = require('../services/rasterizeService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Inbound documents accept PDFs AND images (phone/tablet camera capture).
|
||||
const inboundStorage = multer.diskStorage({
|
||||
const ALLOWED_MIME = ['application/pdf', 'image/jpeg', 'image/png'];
|
||||
|
||||
function diskUpload(subdir) {
|
||||
return multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: async (_req, _file, cb) => {
|
||||
const year = new Date().getFullYear();
|
||||
const dir = path.join(getStoragePath(), 'business-docs', 'inbound', String(year));
|
||||
const dir = path.join(getStoragePath(), 'business-docs', subdir, String(new Date().getFullYear()));
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
cb(null, dir);
|
||||
},
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname) || '';
|
||||
cb(null, `inbound-${Date.now()}${ext}`);
|
||||
},
|
||||
});
|
||||
const INBOUND_MIME = ['application/pdf', 'image/jpeg', 'image/png'];
|
||||
const inboundUpload = multer({
|
||||
storage: inboundStorage,
|
||||
limits: { fileSize: 15 * 1024 * 1024 }, // 15 MB — camera photos run large
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (INBOUND_MIME.includes(file.mimetype)) return cb(null, true);
|
||||
return cb(new Error('Only PDF, JPEG or PNG files are allowed'));
|
||||
},
|
||||
filename: (_req, file, cb) => cb(null, `${subdir.split('/').pop()}-${Date.now()}${path.extname(file.originalname) || ''}`),
|
||||
}),
|
||||
limits: { fileSize: 15 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) => (ALLOWED_MIME.includes(file.mimetype) ? cb(null, true) : cb(new Error('Only PDF, JPEG or PNG files are allowed'))),
|
||||
});
|
||||
}
|
||||
const inboundUpload = diskUpload('inbound');
|
||||
const proofUpload = diskUpload('expenses/proof');
|
||||
|
||||
// Gated on the `incomingInvoices` sub-feature, which the feature-flag
|
||||
// dependency rules force OFF whenever the `accounting` master is off — so
|
||||
// this single check covers both.
|
||||
async function requireIncomingInvoicesFlag(req, res, next) {
|
||||
function requireFlag(key, code) {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
const row = await db('feature_flags').where({ key: 'incomingInvoices' }).first();
|
||||
const row = await db('feature_flags').where({ key }).first();
|
||||
const enabled = row && (row.value === true || row.value === 1 || row.value === '1');
|
||||
if (!enabled) return res.status(403).json({ error: 'Incoming invoices feature is disabled', code: 'INCOMING_INVOICES_DISABLED' });
|
||||
if (!enabled) return res.status(403).json({ error: `${key} feature is disabled`, code });
|
||||
return next();
|
||||
} catch (err) { return next(err); }
|
||||
};
|
||||
}
|
||||
const requireIncoming = requireFlag('incomingInvoices', 'INCOMING_INVOICES_DISABLED');
|
||||
const requireExpenses = requireFlag('expenses', 'EXPENSES_DISABLED');
|
||||
const requireAccounting = requireFlag('accounting', 'ACCOUNTING_DISABLED');
|
||||
|
||||
router.use(adminAuth);
|
||||
router.use(requireIncomingInvoicesFlag);
|
||||
|
||||
// ── Expense categories (literal path — register BEFORE '/:id') ──────────────
|
||||
router.get('/categories', requirePermission('accounting.view'), handleAsync(async (_req, res) => {
|
||||
return successResponse(res, { items: await expenseCategoriesService.list() });
|
||||
}));
|
||||
const toInt = (v) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n : undefined; };
|
||||
|
||||
router.post('/categories', requirePermission('accounting.manage'),
|
||||
// ── Expense categories (accounting master) ──────────────────────────────────
|
||||
router.get('/categories', requireAccounting, requirePermission('accounting.view'), handleAsync(async (_req, res) =>
|
||||
successResponse(res, { items: await expenseCategoriesService.list() })));
|
||||
|
||||
router.post('/categories', requireAccounting, requirePermission('accounting.manage'),
|
||||
[body('name').isString().isLength({ min: 1, max: 128 }), body('color').optional({ nullable: true }).isString()],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const cat = await expenseCategoriesService.create(req.body, req.admin.id);
|
||||
return successResponse(res, { category: cat }, 201, 'Category created');
|
||||
return successResponse(res, { category: await expenseCategoriesService.create(req.body, req.admin.id) }, 201, 'Category created');
|
||||
}));
|
||||
|
||||
router.patch('/categories/:id', requirePermission('accounting.manage'),
|
||||
router.patch('/categories/:id', requireAccounting, requirePermission('accounting.manage'),
|
||||
[param('id').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const cat = await expenseCategoriesService.update(parseInt(req.params.id, 10), req.body);
|
||||
return successResponse(res, { category: cat });
|
||||
return successResponse(res, { category: await expenseCategoriesService.update(toInt(req.params.id), req.body) });
|
||||
}));
|
||||
|
||||
router.delete('/categories/:id', requirePermission('accounting.manage'),
|
||||
router.delete('/categories/:id', requireAccounting, requirePermission('accounting.manage'),
|
||||
[param('id').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
return successResponse(res, await expenseCategoriesService.remove(parseInt(req.params.id, 10)));
|
||||
return successResponse(res, await expenseCategoriesService.remove(toInt(req.params.id)));
|
||||
}));
|
||||
|
||||
// ── Inbound documents (literal path — register BEFORE '/:id') ───────────────
|
||||
router.post('/inbound', requirePermission('accounting.manage'),
|
||||
// ── Incoming invoices (external) ────────────────────────────────────────────
|
||||
router.post('/inbound', requireIncoming, requirePermission('accounting.manage'),
|
||||
inboundUpload.single('file'),
|
||||
[body('source').optional().isIn(['upload', 'camera', 'email', 'manual'])],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded', code: 'NO_FILE' });
|
||||
const doc = await expenseService.recordInboundDocument({
|
||||
source: req.body.source || 'upload',
|
||||
filePath: req.file.path,
|
||||
originalFilename: req.file.originalname,
|
||||
mimeType: req.file.mimetype,
|
||||
const document = await expenseService.recordInboundDocument({
|
||||
source: req.body.source || 'upload', filePath: req.file.path,
|
||||
originalFilename: req.file.originalname, mimeType: req.file.mimetype,
|
||||
}, req.admin.id);
|
||||
return successResponse(res, { document: doc }, 201, 'Document captured');
|
||||
return successResponse(res, { document }, 201, 'Document captured');
|
||||
}));
|
||||
|
||||
router.get('/inbound', requirePermission('accounting.view'),
|
||||
router.get('/inbound', requireIncoming, requirePermission('accounting.view'),
|
||||
[query('status').optional().isString(), query('page').optional().isInt({ min: 1 }), query('pageSize').optional().isInt({ min: 1, max: 100 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
return successResponse(res, await expenseService.listInbound(req.query));
|
||||
}));
|
||||
handleAsync(async (req, res) => { validateRequest(req); return successResponse(res, await expenseService.listInbound(req.query)); }));
|
||||
|
||||
router.get('/inbound/:id', requirePermission('accounting.view'),
|
||||
router.get('/inbound/:id/file', requireIncoming, requirePermission('accounting.view'),
|
||||
[param('id').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
return successResponse(res, { document: await expenseService.getInbound(parseInt(req.params.id, 10)) });
|
||||
}));
|
||||
|
||||
// Stream the stored file. Images render inline (already flat raster); PDFs are
|
||||
// served as a DOWNLOAD only and are NEVER rendered inline in the browser —
|
||||
// inline PDF preview goes through the rasterised /page/:n images below so a
|
||||
// malicious PDF can't execute JS or phone home in the admin's session.
|
||||
router.get('/inbound/:id/file', requirePermission('accounting.view'),
|
||||
[param('id').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const row = await db('inbound_documents').where({ id: parseInt(req.params.id, 10) })
|
||||
.first('file_path', 'mime_type');
|
||||
const row = await db('inbound_documents').where({ id: toInt(req.params.id) }).first('file_path', 'mime_type');
|
||||
if (!row || !row.file_path) return res.status(404).json({ error: 'File not found', code: 'NO_FILE' });
|
||||
const safe = assertPathInside(row.file_path, [path.join(getStoragePath(), 'business-docs')]);
|
||||
const isPdf = (row.mime_type || '').includes('pdf');
|
||||
@@ -139,18 +124,15 @@ router.get('/inbound/:id/file', requirePermission('accounting.view'),
|
||||
createReadStream(safe).pipe(res);
|
||||
}));
|
||||
|
||||
// Rasterised PDF page (PNG) — the ONLY way a PDF is shown in-browser. The raw
|
||||
// PDF never reaches the client. CSP-locked + nosniff.
|
||||
router.get('/inbound/:id/page/:n', requirePermission('accounting.view'),
|
||||
router.get('/inbound/:id/page/:n', requireIncoming, requirePermission('accounting.view'),
|
||||
[param('id').isInt({ min: 1 }), param('n').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const id = toInt(req.params.id);
|
||||
const row = await db('inbound_documents').where({ id }).first('file_path', 'mime_type', 'page_count');
|
||||
if (!row || !row.file_path) return res.status(404).json({ error: 'File not found', code: 'NO_FILE' });
|
||||
if (!(row.mime_type || '').includes('pdf')) return res.status(415).json({ error: 'Not a PDF', code: 'NOT_PDF' });
|
||||
const maxPage = row.page_count || 1;
|
||||
const page = Math.min(Math.max(1, parseInt(req.params.n, 10)), maxPage);
|
||||
const page = Math.min(Math.max(1, toInt(req.params.n)), row.page_count || 1);
|
||||
const srcPdf = assertPathInside(row.file_path, [path.join(getStoragePath(), 'business-docs')]);
|
||||
const pngPath = await rasterizeService.getRenderedPagePath(id, srcPdf, page);
|
||||
const safePng = assertPathInside(pngPath, [path.join(getStoragePath(), 'business-docs')]);
|
||||
@@ -161,72 +143,80 @@ router.get('/inbound/:id/page/:n', requirePermission('accounting.view'),
|
||||
createReadStream(safePng).pipe(res);
|
||||
}));
|
||||
|
||||
router.patch('/inbound/:id', requirePermission('accounting.manage'),
|
||||
router.get('/inbound/:id', requireIncoming, requirePermission('accounting.view'),
|
||||
[param('id').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const doc = await expenseService.updateInbound(parseInt(req.params.id, 10), req.body, req.admin.id);
|
||||
return successResponse(res, { document: doc });
|
||||
}));
|
||||
handleAsync(async (req, res) => { validateRequest(req); return successResponse(res, { document: await expenseService.getInbound(toInt(req.params.id)) }); }));
|
||||
|
||||
router.post('/inbound/:id/categorize', requirePermission('accounting.manage'),
|
||||
router.patch('/inbound/:id', requireIncoming, requirePermission('accounting.manage'),
|
||||
[param('id').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => { validateRequest(req); return successResponse(res, { document: await expenseService.updateInbound(toInt(req.params.id), req.body, req.admin.id) }); }));
|
||||
|
||||
router.post('/inbound/:id/categorize', requireIncoming, requirePermission('accounting.manage'),
|
||||
[param('id').isInt({ min: 1 }), body('disposition').isIn(expenseService.DISPOSITIONS)],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const expense = await expenseService.categorizeInbound(parseInt(req.params.id, 10), req.body, req.admin.id);
|
||||
return successResponse(res, { expense }, 201, 'Expense created');
|
||||
}));
|
||||
handleAsync(async (req, res) => { validateRequest(req); return successResponse(res, { document: await expenseService.categorizeInbound(toInt(req.params.id), req.body, req.admin.id) }, 200, 'Categorized'); }));
|
||||
|
||||
// ── Expenses ────────────────────────────────────────────────────────────────
|
||||
router.get('/', requirePermission('accounting.view'),
|
||||
[query('status').optional().isString(), query('disposition').optional().isIn(expenseService.DISPOSITIONS),
|
||||
query('customerAccountId').optional().isInt({ min: 1 }), query('eventId').optional().isInt({ min: 1 }),
|
||||
query('page').optional().isInt({ min: 1 }), query('pageSize').optional().isInt({ min: 1, max: 100 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
return successResponse(res, await expenseService.listExpenses(req.query));
|
||||
}));
|
||||
|
||||
router.post('/', requirePermission('accounting.manage'),
|
||||
[body('disposition').isIn(expenseService.DISPOSITIONS)],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const expense = await expenseService.createManualExpense(req.body, req.admin.id);
|
||||
return successResponse(res, { expense }, 201, 'Expense created');
|
||||
}));
|
||||
|
||||
router.get('/:id', requirePermission('accounting.view'),
|
||||
[param('id').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
return successResponse(res, { expense: await expenseService.getExpense(parseInt(req.params.id, 10)) });
|
||||
}));
|
||||
|
||||
router.patch('/:id', requirePermission('accounting.manage'),
|
||||
[param('id').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const expense = await expenseService.updateExpense(parseInt(req.params.id, 10), req.body, req.admin.id);
|
||||
return successResponse(res, { expense });
|
||||
}));
|
||||
|
||||
router.post('/:id/rebill', requirePermission('accounting.manage'),
|
||||
router.post('/inbound/:id/rebill', requireIncoming, 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('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.rebillInbound(toInt(req.params.id), req.body, req.admin.id), 201, 'Re-billed'); }));
|
||||
|
||||
router.post('/inbound/:id/supplier-payment', requireIncoming, 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); return successResponse(res, { document: await expenseService.markInboundSupplierPayment(toInt(req.params.id), req.body, req.admin.id) }); }));
|
||||
|
||||
// ── Expenses (internal) ─────────────────────────────────────────────────────
|
||||
router.get('/', requireExpenses, requirePermission('accounting.view'),
|
||||
[query('kind').optional().isIn(expenseService.EXPENSE_KINDS), query('categoryId').optional().isInt({ min: 1 }),
|
||||
query('page').optional().isInt({ min: 1 }), query('pageSize').optional().isInt({ min: 1, max: 100 })],
|
||||
handleAsync(async (req, res) => { validateRequest(req); return successResponse(res, await expenseService.listExpenses(req.query)); }));
|
||||
|
||||
router.post('/', requireExpenses, requirePermission('accounting.manage'),
|
||||
proofUpload.single('proof'),
|
||||
[body('kind').optional().isIn(expenseService.EXPENSE_KINDS)],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const result = await expenseService.rebillToEvent(parseInt(req.params.id, 10), req.body, req.admin.id);
|
||||
return successResponse(res, result, 201, 'Expense re-billed');
|
||||
const b = req.body;
|
||||
const payload = {
|
||||
kind: b.kind || 'amount',
|
||||
quantity: b.quantity !== undefined && b.quantity !== '' ? Number(b.quantity) : undefined,
|
||||
rateMinor: toInt(b.rateMinor),
|
||||
chfAmountMinor: toInt(b.chfAmountMinor),
|
||||
eventId: toInt(b.eventId) || null,
|
||||
categoryId: toInt(b.categoryId) || null,
|
||||
supplierName: b.supplierName || null,
|
||||
description: b.description || null,
|
||||
taxTreatment: b.taxTreatment,
|
||||
};
|
||||
const expense = await expenseService.createExpense(payload, req.admin.id, { receiptPath: req.file ? req.file.path : null });
|
||||
return successResponse(res, { expense }, 201, 'Expense created');
|
||||
}));
|
||||
|
||||
router.post('/:id/supplier-payment', requirePermission('accounting.manage'),
|
||||
[param('id').isInt({ min: 1 }), body('paid').isBoolean(),
|
||||
body('paymentMethod').optional({ nullable: true }).isIn(expenseService.PAYMENT_METHODS)],
|
||||
router.get('/:id/proof', requireExpenses, requirePermission('accounting.view'),
|
||||
[param('id').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const expense = await expenseService.setSupplierPayment(parseInt(req.params.id, 10), req.body, req.admin.id);
|
||||
const row = await db('expenses').where({ id: toInt(req.params.id) }).first('receipt_path');
|
||||
if (!row || !row.receipt_path) return res.status(404).json({ error: 'No proof', code: 'NO_PROOF' });
|
||||
const safe = assertPathInside(row.receipt_path, [path.join(getStoragePath(), 'business-docs')]);
|
||||
const isPdf = safe.toLowerCase().endsWith('.pdf');
|
||||
res.setHeader('Content-Type', isPdf ? 'application/pdf' : 'application/octet-stream');
|
||||
res.setHeader('Content-Disposition', isPdf ? 'attachment' : 'inline');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
if (!isPdf) res.setHeader('Content-Security-Policy', "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'");
|
||||
createReadStream(safe).pipe(res);
|
||||
}));
|
||||
|
||||
router.get('/:id', requireExpenses, requirePermission('accounting.view'),
|
||||
[param('id').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => { validateRequest(req); return successResponse(res, { expense: await expenseService.getExpense(toInt(req.params.id)) }); }));
|
||||
|
||||
router.patch('/:id', requireExpenses, requirePermission('accounting.manage'),
|
||||
proofUpload.single('proof'),
|
||||
[param('id').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const expense = await expenseService.updateExpense(toInt(req.params.id), req.body, req.admin.id, { receiptPath: req.file ? req.file.path : null });
|
||||
return successResponse(res, { expense });
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
/**
|
||||
* Expense / inbound-document service (migration 124).
|
||||
* Accounting service — two separate concepts (split in migration 126):
|
||||
*
|
||||
* Captures received supplier invoices (upload / camera), lets the admin give
|
||||
* them a disposition, and — for "rebill" (Weiterverrechnung) — folds the cost
|
||||
* onto a client's event invoice as a line item. The re-bill flow mirrors
|
||||
* customerHoursService.billUnbilledEntries: resolve amount + markup, then
|
||||
* createInvoice({ customerAccountId, eventId, lineItems }) and stamp the
|
||||
* source row billed.
|
||||
* INCOMING INVOICES (external) → the `inbound_documents` row IS the payable.
|
||||
* It carries its own disposition, supplier-payment, booking (event_id, NULL
|
||||
* = company) and re-bill linkage. A supplier invoice never creates an
|
||||
* `expenses` row, so it appears ONLY in the incoming-invoices surface.
|
||||
*
|
||||
* Money is integer minor units throughout. The QR-encoded amount on an
|
||||
* inbound document is stored separately and NEVER used as the authoritative
|
||||
* total. All VAT/tax handling here is v1 (capture-only) and must be reviewed
|
||||
* with a Treuhänder before being relied upon.
|
||||
* EXPENSES (internal) → `expenses` rows are own costs entered by
|
||||
* staff: kind = amount | mileage(km) | per_diem, amount = quantity x rate
|
||||
* (rate from accounting settings, per-entry override), optional proof file,
|
||||
* booked to an event or the company. No supplier payment (you incur these).
|
||||
*
|
||||
* Money is integer minor units. VAT/tax handling is v1 (capture only) — verify
|
||||
* with a Treuhaender.
|
||||
*/
|
||||
const crypto = require('crypto');
|
||||
const fsp = require('fs').promises;
|
||||
const { PDFDocument } = require('pdf-lib');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { AppError } = require('../utils/errors');
|
||||
const { hasColumnCached } = require('../utils/schemaCache');
|
||||
const logger = require('../utils/logger');
|
||||
const invoiceService = require('./invoiceService');
|
||||
|
||||
@@ -26,8 +26,8 @@ const DISPOSITIONS = ['rebill', 'durchlaufend', 'eigener_aufwand', 'duplikat', '
|
||||
const TAX_TREATMENTS = ['domestic', 'reverse_charge_service', 'foreign_vat_non_reclaimable', 'import_goods'];
|
||||
const MARKUP_TYPES = ['none', 'percent', 'flat'];
|
||||
const PAYMENT_METHODS = ['bank_transfer', 'cash', 'twint', 'paypal', 'card', 'other'];
|
||||
const EXPENSE_KINDS = ['amount', 'mileage', 'per_diem'];
|
||||
|
||||
// Disposition → inbound_documents.status once categorised.
|
||||
const DISPOSITION_DOC_STATUS = {
|
||||
rebill: 'categorized',
|
||||
durchlaufend: 'categorized',
|
||||
@@ -39,14 +39,30 @@ const DISPOSITION_DOC_STATUS = {
|
||||
function toIsoDate(v) {
|
||||
if (!v) return null;
|
||||
if (v instanceof Date) return v.toISOString().slice(0, 10);
|
||||
return String(v).slice(0, 10); // PG datetime or SQLite bare date both normalise here
|
||||
return String(v).slice(0, 10);
|
||||
}
|
||||
|
||||
function parseTags(raw) {
|
||||
if (!raw) return [];
|
||||
try { const a = JSON.parse(raw); return Array.isArray(a) ? a : []; } catch (_e) { return []; }
|
||||
// ── Accounting settings (app_settings, type 'accounting') ───────────────────
|
||||
async function getAccountingSettings() {
|
||||
const keys = ['accounting_km_rate_minor', 'accounting_per_diem_rate_minor', 'accounting_require_proof'];
|
||||
let rows = [];
|
||||
try {
|
||||
rows = await db('app_settings').whereIn('setting_key', keys).select('setting_key', 'setting_value');
|
||||
} catch (_e) { /* table may not exist in some test harnesses */ }
|
||||
const map = {};
|
||||
for (const r of rows) {
|
||||
let v = r.setting_value;
|
||||
if (typeof v === 'string') { try { v = JSON.parse(v); } catch (_e) { /* keep raw */ } }
|
||||
map[r.setting_key] = v;
|
||||
}
|
||||
return {
|
||||
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',
|
||||
};
|
||||
}
|
||||
|
||||
// ── Incoming invoices (inbound_documents) ───────────────────────────────────
|
||||
function transformInbound(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
@@ -57,7 +73,6 @@ function transformInbound(row) {
|
||||
status: row.status,
|
||||
parseStatus: row.parse_status,
|
||||
parseMethod: row.parse_method,
|
||||
parseError: row.parse_error,
|
||||
pageCount: row.page_count,
|
||||
supplierName: row.supplier_name,
|
||||
invoiceNumber: row.invoice_number,
|
||||
@@ -71,45 +86,21 @@ function transformInbound(row) {
|
||||
iban: row.iban,
|
||||
paymentReference: row.payment_reference,
|
||||
duplicateOfId: row.duplicate_of_id,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function transformExpense(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
inboundDocumentId: row.inbound_document_id,
|
||||
// classification + booking (migration 126)
|
||||
disposition: row.disposition,
|
||||
taxTreatment: row.tax_treatment,
|
||||
eventId: row.event_id,
|
||||
customerAccountId: row.customer_account_id,
|
||||
supplierName: row.supplier_name,
|
||||
description: row.description,
|
||||
originalCurrency: row.original_currency,
|
||||
originalAmountMinor: row.original_amount_minor,
|
||||
chfAmountMinor: row.chf_amount_minor,
|
||||
fxLocked: !!row.fx_locked,
|
||||
fxLockReason: row.fx_lock_reason,
|
||||
netAmountMinor: row.net_amount_minor,
|
||||
vatAmountMinor: row.vat_amount_minor,
|
||||
grossAmountMinor: row.gross_amount_minor,
|
||||
categoryId: row.category_id,
|
||||
markupType: row.markup_type,
|
||||
markupPercent: row.markup_percent != null ? Number(row.markup_percent) : null,
|
||||
markupFlatMinor: row.markup_flat_minor,
|
||||
categoryId: row.category_id,
|
||||
tags: parseTags(row.tags),
|
||||
billedInvoiceId: row.billed_invoice_id,
|
||||
billedInvoiceLineItemId: row.billed_invoice_line_item_id,
|
||||
unbilledParked: !!row.unbilled_parked,
|
||||
billedAt: row.billed_at,
|
||||
// supplier payment (paid on the incoming invoice itself)
|
||||
supplierPaid: !!row.supplier_paid,
|
||||
supplierPaidAt: row.supplier_paid_at,
|
||||
paymentMethod: row.payment_method,
|
||||
paymentReference: row.payment_reference,
|
||||
declineReason: row.decline_reason,
|
||||
status: row.status,
|
||||
supplierPaymentMethod: row.supplier_payment_method,
|
||||
supplierPaymentRef: row.supplier_payment_ref,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
@@ -121,8 +112,6 @@ function clampPage(page, pageSize) {
|
||||
return { p, ps };
|
||||
}
|
||||
|
||||
// Read the file once: SHA-256 (for dedup) + PDF page count (for the
|
||||
// "jump to last page / QR" preview).
|
||||
async function inspectFile(filePath, mimeType) {
|
||||
const buf = await fsp.readFile(filePath);
|
||||
const sha = crypto.createHash('sha256').update(buf).digest('hex');
|
||||
@@ -132,25 +121,18 @@ async function inspectFile(filePath, mimeType) {
|
||||
const pdf = await PDFDocument.load(buf, { updateMetadata: false });
|
||||
pageCount = pdf.getPageCount();
|
||||
} catch (e) {
|
||||
logger.warn?.(`expenseService: could not read PDF page count for ${filePath}: ${e.message}`);
|
||||
logger.warn?.(`expenseService: PDF page count failed for ${filePath}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
return { sha, pageCount };
|
||||
}
|
||||
|
||||
// ── Inbound documents ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Persist a received document (the system of record) and run best-effort
|
||||
* extraction. Duplicates (same SHA-256) are flagged but still stored.
|
||||
*/
|
||||
async function recordInboundDocument({ source, filePath, originalFilename, mimeType }, adminId) {
|
||||
let fileSha256 = null;
|
||||
let pageCount = null;
|
||||
try {
|
||||
const info = await inspectFile(filePath, mimeType);
|
||||
fileSha256 = info.sha;
|
||||
pageCount = info.pageCount;
|
||||
fileSha256 = info.sha; pageCount = info.pageCount;
|
||||
} catch (e) {
|
||||
logger.warn?.(`expenseService: could not inspect ${filePath}: ${e.message}`);
|
||||
}
|
||||
@@ -161,17 +143,6 @@ async function recordInboundDocument({ source, filePath, originalFilename, mimeT
|
||||
if (dup) duplicateOfId = dup.id;
|
||||
}
|
||||
|
||||
// Best-effort extraction (currently a no-op scaffold — see extractionService).
|
||||
let parse = { parsed: false, method: 'none', fields: {} };
|
||||
try {
|
||||
// eslint-disable-next-line global-require
|
||||
const extractionService = require('./extractionService');
|
||||
parse = await extractionService.extract(filePath, mimeType);
|
||||
} catch (e) {
|
||||
parse = { parsed: false, method: 'none', fields: {}, error: e.message };
|
||||
}
|
||||
const f = parse.fields || {};
|
||||
|
||||
const now = new Date();
|
||||
const row = {
|
||||
source: source || 'upload',
|
||||
@@ -180,22 +151,9 @@ async function recordInboundDocument({ source, filePath, originalFilename, mimeT
|
||||
mime_type: mimeType || null,
|
||||
file_sha256: fileSha256,
|
||||
status: duplicateOfId ? 'duplicate' : 'unsorted',
|
||||
parse_status: parse.error ? 'failed' : (parse.parsed ? 'parsed' : 'pending'),
|
||||
parse_method: parse.method || 'none',
|
||||
parse_error: parse.error || null,
|
||||
parse_status: 'pending',
|
||||
parse_method: 'none',
|
||||
page_count: pageCount,
|
||||
supplier_name: f.supplierName || null,
|
||||
invoice_number: f.invoiceNumber || null,
|
||||
invoice_date: f.invoiceDate || null,
|
||||
due_date: f.dueDate || null,
|
||||
currency: f.currency || null,
|
||||
net_amount_minor: Number.isInteger(f.netAmountMinor) ? f.netAmountMinor : null,
|
||||
vat_amount_minor: Number.isInteger(f.vatAmountMinor) ? f.vatAmountMinor : null,
|
||||
total_amount_minor: Number.isInteger(f.totalAmountMinor) ? f.totalAmountMinor : null,
|
||||
qr_amount_minor: Number.isInteger(f.qrAmountMinor) ? f.qrAmountMinor : null,
|
||||
iban: f.iban || null,
|
||||
payment_reference: f.paymentReference || null,
|
||||
raw_parsed: parse.raw ? JSON.stringify(parse.raw) : null,
|
||||
duplicate_of_id: duplicateOfId,
|
||||
created_by_admin_id: adminId || null,
|
||||
created_at: now,
|
||||
@@ -203,13 +161,13 @@ async function recordInboundDocument({ source, filePath, originalFilename, mimeT
|
||||
};
|
||||
const inserted = await db('inbound_documents').insert(row).returning('id');
|
||||
const id = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
|
||||
await logActivity('expense_inbound_captured', { inboundDocumentId: id, source: row.source, duplicate: !!duplicateOfId }, adminId);
|
||||
await logActivity('incoming_invoice_captured', { inboundDocumentId: id, source: row.source, duplicate: !!duplicateOfId }, adminId);
|
||||
return getInbound(id);
|
||||
}
|
||||
|
||||
async function getInbound(id) {
|
||||
const row = await db('inbound_documents').where({ id }).first();
|
||||
if (!row) throw new AppError('Inbound document not found', 404, 'INBOUND_NOT_FOUND');
|
||||
if (!row) throw new AppError('Incoming invoice not found', 404, 'INBOUND_NOT_FOUND');
|
||||
return transformInbound(row);
|
||||
}
|
||||
|
||||
@@ -219,13 +177,8 @@ async function listInbound({ status, page, pageSize } = {}) {
|
||||
if (status) base.where({ status });
|
||||
const countRow = await base.clone().count({ count: '*' }).first();
|
||||
const total = parseInt(countRow?.count || 0, 10);
|
||||
const rows = await base.clone()
|
||||
.orderBy('created_at', 'desc')
|
||||
.limit(ps).offset((p - 1) * ps);
|
||||
return {
|
||||
items: rows.map(transformInbound),
|
||||
pagination: { page: p, pageSize: ps, total, totalPages: Math.ceil(total / ps) },
|
||||
};
|
||||
const rows = await base.clone().orderBy('created_at', 'desc').limit(ps).offset((p - 1) * ps);
|
||||
return { items: rows.map(transformInbound), pagination: { page: p, pageSize: ps, total, totalPages: Math.ceil(total / ps) } };
|
||||
}
|
||||
|
||||
const INBOUND_EDITABLE = {
|
||||
@@ -235,7 +188,6 @@ const INBOUND_EDITABLE = {
|
||||
paymentReference: 'payment_reference',
|
||||
};
|
||||
|
||||
/** Confirm / correct best-effort parsed fields (assist is never blind-trusted). */
|
||||
async function updateInbound(id, payload, adminId) {
|
||||
await getInbound(id);
|
||||
const patch = { updated_at: new Date(), parse_status: 'manual' };
|
||||
@@ -243,50 +195,186 @@ async function updateInbound(id, payload, adminId) {
|
||||
if (payload[camel] !== undefined) patch[snake] = payload[camel] === '' ? null : payload[camel];
|
||||
}
|
||||
await db('inbound_documents').where({ id }).update(patch);
|
||||
await logActivity('expense_inbound_updated', { inboundDocumentId: id }, adminId);
|
||||
await logActivity('incoming_invoice_updated', { inboundDocumentId: id }, adminId);
|
||||
return getInbound(id);
|
||||
}
|
||||
|
||||
// ── Expenses ───────────────────────────────────────────────────────────────
|
||||
// markup helpers (shared with re-bill)
|
||||
async function resolveMarkup(source, override, contractId, trx) {
|
||||
const pick = (type, percent, flatMinor) => ({
|
||||
type: MARKUP_TYPES.includes(type) ? type : 'none',
|
||||
percent: percent != null ? Number(percent) : null,
|
||||
flatMinor: Number.isInteger(flatMinor) ? flatMinor : null,
|
||||
});
|
||||
if (override && override.markupType && override.markupType !== 'none') {
|
||||
return pick(override.markupType, override.markupPercent, override.markupFlatMinor);
|
||||
}
|
||||
if (source && source.markupType && source.markupType !== 'none') {
|
||||
return pick(source.markupType, source.markupPercent, source.markupFlatMinor);
|
||||
}
|
||||
const { hasColumnCached } = require('../utils/schemaCache');
|
||||
if (contractId && (await hasColumnCached('contracts', 'expense_markup_type'))) {
|
||||
const c = await (trx || db)('contracts').where({ id: contractId })
|
||||
.first('expense_markup_type', 'expense_markup_percent', 'expense_markup_flat_minor');
|
||||
if (c && c.expense_markup_type && c.expense_markup_type !== 'none') {
|
||||
return pick(c.expense_markup_type, c.expense_markup_percent, c.expense_markup_flat_minor);
|
||||
}
|
||||
}
|
||||
return pick('none', null, null);
|
||||
}
|
||||
|
||||
function buildExpenseInsert(payload, adminId) {
|
||||
const now = new Date();
|
||||
function computeMarkupMinor(baseMinor, markup) {
|
||||
if (markup.type === 'percent' && markup.percent != null) return Math.round(baseMinor * Number(markup.percent) / 100);
|
||||
if (markup.type === 'flat' && Number.isInteger(markup.flatMinor)) return markup.flatMinor;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Re-bill an incoming invoice to a client (mints an editable scheduled invoice). */
|
||||
async function rebillInbound(id, payload, adminId, trx0) {
|
||||
const run = async (trx) => {
|
||||
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);
|
||||
if (doc.billedInvoiceId) throw new AppError('Already re-billed', 409, 'ALREADY_BILLED');
|
||||
if (!payload.customerAccountId) throw new AppError('customerAccountId is required to re-bill', 400, 'CUSTOMER_REQUIRED');
|
||||
const base = doc.totalAmountMinor != null ? doc.totalAmountMinor : doc.netAmountMinor;
|
||||
if (base == null) throw new AppError('Incoming invoice has no amount to re-bill', 400, 'AMOUNT_REQUIRED');
|
||||
|
||||
const markup = await resolveMarkup(
|
||||
{ markupType: doc.markupType, markupPercent: doc.markupPercent, markupFlatMinor: doc.markupFlatMinor },
|
||||
payload, payload.contractId, trx,
|
||||
);
|
||||
const lineTotal = base + computeMarkupMinor(base, markup);
|
||||
const label = doc.supplierName || 'Weiterverrechnete Auslage';
|
||||
const { invoiceIds } = await invoiceService.createInvoice({
|
||||
customerAccountId: payload.customerAccountId,
|
||||
eventId: payload.eventId || doc.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 the re-bill invoice', 500, 'REBILL_FAILED');
|
||||
const line = await trx('invoice_line_items').where({ invoice_id: invoiceId }).orderBy('id', 'desc').first('id');
|
||||
|
||||
await trx('inbound_documents').where({ id }).update({
|
||||
disposition: 'rebill',
|
||||
status: 'categorized',
|
||||
event_id: payload.eventId || doc.eventId || null,
|
||||
markup_type: markup.type,
|
||||
markup_percent: markup.type === 'percent' ? markup.percent : null,
|
||||
markup_flat_minor: markup.type === 'flat' ? markup.flatMinor : null,
|
||||
billed_invoice_id: invoiceId,
|
||||
billed_invoice_line_item_id: line ? line.id : null,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
await logActivity('incoming_invoice_rebilled', { inboundDocumentId: id, invoiceId }, adminId);
|
||||
return invoiceId;
|
||||
};
|
||||
const invoiceId = trx0 ? await run(trx0) : await db.transaction(run);
|
||||
return { document: await getInbound(id), invoiceId };
|
||||
}
|
||||
|
||||
/** Give an incoming invoice a disposition (updates the document, no expense row). */
|
||||
async function categorizeInbound(id, payload, adminId) {
|
||||
const doc = await getInbound(id);
|
||||
const disposition = payload.disposition;
|
||||
if (!DISPOSITIONS.includes(disposition)) {
|
||||
throw new AppError(`disposition must be one of ${DISPOSITIONS.join(', ')}`, 400, 'BAD_DISPOSITION');
|
||||
}
|
||||
const taxTreatment = payload.taxTreatment && TAX_TREATMENTS.includes(payload.taxTreatment)
|
||||
? payload.taxTreatment : 'domestic';
|
||||
const markupType = payload.markupType && MARKUP_TYPES.includes(payload.markupType)
|
||||
? payload.markupType : 'none';
|
||||
let status = 'open';
|
||||
if (disposition === 'abgelehnt') status = 'declined';
|
||||
else if (payload.unbilledParked) status = 'parked';
|
||||
|
||||
return {
|
||||
inbound_document_id: payload.inboundDocumentId || null,
|
||||
if (disposition === 'rebill') {
|
||||
const { document } = await rebillInbound(id, payload, adminId);
|
||||
// also stamp tax_treatment/category/event from payload
|
||||
await db('inbound_documents').where({ id }).update({
|
||||
tax_treatment: TAX_TREATMENTS.includes(payload.taxTreatment) ? payload.taxTreatment : (document.taxTreatment || 'domestic'),
|
||||
category_id: payload.categoryId || null,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
return getInbound(id);
|
||||
}
|
||||
const patch = {
|
||||
disposition,
|
||||
tax_treatment: taxTreatment,
|
||||
event_id: payload.eventId || null,
|
||||
customer_account_id: payload.customerAccountId || null,
|
||||
tax_treatment: TAX_TREATMENTS.includes(payload.taxTreatment) ? payload.taxTreatment : 'domestic',
|
||||
event_id: payload.eventId || null, // null = company
|
||||
category_id: disposition === 'eigener_aufwand' ? (payload.categoryId || null) : null,
|
||||
status: DISPOSITION_DOC_STATUS[disposition] || 'categorized',
|
||||
updated_at: new Date(),
|
||||
};
|
||||
if (disposition === 'duplikat' && payload.duplicateOfId) patch.duplicate_of_id = payload.duplicateOfId;
|
||||
await db('inbound_documents').where({ id }).update(patch);
|
||||
await logActivity('incoming_invoice_categorized', { inboundDocumentId: id, disposition }, adminId);
|
||||
return getInbound(id);
|
||||
}
|
||||
|
||||
/** Mark the supplier paid on the incoming invoice (the payable lives here). */
|
||||
async function markInboundSupplierPayment(id, { paid, paidAt, paymentMethod, paymentReference }, adminId) {
|
||||
await getInbound(id);
|
||||
if (paymentMethod && !PAYMENT_METHODS.includes(paymentMethod)) {
|
||||
throw new AppError(`paymentMethod must be one of ${PAYMENT_METHODS.join(', ')}`, 400, 'BAD_PAYMENT_METHOD');
|
||||
}
|
||||
await db('inbound_documents').where({ id }).update({
|
||||
supplier_paid: !!paid,
|
||||
supplier_paid_at: paid ? (paidAt ? new Date(paidAt) : new Date()) : null,
|
||||
supplier_payment_method: paid ? (paymentMethod || null) : null,
|
||||
supplier_payment_ref: paid ? (paymentReference || null) : null,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
await logActivity('incoming_invoice_supplier_payment', { inboundDocumentId: id, paid: !!paid }, adminId);
|
||||
return getInbound(id);
|
||||
}
|
||||
|
||||
// ── Expenses (internal) ─────────────────────────────────────────────────────
|
||||
function transformExpense(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind || 'amount',
|
||||
quantity: row.quantity != null ? Number(row.quantity) : null,
|
||||
rateMinor: row.rate_minor,
|
||||
eventId: row.event_id, // null = company
|
||||
supplierName: row.supplier_name,
|
||||
description: row.description,
|
||||
chfAmountMinor: row.chf_amount_minor,
|
||||
categoryId: row.category_id,
|
||||
receiptPath: row.receipt_path,
|
||||
hasProof: !!row.receipt_path,
|
||||
taxTreatment: row.tax_treatment,
|
||||
status: row.status,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/** Compute the booked amount (minor) for an internal expense. */
|
||||
function computeExpenseAmount(kind, quantity, rateMinor, amountMinor) {
|
||||
if (kind === 'mileage' || kind === 'per_diem') {
|
||||
if (quantity != null && rateMinor != null) return Math.round(Number(quantity) * Number(rateMinor));
|
||||
return null;
|
||||
}
|
||||
return Number.isInteger(amountMinor) ? amountMinor : null;
|
||||
}
|
||||
|
||||
function buildExpenseInsert(payload, adminId, opts = {}) {
|
||||
const now = new Date();
|
||||
const kind = EXPENSE_KINDS.includes(payload.kind) ? payload.kind : 'amount';
|
||||
let rateMinor = null;
|
||||
if (kind === 'mileage') rateMinor = Number.isInteger(payload.rateMinor) ? payload.rateMinor : (opts.kmRateMinor ?? null);
|
||||
else if (kind === 'per_diem') rateMinor = Number.isInteger(payload.rateMinor) ? payload.rateMinor : (opts.perDiemRateMinor ?? null);
|
||||
const quantity = (kind === 'mileage' || kind === 'per_diem') && payload.quantity != null ? Number(payload.quantity) : null;
|
||||
const chf = computeExpenseAmount(kind, quantity, rateMinor, payload.chfAmountMinor);
|
||||
return {
|
||||
inbound_document_id: null,
|
||||
disposition: 'eigener_aufwand', // internal expenses are always own-cost
|
||||
tax_treatment: TAX_TREATMENTS.includes(payload.taxTreatment) ? payload.taxTreatment : 'domestic',
|
||||
event_id: payload.eventId || null, // null = company
|
||||
supplier_name: payload.supplierName || null,
|
||||
description: payload.description || null,
|
||||
original_currency: payload.originalCurrency || null,
|
||||
original_amount_minor: Number.isInteger(payload.originalAmountMinor) ? payload.originalAmountMinor : null,
|
||||
chf_amount_minor: Number.isInteger(payload.chfAmountMinor) ? payload.chfAmountMinor : null,
|
||||
fx_locked: !!payload.fxLocked,
|
||||
fx_lock_reason: payload.fxLockReason || null,
|
||||
net_amount_minor: Number.isInteger(payload.netAmountMinor) ? payload.netAmountMinor : null,
|
||||
vat_amount_minor: Number.isInteger(payload.vatAmountMinor) ? payload.vatAmountMinor : null,
|
||||
gross_amount_minor: Number.isInteger(payload.grossAmountMinor) ? payload.grossAmountMinor : null,
|
||||
markup_type: markupType,
|
||||
markup_percent: markupType === 'percent' && payload.markupPercent != null ? payload.markupPercent : null,
|
||||
markup_flat_minor: markupType === 'flat' && Number.isInteger(payload.markupFlatMinor) ? payload.markupFlatMinor : null,
|
||||
kind,
|
||||
quantity,
|
||||
rate_minor: rateMinor,
|
||||
chf_amount_minor: chf,
|
||||
gross_amount_minor: chf,
|
||||
category_id: payload.categoryId || null,
|
||||
tags: Array.isArray(payload.tags) ? JSON.stringify(payload.tags) : null,
|
||||
unbilled_parked: !!payload.unbilledParked,
|
||||
decline_reason: disposition === 'abgelehnt' ? (payload.declineReason || null) : null,
|
||||
status,
|
||||
receipt_path: opts.receiptPath || null,
|
||||
status: 'open',
|
||||
created_by_admin_id: adminId || null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
@@ -299,240 +387,73 @@ async function getExpense(id) {
|
||||
return transformExpense(row);
|
||||
}
|
||||
|
||||
async function createManualExpense(payload, adminId) {
|
||||
const row = buildExpenseInsert(payload, adminId);
|
||||
async function createExpense(payload, adminId, { receiptPath } = {}) {
|
||||
const settings = await getAccountingSettings();
|
||||
if (settings.requireProof && !receiptPath) {
|
||||
throw new AppError('A proof file is required for expenses', 400, 'PROOF_REQUIRED');
|
||||
}
|
||||
const row = buildExpenseInsert(payload, adminId, {
|
||||
receiptPath,
|
||||
kmRateMinor: settings.kmRateMinor,
|
||||
perDiemRateMinor: settings.perDiemRateMinor,
|
||||
});
|
||||
const inserted = await db('expenses').insert(row).returning('id');
|
||||
const id = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
|
||||
await logActivity('expense_created', { expenseId: id, disposition: row.disposition }, adminId);
|
||||
await logActivity('expense_created', { expenseId: id, kind: row.kind }, adminId);
|
||||
return getExpense(id);
|
||||
}
|
||||
|
||||
/** Create an expense FROM an inbound document and move the doc out of "Unsortiert". */
|
||||
async function categorizeInbound(inboundId, payload, adminId) {
|
||||
const doc = await getInbound(inboundId);
|
||||
return db.transaction(async (trx) => {
|
||||
const row = buildExpenseInsert({
|
||||
// Seed expense fields from the (confirmed) document, payload overrides win.
|
||||
supplierName: doc.supplierName,
|
||||
chfAmountMinor: doc.totalAmountMinor,
|
||||
netAmountMinor: doc.netAmountMinor,
|
||||
vatAmountMinor: doc.vatAmountMinor,
|
||||
grossAmountMinor: doc.totalAmountMinor,
|
||||
originalCurrency: doc.currency,
|
||||
...payload,
|
||||
inboundDocumentId: inboundId,
|
||||
}, adminId);
|
||||
const inserted = await trx('expenses').insert(row).returning('id');
|
||||
const id = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
|
||||
|
||||
const docStatus = DISPOSITION_DOC_STATUS[row.disposition] || 'categorized';
|
||||
const docPatch = { status: docStatus, updated_at: new Date() };
|
||||
if (row.disposition === 'duplikat' && payload.duplicateOfId) {
|
||||
docPatch.duplicate_of_id = payload.duplicateOfId;
|
||||
}
|
||||
await trx('inbound_documents').where({ id: inboundId }).update(docPatch);
|
||||
|
||||
await logActivity('expense_categorized', { expenseId: id, inboundDocumentId: inboundId, disposition: row.disposition }, adminId);
|
||||
const created = await trx('expenses').where({ id }).first();
|
||||
return transformExpense(created);
|
||||
});
|
||||
}
|
||||
|
||||
async function listExpenses({ status, disposition, customerAccountId, eventId, page, pageSize } = {}) {
|
||||
async function listExpenses({ kind, eventId, categoryId, page, pageSize } = {}) {
|
||||
const { p, ps } = clampPage(page, pageSize);
|
||||
const base = db('expenses');
|
||||
if (status) base.where({ status });
|
||||
if (disposition) base.where({ disposition });
|
||||
if (customerAccountId) base.where({ customer_account_id: customerAccountId });
|
||||
if (eventId) base.where({ event_id: eventId });
|
||||
const base = db('expenses').where({ inbound_document_id: null }); // internal only
|
||||
if (kind) base.where({ kind });
|
||||
if (categoryId) base.where({ category_id: categoryId });
|
||||
if (eventId === 'company') base.whereNull('event_id');
|
||||
else if (eventId) base.where({ event_id: eventId });
|
||||
const countRow = await base.clone().count({ count: '*' }).first();
|
||||
const total = parseInt(countRow?.count || 0, 10);
|
||||
const rows = await base.clone()
|
||||
.orderBy('created_at', 'desc')
|
||||
.limit(ps).offset((p - 1) * ps);
|
||||
return {
|
||||
items: rows.map(transformExpense),
|
||||
pagination: { page: p, pageSize: ps, total, totalPages: Math.ceil(total / ps) },
|
||||
};
|
||||
const rows = await base.clone().orderBy('created_at', 'desc').limit(ps).offset((p - 1) * ps);
|
||||
return { items: rows.map(transformExpense), pagination: { page: p, pageSize: ps, total, totalPages: Math.ceil(total / ps) } };
|
||||
}
|
||||
|
||||
const EXPENSE_EDITABLE = {
|
||||
supplierName: 'supplier_name', description: 'description', taxTreatment: 'tax_treatment',
|
||||
eventId: 'event_id', customerAccountId: 'customer_account_id', categoryId: 'category_id',
|
||||
originalCurrency: 'original_currency', originalAmountMinor: 'original_amount_minor',
|
||||
chfAmountMinor: 'chf_amount_minor', netAmountMinor: 'net_amount_minor',
|
||||
vatAmountMinor: 'vat_amount_minor', grossAmountMinor: 'gross_amount_minor',
|
||||
markupType: 'markup_type', markupPercent: 'markup_percent', markupFlatMinor: 'markup_flat_minor',
|
||||
declineReason: 'decline_reason',
|
||||
eventId: 'event_id', categoryId: 'category_id',
|
||||
};
|
||||
|
||||
async function updateExpense(id, payload, adminId) {
|
||||
const existing = await getExpense(id);
|
||||
if (existing.billedInvoiceId) {
|
||||
throw new AppError('Expense already billed — edit is locked', 409, 'EXPENSE_LOCKED');
|
||||
}
|
||||
async function updateExpense(id, payload, adminId, { receiptPath } = {}) {
|
||||
await getExpense(id);
|
||||
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];
|
||||
}
|
||||
if (payload.tags !== undefined) patch.tags = Array.isArray(payload.tags) ? JSON.stringify(payload.tags) : null;
|
||||
// FX lock: once locked, the converted amount can't drift.
|
||||
if (existing.fxLocked && (patch.chf_amount_minor !== undefined)) {
|
||||
throw new AppError('FX amount is locked (bank-reconciled or billed)', 409, 'FX_LOCKED');
|
||||
}
|
||||
if (receiptPath) patch.receipt_path = receiptPath;
|
||||
await db('expenses').where({ id }).update(patch);
|
||||
await logActivity('expense_updated', { expenseId: id }, adminId);
|
||||
return getExpense(id);
|
||||
}
|
||||
|
||||
/** Toggle the supplier-payment status (decoupled from categorisation). */
|
||||
async function setSupplierPayment(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');
|
||||
}
|
||||
const patch = {
|
||||
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 db('expenses').where({ id }).update(patch);
|
||||
await logActivity('expense_supplier_payment', { expenseId: id, paid: !!paid }, adminId);
|
||||
return getExpense(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the markup to apply: explicit override → expense's own clause →
|
||||
* the event's contract Spesen-Zuschlag clause → none (0%).
|
||||
* Returns { type, percent, flatMinor }.
|
||||
*/
|
||||
async function resolveMarkup(expense, override, contractId, trx) {
|
||||
const pick = (type, percent, flatMinor) => ({
|
||||
type: MARKUP_TYPES.includes(type) ? type : 'none',
|
||||
percent: percent != null ? Number(percent) : null,
|
||||
flatMinor: Number.isInteger(flatMinor) ? flatMinor : null,
|
||||
});
|
||||
if (override && override.markupType && override.markupType !== 'none') {
|
||||
return pick(override.markupType, override.markupPercent, override.markupFlatMinor);
|
||||
}
|
||||
if (expense.markupType && expense.markupType !== 'none') {
|
||||
return pick(expense.markupType, expense.markupPercent, expense.markupFlatMinor);
|
||||
}
|
||||
if (contractId && (await hasColumnCached('contracts', 'expense_markup_type'))) {
|
||||
const c = await (trx || db)('contracts').where({ id: contractId })
|
||||
.first('expense_markup_type', 'expense_markup_percent', 'expense_markup_flat_minor');
|
||||
if (c && c.expense_markup_type && c.expense_markup_type !== 'none') {
|
||||
return pick(c.expense_markup_type, c.expense_markup_percent, c.expense_markup_flat_minor);
|
||||
}
|
||||
}
|
||||
return pick('none', null, null);
|
||||
}
|
||||
|
||||
function computeMarkupMinor(baseMinor, markup) {
|
||||
if (markup.type === 'percent' && markup.percent != null) {
|
||||
return Math.round(baseMinor * Number(markup.percent) / 100);
|
||||
}
|
||||
if (markup.type === 'flat' && Number.isInteger(markup.flatMinor)) {
|
||||
return markup.flatMinor;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-bill an expense to a client (Weiterverrechnung). Event-scoped: one event
|
||||
* → one customer. Mints an editable scheduled invoice with a single line
|
||||
* (cost + markup), then stamps the expense billed + FX-locked. Mirrors
|
||||
* customerHoursService.billUnbilledEntries.
|
||||
*
|
||||
* For monthly/manual-cadence customers, createInvoice appends the line to the
|
||||
* running draft instead (its accumulator intercept) — same as logged hours.
|
||||
*/
|
||||
async function rebillToEvent(expenseId, payload, adminId) {
|
||||
const { customerAccountId, eventId, contractId } = payload;
|
||||
if (!customerAccountId) throw new AppError('customerAccountId is required to re-bill', 400, 'CUSTOMER_REQUIRED');
|
||||
|
||||
return db.transaction(async (trx) => {
|
||||
const row = await trx('expenses').where({ id: expenseId }).first();
|
||||
if (!row) throw new AppError('Expense not found', 404, 'EXPENSE_NOT_FOUND');
|
||||
const expense = transformExpense(row);
|
||||
if (expense.billedInvoiceId) throw new AppError('Expense already billed', 409, 'ALREADY_BILLED');
|
||||
|
||||
const baseMinor = expense.chfAmountMinor != null ? expense.chfAmountMinor
|
||||
: (expense.grossAmountMinor != null ? expense.grossAmountMinor : null);
|
||||
if (baseMinor == null) throw new AppError('Expense has no amount to re-bill', 400, 'AMOUNT_REQUIRED');
|
||||
|
||||
const markup = await resolveMarkup(expense, payload, contractId, trx);
|
||||
const markupMinor = computeMarkupMinor(baseMinor, markup);
|
||||
const lineTotalMinor = baseMinor + markupMinor;
|
||||
|
||||
const label = (expense.description || expense.supplierName || 'Weiterverrechnete Auslage');
|
||||
const lineItem = {
|
||||
description: `${label} (Weiterverrechnung)`,
|
||||
quantity: 1,
|
||||
unit_price_minor: lineTotalMinor,
|
||||
discount_percent: 0,
|
||||
line_total_minor: lineTotalMinor,
|
||||
};
|
||||
|
||||
const { invoiceIds } = await invoiceService.createInvoice({
|
||||
customerAccountId,
|
||||
eventId: eventId || expense.eventId || null,
|
||||
lineItems: [lineItem],
|
||||
}, adminId, trx);
|
||||
const invoiceId = Array.isArray(invoiceIds) ? invoiceIds[0] : null;
|
||||
if (!invoiceId) throw new AppError('Failed to create the re-bill invoice', 500, 'REBILL_FAILED');
|
||||
|
||||
// Newest line in this invoice within the transaction is the one we added.
|
||||
const line = await trx('invoice_line_items').where({ invoice_id: invoiceId })
|
||||
.orderBy('id', 'desc').first('id');
|
||||
|
||||
const now = new Date();
|
||||
await trx('expenses').where({ id: expenseId }).update({
|
||||
disposition: 'rebill',
|
||||
status: 'billed',
|
||||
event_id: eventId || expense.eventId || null,
|
||||
customer_account_id: customerAccountId,
|
||||
markup_type: markup.type,
|
||||
markup_percent: markup.type === 'percent' ? markup.percent : null,
|
||||
markup_flat_minor: markup.type === 'flat' ? markup.flatMinor : null,
|
||||
billed_invoice_id: invoiceId,
|
||||
billed_invoice_line_item_id: line ? line.id : null,
|
||||
billed_at: now,
|
||||
fx_locked: true,
|
||||
fx_lock_reason: 'billed',
|
||||
updated_at: now,
|
||||
});
|
||||
|
||||
await logActivity('expense_rebilled', {
|
||||
expenseId, invoiceId, customerAccountId, baseMinor, markupMinor, lineTotalMinor,
|
||||
}, adminId);
|
||||
|
||||
const updated = await trx('expenses').where({ id: expenseId }).first();
|
||||
return { expense: transformExpense(updated), invoiceId };
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
// inbound documents
|
||||
getAccountingSettings,
|
||||
// incoming invoices
|
||||
recordInboundDocument,
|
||||
getInbound,
|
||||
listInbound,
|
||||
updateInbound,
|
||||
categorizeInbound,
|
||||
rebillInbound,
|
||||
markInboundSupplierPayment,
|
||||
// expenses
|
||||
createManualExpense,
|
||||
createExpense,
|
||||
getExpense,
|
||||
listExpenses,
|
||||
updateExpense,
|
||||
setSupplierPayment,
|
||||
rebillToEvent,
|
||||
// constants (for route validators)
|
||||
// constants
|
||||
DISPOSITIONS,
|
||||
TAX_TREATMENTS,
|
||||
MARKUP_TYPES,
|
||||
PAYMENT_METHODS,
|
||||
// exposed for unit tests
|
||||
_internal: { computeMarkupMinor, resolveMarkup, buildExpenseInsert, transformExpense, transformInbound },
|
||||
EXPENSE_KINDS,
|
||||
// unit-test surface
|
||||
_internal: { computeMarkupMinor, resolveMarkup, computeExpenseAmount, buildExpenseInsert, transformExpense, transformInbound },
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user