feat(accounting): split Incoming invoices vs Expenses - flags, schema, settings (stage 1)
Foundation for separating external supplier invoices from internal expenses, per design review. This stage is additive + buildable; the service/route/UI data rework follows in stage 2. - Migration 126: incoming invoices own their payable on inbound_documents (supplier_paid/at/method/ref + disposition + tax_treatment + booking event_id + category_id + re-bill markup/linkage); expenses gain kind (amount/mileage/ per_diem) + quantity + snapshotted rate_minor. Additive, hasColumn-guarded. - Migration 127: seed `expenses` feature flag (default off) + accounting app_settings (accounting_km_rate_minor=70, accounting_per_diem_rate_minor=0, accounting_require_proof=false). - Backend: `expenses` added to feature-flag known/defaults/dependency (forced off when the accounting master is off); new PUT /admin/settings/accounting (read via the generic GET /:type). - Frontend: `expenses` flag (type + context + dependency); Features tab gets an Expenses sub-card; the Expenses sub-nav + route now gate on `expenses` (not incomingInvoices); AccountingIndex prefers inbox -> expenses -> tax. - i18n: settings.features.expenses.* (EN + DE). Verified: node -c; migration 124->126->127 harness (new columns, flag, settings + idempotency); en/de JSON valid; npm run build green.
This commit is contained in:
@@ -66,10 +66,13 @@ const KNOWN_FLAGS = [
|
||||
// supplier invoices, expenses + re-bill, and the tax report (which
|
||||
// relocates here from CRM when this is on). Strictly opt-in.
|
||||
'accounting',
|
||||
// Incoming invoices (migration 124) — supplier-invoice capture +
|
||||
// expenses + re-bill. Accounting sub-feature; forced off when the
|
||||
// `accounting` master is off.
|
||||
// Incoming invoices (migration 124) — external supplier-invoice capture +
|
||||
// re-bill. Accounting sub-feature; forced off when the `accounting` master
|
||||
// is off.
|
||||
'incomingInvoices',
|
||||
// Expenses (migration 127) — internal expenses (mileage / per-diem / cash).
|
||||
// Separate Accounting sub-feature; forced off when `accounting` is off.
|
||||
'expenses',
|
||||
];
|
||||
|
||||
// Spec defaults for any flag missing from the DB (e.g. a row added by a
|
||||
@@ -94,6 +97,7 @@ const DEFAULT_FLAGS = {
|
||||
contracts: false,
|
||||
accounting: false,
|
||||
incomingInvoices: false,
|
||||
expenses: false,
|
||||
};
|
||||
|
||||
async function readAllFlags() {
|
||||
@@ -120,6 +124,7 @@ function applyDependencyRules(flags) {
|
||||
if (out.accounting === false) {
|
||||
out.taxReport = false;
|
||||
out.incomingInvoices = false;
|
||||
out.expenses = false;
|
||||
}
|
||||
// Clients parent flag is DERIVED from its children. Admins don't
|
||||
// toggle it directly in the Features tab — they enable a specific
|
||||
|
||||
@@ -234,6 +234,44 @@ router.put('/customer-surface', adminAuth, requirePermission('settings.edit'), a
|
||||
}
|
||||
});
|
||||
|
||||
// Accounting settings (km rate, per-diem rate, require-proof). Read via the
|
||||
// generic GET /:type ('accounting'); this is the typed write. Rates are
|
||||
// integer minor units; verify legal/tax guidance with a Treuhaender.
|
||||
router.put('/accounting', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const updates = [];
|
||||
const setInt = (key) => {
|
||||
if (Object.prototype.hasOwnProperty.call(req.body, key)) {
|
||||
const n = Math.max(0, Math.round(Number(req.body[key]) || 0));
|
||||
updates.push({ setting_key: key, setting_value: JSON.stringify(n), setting_type: 'accounting' });
|
||||
}
|
||||
};
|
||||
setInt('accounting_km_rate_minor');
|
||||
setInt('accounting_per_diem_rate_minor');
|
||||
if (Object.prototype.hasOwnProperty.call(req.body, 'accounting_require_proof')) {
|
||||
updates.push({
|
||||
setting_key: 'accounting_require_proof',
|
||||
setting_value: JSON.stringify(!!req.body.accounting_require_proof),
|
||||
setting_type: 'accounting',
|
||||
});
|
||||
}
|
||||
for (const u of updates) {
|
||||
const existing = await db('app_settings').where('setting_key', u.setting_key).first();
|
||||
if (existing) {
|
||||
await db('app_settings').where('setting_key', u.setting_key).update({
|
||||
setting_value: u.setting_value, setting_type: u.setting_type, updated_at: new Date(),
|
||||
});
|
||||
} else {
|
||||
await db('app_settings').insert({ ...u, created_at: new Date(), updated_at: new Date() });
|
||||
}
|
||||
}
|
||||
res.json({ message: 'Accounting settings updated', updated: updates.map((u) => u.setting_key) });
|
||||
} catch (error) {
|
||||
console.error('Accounting settings save error:', error);
|
||||
res.status(500).json({ error: 'Failed to save accounting settings' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get settings by type
|
||||
router.get('/:type', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user