Files
oikos/server/routes/budget.js
T
Ulas 3b90074723 refactor(logging): replace console.* with structured logger across server
Add server/logger.js - zero-dependency, level-based logger that outputs
JSON in production and human-readable format in development. Controlled
via LOG_LEVEL env var (debug/info/warn/error, default: info).

Replaces all 100 console.log/warn/error calls in 14 server files.
2026-04-03 22:05:22 +02:00

352 lines
12 KiB
JavaScript

/**
* Modul: Budget-Tracker (Budget)
* Zweck: REST-API-Routen für Einnahmen/Ausgaben, Monatsübersicht, CSV-Export
* Abhängigkeiten: express, server/db.js, server/auth.js
*/
'use strict';
const { createLogger } = require('../logger');
const log = createLogger('Budget');
const express = require('express');
const router = express.Router();
const db = require('../db');
const { str, oneOf, date: validateDate, num, rrule, collectErrors, MAX_TITLE, MONTH_RE } = require('../middleware/validate');
// --------------------------------------------------------
// Wiederkehrende Einträge: fehlende Instanzen für einen Monat erzeugen
// --------------------------------------------------------
/**
* Erstellt fehlende Instanzen wiederkehrender Budget-Einträge für den angefragten Monat.
* Läuft idempotent - bereits vorhandene oder explizit übersprungene Instanzen werden ignoriert.
* @param {import('better-sqlite3').Database} database
* @param {string} month YYYY-MM
*/
function generateRecurringInstances(database, month) {
const [y, m] = month.split('-').map(Number);
const monthStart = `${month}-01`;
const monthEnd = `${month}-31`;
// Alle Serien-Originale, die vor diesem Monat begonnen haben
const originals = database.prepare(`
SELECT * FROM budget_entries
WHERE is_recurring = 1 AND recurrence_parent_id IS NULL
AND strftime('%Y-%m', date) < ?
`).all(month);
for (const orig of originals) {
// Übersprungener Monat?
const skipped = database.prepare(
'SELECT 1 FROM budget_recurrence_skipped WHERE parent_id = ? AND month = ?'
).get(orig.id, month);
if (skipped) continue;
// Instanz schon vorhanden?
const existing = database.prepare(`
SELECT id FROM budget_entries
WHERE recurrence_parent_id = ? AND date BETWEEN ? AND ?
`).get(orig.id, monthStart, monthEnd);
if (existing) continue;
// Datum berechnen: gleicher Tag, am letzten Tag des Monats gekappt
const origDay = parseInt(orig.date.split('-')[2], 10);
const lastDay = new Date(y, m, 0).getDate();
const instanceDay = Math.min(origDay, lastDay);
const instanceDate = `${month}-${String(instanceDay).padStart(2, '0')}`;
database.prepare(`
INSERT INTO budget_entries
(title, amount, category, date, is_recurring, recurrence_parent_id, created_by)
VALUES (?, ?, ?, ?, 0, ?, ?)
`).run(orig.title, orig.amount, orig.category, instanceDate, orig.id, orig.created_by);
}
}
const VALID_CATEGORIES = [
'Lebensmittel', 'Miete', 'Versicherung', 'Mobilität',
'Freizeit', 'Kleidung', 'Gesundheit', 'Bildung', 'Sonstiges',
];
// --------------------------------------------------------
// Statische Routen vor /:id
// --------------------------------------------------------
/**
* GET /api/v1/budget/summary
* Monatsübersicht: Einnahmen, Ausgaben, Saldo, Aufschlüsselung nach Kategorie.
* Query: ?month=YYYY-MM (default: aktueller Monat)
* Response: { data: { month, income, expenses, balance, byCategory: [] } }
*/
router.get('/summary', (req, res) => {
try {
const today = new Date().toISOString().slice(0, 7); // YYYY-MM
const month = req.query.month || today;
if (!MONTH_RE.test(month))
return res.status(400).json({ error: 'month muss YYYY-MM sein', code: 400 });
const from = `${month}-01`;
const to = `${month}-31`;
const totals = db.get().prepare(`
SELECT
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) AS income,
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expenses,
SUM(amount) AS balance
FROM budget_entries
WHERE date BETWEEN ? AND ?
`).get(from, to);
const byCategory = db.get().prepare(`
SELECT category,
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) AS income,
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expenses,
SUM(amount) AS total
FROM budget_entries
WHERE date BETWEEN ? AND ?
GROUP BY category
ORDER BY ABS(SUM(amount)) DESC
`).all(from, to);
res.json({
data: {
month,
income: totals.income || 0,
expenses: totals.expenses || 0,
balance: totals.balance || 0,
byCategory,
},
});
} catch (err) {
log.error('', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* GET /api/v1/budget/export
* Monatseinträge als CSV-Download.
* Query: ?month=YYYY-MM
* Response: text/csv
*/
router.get('/export', (req, res) => {
try {
const today = new Date().toISOString().slice(0, 7);
const month = req.query.month || today;
if (!MONTH_RE.test(month))
return res.status(400).json({ error: 'month muss YYYY-MM sein', code: 400 });
const from = `${month}-01`;
const to = `${month}-31`;
const entries = db.get().prepare(`
SELECT b.*, u.display_name AS creator_name
FROM budget_entries b
LEFT JOIN users u ON u.id = b.created_by
WHERE b.date BETWEEN ? AND ?
ORDER BY b.date ASC
`).all(from, to);
const header = 'Datum,Titel,Betrag,Kategorie,Wiederkehrend,Erstellt von\n';
const csvSafe = (val) => {
let s = String(val || '').replace(/"/g, '""');
if (/^[=+\-@\t\r]/.test(s)) s = "'" + s;
return `"${s}"`;
};
const rows = entries.map((e) =>
[
e.date,
csvSafe(e.title),
e.amount.toFixed(2).replace('.', ','),
e.category,
e.is_recurring ? 'Ja' : 'Nein',
csvSafe(e.creator_name),
].join(',')
).join('\n');
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="budget-${month}.csv"`);
res.send('\uFEFF' + header + rows); // BOM für Excel
} catch (err) {
log.error('', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* GET /api/v1/budget/meta
* Kategorien-Liste für Dropdowns.
* Response: { data: { categories } }
*/
router.get('/meta', (req, res) => {
res.json({ data: { categories: VALID_CATEGORIES } });
});
// --------------------------------------------------------
// CRUD-Routen
// --------------------------------------------------------
/**
* GET /api/v1/budget
* Einträge eines Monats abrufen.
* Query: ?month=YYYY-MM&category=<cat>
* Response: { data: Entry[] }
*/
router.get('/', (req, res) => {
try {
const today = new Date().toISOString().slice(0, 7);
const month = req.query.month || today;
if (!MONTH_RE.test(month))
return res.status(400).json({ error: 'month muss YYYY-MM sein', code: 400 });
generateRecurringInstances(db.get(), month);
const from = `${month}-01`;
const to = `${month}-31`;
let sql = `
SELECT b.*, u.display_name AS creator_name
FROM budget_entries b
LEFT JOIN users u ON u.id = b.created_by
WHERE b.date BETWEEN ? AND ?
`;
const params = [from, to];
if (req.query.category && VALID_CATEGORIES.includes(req.query.category)) {
sql += ' AND b.category = ?';
params.push(req.query.category);
}
sql += ' ORDER BY b.date DESC, b.created_at DESC';
const entries = db.get().prepare(sql).all(...params);
res.json({ data: entries });
} catch (err) {
log.error('', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* POST /api/v1/budget
* Neuen Eintrag anlegen.
* Body: { title, amount, category?, date, is_recurring?, recurrence_rule? }
* Response: { data: Entry }
*/
router.post('/', (req, res) => {
try {
const vTitle = str(req.body.title, 'Titel', { max: MAX_TITLE });
const vAmount = num(req.body.amount, 'Betrag', { required: true });
const vCat = oneOf(req.body.category || 'Sonstiges', VALID_CATEGORIES, 'Kategorie');
const vDate = validateDate(req.body.date, 'Datum', true);
const vRrule = rrule(req.body.recurrence_rule, 'Wiederholung');
const errors = collectErrors([vTitle, vAmount, vCat, vDate, vRrule]);
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
const result = db.get().prepare(`
INSERT INTO budget_entries (title, amount, category, date, is_recurring, recurrence_rule, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
vTitle.value, vAmount.value, vCat.value || 'Sonstiges', vDate.value,
req.body.is_recurring ? 1 : 0, vRrule.value,
req.session.userId
);
const entry = db.get().prepare(`
SELECT b.*, u.display_name AS creator_name
FROM budget_entries b LEFT JOIN users u ON u.id = b.created_by
WHERE b.id = ?
`).get(result.lastInsertRowid);
res.status(201).json({ data: entry });
} catch (err) {
log.error('', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* PUT /api/v1/budget/:id
* Eintrag bearbeiten.
* Body: alle Felder optional
* Response: { data: Entry }
*/
router.put('/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10);
const entry = db.get().prepare('SELECT * FROM budget_entries WHERE id = ?').get(id);
if (!entry) return res.status(404).json({ error: 'Eintrag nicht gefunden', code: 404 });
const checks = [];
if (req.body.title !== undefined) checks.push(str(req.body.title, 'Titel', { max: MAX_TITLE, required: false }));
if (req.body.amount !== undefined) checks.push(num(req.body.amount, 'Betrag'));
if (req.body.category !== undefined) checks.push(oneOf(req.body.category, VALID_CATEGORIES, 'Kategorie'));
if (req.body.date !== undefined) checks.push(validateDate(req.body.date, 'Datum'));
if (req.body.recurrence_rule !== undefined) checks.push(rrule(req.body.recurrence_rule, 'Wiederholung'));
const errors = collectErrors(checks);
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
const { title, amount, category, date, is_recurring, recurrence_rule } = req.body;
db.get().prepare(`
UPDATE budget_entries
SET title = COALESCE(?, title),
amount = COALESCE(?, amount),
category = COALESCE(?, category),
date = COALESCE(?, date),
is_recurring = COALESCE(?, is_recurring),
recurrence_rule = ?
WHERE id = ?
`).run(
title?.trim() ?? null,
amount !== undefined ? Number(amount) : null,
category ?? null,
date ?? null,
is_recurring !== undefined ? (is_recurring ? 1 : 0) : null,
recurrence_rule !== undefined ? (recurrence_rule || null) : entry.recurrence_rule,
id
);
const updated = db.get().prepare(`
SELECT b.*, u.display_name AS creator_name
FROM budget_entries b LEFT JOIN users u ON u.id = b.created_by WHERE b.id = ?
`).get(id);
res.json({ data: updated });
} catch (err) {
log.error('', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* DELETE /api/v1/budget/:id
* Eintrag löschen.
* Response: 204 No Content
*/
router.delete('/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10);
const entry = db.get().prepare('SELECT * FROM budget_entries WHERE id = ?').get(id);
if (!entry) return res.status(404).json({ error: 'Eintrag nicht gefunden', code: 404 });
db.get().prepare('DELETE FROM budget_entries WHERE id = ?').run(id);
// Wenn eine Instanz gelöscht wird: Monat als übersprungen markieren
if (entry.recurrence_parent_id) {
const month = entry.date.slice(0, 7);
db.get().prepare(
'INSERT OR IGNORE INTO budget_recurrence_skipped (parent_id, month) VALUES (?, ?)'
).run(entry.recurrence_parent_id, month);
}
res.status(204).end();
} catch (err) {
log.error('', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
module.exports = router;