/** * 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 */ import { createLogger } from '../logger.js'; import express from 'express'; import { readFileSync } from 'node:fs'; import path from 'path'; import * as db from '../db.js'; import { str, oneOf, date as validateDate, month as validateMonth, num, rrule, collectErrors, MAX_TITLE, MAX_SHORT, MONTH_RE } from '../middleware/validate.js'; const log = createLogger('Budget'); const router = express.Router(); const LOCALE_CACHE = new Map(); const SUPPORTED_LANGS = new Set(['ar', 'de', 'el', 'en', 'es', 'fr', 'hi', 'it', 'ja', 'pt', 'ru', 'sv', 'tr', 'uk', 'zh']); const CATEGORY_LABEL_KEYS = { housing: 'catHousing', food: 'catFood', transport: 'catTransport', personal_health: 'catPersonalHealth', leisure: 'catLeisure', shopping_clothing: 'catShoppingClothing', education: 'catEducation', financial_other: 'catFinancialOther', 'Erwerbseinkommen': 'catEarnedIncome', 'Kapitalerträge': 'catInvestmentIncome', 'Geschenke & Transfers': 'catTransferGiftIncome', 'Sozialleistungen': 'catGovernmentBenefits', 'Sonstiges Einkommen': 'catOtherIncome', }; const SUBCATEGORY_LABEL_KEYS = { rent_mortgage: 'subcatRentMortgage', condominium: 'subcatCondominium', utilities: 'subcatUtilities', internet_tv_phone: 'subcatInternetTvPhone', renovation_maintenance: 'subcatRenovationMaintenance', cleaning: 'subcatCleaning', groceries: 'subcatGroceries', restaurants_bars: 'subcatRestaurantsBars', snacks_fast_food: 'subcatSnacksFastFood', bakery: 'subcatBakery', fuel: 'subcatFuel', parking_tolls: 'subcatParkingTolls', public_transport: 'subcatPublicTransport', apps_taxi: 'subcatAppsTaxi', maintenance_insurance: 'subcatMaintenanceInsurance', pharmacy: 'subcatPharmacy', health_insurance: 'subcatHealthInsurance', gym_sports: 'subcatGymSports', beauty_cosmetics: 'subcatBeautyCosmetics', travel: 'subcatTravel', streaming: 'subcatStreaming', events: 'subcatEvents', hobbies: 'subcatHobbies', clothes_shoes: 'subcatClothesShoes', electronics: 'subcatElectronics', gifts: 'subcatGifts', courses_college: 'subcatCoursesCollege', school_supplies: 'subcatSchoolSupplies', languages: 'subcatLanguages', loans_interest: 'subcatLoansInterest', bank_fees: 'subcatBankFees', insurance_other: 'subcatInsuranceOther', investments: 'subcatInvestments', taxes: 'subcatTaxes', }; function normalizeLang(raw) { const lang = String(raw || 'en').trim().toLowerCase(); const base = lang.split(/[-_]/)[0]; return SUPPORTED_LANGS.has(base) ? base : 'en'; } function budgetMessages(lang) { const normalized = normalizeLang(lang); if (!LOCALE_CACHE.has(normalized)) { const localePath = path.join(import.meta.dirname, '..', '..', 'public', 'locales', `${normalized}.json`); const parsed = JSON.parse(readFileSync(localePath, 'utf-8')); LOCALE_CACHE.set(normalized, parsed.budget || {}); } return LOCALE_CACHE.get(normalized); } function localizedCategory(category, lang) { const budget = budgetMessages(lang); const labelKey = CATEGORY_LABEL_KEYS[category.key]; return { ...category, label: labelKey ? (budget[labelKey] || category.name) : category.name, }; } function localizedSubcategory(subcategory, lang) { const budget = budgetMessages(lang); const labelKey = SUBCATEGORY_LABEL_KEYS[subcategory.key]; return { ...subcategory, label: labelKey ? (budget[labelKey] || subcategory.name) : subcategory.name, }; } // -------------------------------------------------------- // 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, subcategory, date, is_recurring, recurrence_parent_id, created_by) VALUES (?, ?, ?, ?, ?, 0, ?, ?) `).run(orig.title, orig.amount, orig.category, orig.subcategory || '', instanceDate, orig.id, orig.created_by); } } function slugify(value) { return String(value || '') .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .toLowerCase() .replace(/[^a-z0-9]+/g, '_') .replace(/^_+|_+$/g, '') .slice(0, 48) || 'category'; } function uniqueKey(table, base) { const normalized = slugify(base); let key = normalized; let i = 2; const exists = db.get().prepare(`SELECT 1 FROM ${table} WHERE key = ?`); while (exists.get(key)) { key = `${normalized}_${i}`; i += 1; } return key; } function loadBudgetMeta() { const categories = db.get().prepare(` SELECT key, name, type, sort_order FROM budget_categories ORDER BY type DESC, sort_order ASC, name COLLATE NOCASE ASC `).all(); const subcategories = db.get().prepare(` SELECT key, category_key, name, sort_order FROM budget_subcategories ORDER BY sort_order ASC, name COLLATE NOCASE ASC `).all(); const expenseCategories = categories.filter((c) => c.type === 'expense'); const incomeCategories = categories.filter((c) => c.type === 'income'); const expenseSubcategories = {}; for (const sub of subcategories) { if (!expenseSubcategories[sub.category_key]) expenseSubcategories[sub.category_key] = []; expenseSubcategories[sub.category_key].push(sub); } return { categories, expenseCategories, incomeCategories, expenseSubcategories }; } function validCategoryKeys() { return db.get().prepare('SELECT key FROM budget_categories').all().map((c) => c.key); } function validExpenseCategoryKeys() { return db.get().prepare("SELECT key FROM budget_categories WHERE type = 'expense'").all().map((c) => c.key); } function defaultCategory(type) { const row = db.get().prepare(` SELECT key FROM budget_categories WHERE type = ? ORDER BY sort_order ASC, name COLLATE NOCASE ASC LIMIT 1 `).get(type); return row?.key || (type === 'expense' ? 'financial_other' : 'Sonstiges Einkommen'); } function defaultSubcategory(category) { const row = db.get().prepare(` SELECT key FROM budget_subcategories WHERE category_key = ? ORDER BY sort_order ASC, name COLLATE NOCASE ASC LIMIT 1 `).get(category); return row?.key || ''; } function validateSubcategory(category, subcategory) { if (!validExpenseCategoryKeys().includes(category)) return ''; if (!subcategory) return defaultSubcategory(category); const row = db.get().prepare(` SELECT 1 FROM budget_subcategories WHERE category_key = ? AND key = ? `).get(category, subcategory); return row ? subcategory : null; } function addMonths(ym, n) { const [y, m] = ym.split('-').map(Number); const d = new Date(y, m - 1 + n, 1); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; } function cents(value) { return Math.round(Number(value || 0) * 100) / 100; } function loanSummaryRow(loan) { const payments = db.get().prepare(` SELECT p.*, u.display_name AS creator_name, b.title AS entry_title, b.category AS entry_category, b.subcategory AS entry_subcategory, b.is_recurring AS entry_is_recurring, b.recurrence_parent_id AS entry_recurrence_parent_id FROM budget_loan_payments p LEFT JOIN users u ON u.id = p.created_by LEFT JOIN budget_entries b ON b.id = p.budget_entry_id WHERE p.loan_id = ? ORDER BY p.installment_number ASC `).all(loan.id); const paidAmount = cents(payments.reduce((sum, p) => sum + Number(p.amount || 0), 0)); const paidInstallments = payments.length; const remainingAmount = Math.max(0, cents(loan.total_amount - paidAmount)); const remainingInstallments = Math.max(0, loan.installment_count - paidInstallments); const installmentAmount = cents(loan.total_amount / loan.installment_count); return { ...loan, total_amount: cents(loan.total_amount), installment_amount: installmentAmount, paid_amount: paidAmount, paid_installments: paidInstallments, remaining_amount: remainingAmount, remaining_installments: remainingInstallments, next_installment_number: remainingInstallments > 0 ? paidInstallments + 1 : null, next_due_month: remainingInstallments > 0 ? addMonths(loan.start_month, paidInstallments) : null, payments, }; } function loadLoan(id) { const loan = db.get().prepare(` SELECT l.*, u.display_name AS creator_name FROM budget_loans l LEFT JOIN users u ON u.id = l.created_by WHERE l.id = ? `).get(id); return loan ? loanSummaryRow(loan) : null; } function refreshLoanStatus(loanId) { const loan = loadLoan(loanId); if (!loan) return null; const status = loan.remaining_installments === 0 || loan.remaining_amount <= 0.005 ? 'paid' : 'active'; if (status !== loan.status) { db.get().prepare('UPDATE budget_loans SET status = ? WHERE id = ?').run(status, loanId); return loadLoan(loanId); } return loan; } function entryWithLoanMeta(id) { return db.get().prepare(` SELECT b.*, u.display_name AS creator_name, p.id AS loan_payment_id, p.loan_id AS loan_id, p.installment_number AS loan_installment_number, l.title AS loan_title, l.borrower AS loan_borrower FROM budget_entries b LEFT JOIN users u ON u.id = b.created_by LEFT JOIN budget_loan_payments p ON p.budget_entry_id = b.id LEFT JOIN budget_loans l ON l.id = p.loan_id WHERE b.id = ? `).get(id); } // -------------------------------------------------------- // 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: 'Internal error', 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 = 'Date,Title,Amount,Category,Subcategory,Recurring,Created by\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.subcategory || '', e.is_recurring ? 'Yes' : 'No', 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: 'Internal error', code: 500 }); } }); /** * GET /api/v1/budget/meta * Kategorien-Liste für Dropdowns. * Response: { data: { categories } } */ router.get('/meta', (req, res) => { res.json({ data: loadBudgetMeta() }); }); router.get('/categories', (req, res) => { try { const lang = normalizeLang(req.query.lang); const categories = db.get().prepare(` SELECT key, name, type, sort_order FROM budget_categories ORDER BY type DESC, sort_order ASC, name COLLATE NOCASE ASC `).all(); res.json({ data: categories.map((category) => localizedCategory(category, lang)), lang, }); } catch (err) { log.error('GET /categories error:', err); res.status(500).json({ error: 'Internal error', code: 500 }); } }); router.get('/categories/:categoryKey/subcategories', (req, res) => { try { const lang = normalizeLang(req.query.lang); const category = db.get().prepare(` SELECT key, name, type, sort_order FROM budget_categories WHERE key = ? `).get(req.params.categoryKey); if (!category) return res.status(404).json({ error: 'Category not found.', code: 404 }); const subcategories = db.get().prepare(` SELECT key, category_key, name, sort_order FROM budget_subcategories WHERE category_key = ? ORDER BY sort_order ASC, name COLLATE NOCASE ASC `).all(category.key); res.json({ data: subcategories.map((subcategory) => localizedSubcategory(subcategory, lang)), category: localizedCategory(category, lang), lang, }); } catch (err) { log.error('GET /categories/:categoryKey/subcategories error:', err); res.status(500).json({ error: 'Internal error', code: 500 }); } }); router.get('/loans', (req, res) => { try { const loans = db.get().prepare(` SELECT l.*, u.display_name AS creator_name FROM budget_loans l LEFT JOIN users u ON u.id = l.created_by ORDER BY CASE l.status WHEN 'active' THEN 0 ELSE 1 END, l.start_month ASC, l.created_at DESC `).all().map(loanSummaryRow); const active = loans.filter((loan) => loan.status === 'active'); const totals = loans.reduce((acc, loan) => { acc.total_amount += loan.total_amount; acc.paid_amount += loan.paid_amount; acc.remaining_amount += loan.remaining_amount; acc.remaining_installments += loan.remaining_installments; return acc; }, { total_amount: 0, paid_amount: 0, remaining_amount: 0, remaining_installments: 0 }); res.json({ data: { loans, summary: { active_count: active.length, total_count: loans.length, total_amount: cents(totals.total_amount), paid_amount: cents(totals.paid_amount), remaining_amount: cents(totals.remaining_amount), remaining_installments: totals.remaining_installments, }, }, }); } catch (err) { log.error('GET /loans error:', err); res.status(500).json({ error: 'Internal error', code: 500 }); } }); router.post('/loans', (req, res) => { try { const vTitle = str(req.body.title || req.body.borrower, 'Title', { max: MAX_TITLE }); const vBorrower = str(req.body.borrower, 'Borrower', { max: MAX_SHORT }); const vAmount = num(req.body.total_amount, 'Amount', { required: true }); const vStartMonth = validateMonth(req.body.start_month, 'Start month'); const vNotes = str(req.body.notes, 'Notes', { max: 1000, required: false }); const installmentCount = parseInt(req.body.installment_count, 10); const errors = collectErrors([vTitle, vBorrower, vAmount, vStartMonth, vNotes]); if (!Number.isInteger(installmentCount) || installmentCount < 1 || installmentCount > 240) { errors.push('Installment count must be between 1 and 240.'); } if (vAmount.value !== null && vAmount.value <= 0) errors.push('Amount must be greater than zero.'); if (!vStartMonth.value) errors.push('Start month is required.'); if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); const result = db.get().prepare(` INSERT INTO budget_loans (title, borrower, total_amount, installment_count, start_month, notes, created_by) VALUES (?, ?, ?, ?, ?, ?, ?) `).run( vTitle.value, vBorrower.value, cents(vAmount.value), installmentCount, vStartMonth.value, vNotes.value, req.session.userId ); res.status(201).json({ data: loadLoan(result.lastInsertRowid) }); } catch (err) { log.error('POST /loans error:', err); res.status(500).json({ error: 'Internal error', code: 500 }); } }); router.put('/loans/:id', (req, res) => { try { const id = parseInt(req.params.id, 10); const loan = db.get().prepare('SELECT * FROM budget_loans WHERE id = ?').get(id); if (!loan) return res.status(404).json({ error: 'Loan not found.', code: 404 }); const checks = []; if (req.body.title !== undefined) checks.push(str(req.body.title, 'Title', { max: MAX_TITLE })); if (req.body.borrower !== undefined) checks.push(str(req.body.borrower, 'Borrower', { max: MAX_SHORT })); if (req.body.total_amount !== undefined) checks.push(num(req.body.total_amount, 'Amount')); if (req.body.start_month !== undefined) checks.push(validateMonth(req.body.start_month, 'Start month')); if (req.body.notes !== undefined) checks.push(str(req.body.notes, 'Notes', { max: 1000, required: false })); const errors = collectErrors(checks); const installmentCount = req.body.installment_count === undefined ? null : parseInt(req.body.installment_count, 10); if (req.body.installment_count !== undefined && (!Number.isInteger(installmentCount) || installmentCount < 1 || installmentCount > 240)) { errors.push('Installment count must be between 1 and 240.'); } const paidCount = db.get().prepare('SELECT COUNT(*) AS c FROM budget_loan_payments WHERE loan_id = ?').get(id).c; if (installmentCount !== null && installmentCount < paidCount) { errors.push('Installment count cannot be lower than paid installments.'); } if (req.body.total_amount !== undefined && Number(req.body.total_amount) <= 0) errors.push('Amount must be greater than zero.'); if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); db.get().prepare(` UPDATE budget_loans SET title = COALESCE(?, title), borrower = COALESCE(?, borrower), total_amount = COALESCE(?, total_amount), installment_count = COALESCE(?, installment_count), start_month = COALESCE(?, start_month), notes = ? WHERE id = ? `).run( req.body.title?.trim() ?? null, req.body.borrower?.trim() ?? null, req.body.total_amount !== undefined ? cents(req.body.total_amount) : null, installmentCount, req.body.start_month ?? null, req.body.notes !== undefined ? (req.body.notes?.trim() || null) : loan.notes, id ); res.json({ data: refreshLoanStatus(id) }); } catch (err) { log.error('PUT /loans/:id error:', err); res.status(500).json({ error: 'Internal error', code: 500 }); } }); router.post('/loans/:id/payments', (req, res) => { try { const id = parseInt(req.params.id, 10); const loan = loadLoan(id); if (!loan) return res.status(404).json({ error: 'Loan not found.', code: 404 }); if (loan.remaining_installments <= 0) return res.status(409).json({ error: 'Loan is already paid.', code: 409 }); const installmentNumber = req.body.installment_number === undefined ? loan.next_installment_number : parseInt(req.body.installment_number, 10); const defaultAmount = installmentNumber === loan.installment_count ? loan.remaining_amount : Math.min(loan.installment_amount, loan.remaining_amount); const vAmount = num(req.body.amount ?? defaultAmount, 'Amount', { required: true }); const vDate = validateDate(req.body.paid_date, 'Paid date', true); const errors = collectErrors([vAmount, vDate]); if (!Number.isInteger(installmentNumber) || installmentNumber < 1 || installmentNumber > loan.installment_count) { errors.push('Installment number is invalid.'); } if (vAmount.value !== null && vAmount.value <= 0) errors.push('Amount must be greater than zero.'); if (vAmount.value !== null && vAmount.value - loan.remaining_amount > 0.005) { errors.push('Amount cannot be greater than the remaining loan amount.'); } if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); const existing = db.get().prepare(` SELECT 1 FROM budget_loan_payments WHERE loan_id = ? AND installment_number = ? `).get(id, installmentNumber); if (existing) return res.status(409).json({ error: 'Installment already paid.', code: 409 }); const paymentAmount = cents(vAmount.value); const tx = db.get().transaction(() => { const budgetResult = db.get().prepare(` INSERT INTO budget_entries (title, amount, category, subcategory, date, is_recurring, created_by) VALUES (?, ?, ?, '', ?, 0, ?) `).run( `Loan repayment: ${loan.borrower}`, paymentAmount, 'Geschenke & Transfers', vDate.value, req.session.userId ); const paymentResult = db.get().prepare(` INSERT INTO budget_loan_payments (loan_id, installment_number, amount, paid_date, budget_entry_id, created_by) VALUES (?, ?, ?, ?, ?, ?) `).run(id, installmentNumber, paymentAmount, vDate.value, budgetResult.lastInsertRowid, req.session.userId); return paymentResult.lastInsertRowid; }); const paymentId = tx(); res.status(201).json({ data: { payment: db.get().prepare('SELECT * FROM budget_loan_payments WHERE id = ?').get(paymentId), loan: refreshLoanStatus(id), }, }); } catch (err) { log.error('POST /loans/:id/payments error:', err); res.status(500).json({ error: 'Internal error', code: 500 }); } }); router.delete('/loans/:id/payments/:paymentId', (req, res) => { try { const id = parseInt(req.params.id, 10); const paymentId = parseInt(req.params.paymentId, 10); const payment = db.get().prepare(` SELECT * FROM budget_loan_payments WHERE id = ? AND loan_id = ? `).get(paymentId, id); if (!payment) return res.status(404).json({ error: 'Payment not found.', code: 404 }); const tx = db.get().transaction(() => { db.get().prepare('DELETE FROM budget_loan_payments WHERE id = ?').run(paymentId); if (payment.budget_entry_id) { db.get().prepare('DELETE FROM budget_entries WHERE id = ?').run(payment.budget_entry_id); } }); tx(); refreshLoanStatus(id); res.status(204).end(); } catch (err) { log.error('DELETE /loans/:id/payments/:paymentId error:', err); res.status(500).json({ error: 'Internal error', code: 500 }); } }); router.delete('/loans/:id', (req, res) => { try { const id = parseInt(req.params.id, 10); const loan = db.get().prepare('SELECT * FROM budget_loans WHERE id = ?').get(id); if (!loan) return res.status(404).json({ error: 'Loan not found.', code: 404 }); const payments = db.get().prepare('SELECT budget_entry_id FROM budget_loan_payments WHERE loan_id = ?').all(id); const tx = db.get().transaction(() => { db.get().prepare('DELETE FROM budget_loans WHERE id = ?').run(id); for (const payment of payments) { if (payment.budget_entry_id) { db.get().prepare('DELETE FROM budget_entries WHERE id = ?').run(payment.budget_entry_id); } } }); tx(); res.status(204).end(); } catch (err) { log.error('DELETE /loans/:id error:', err); res.status(500).json({ error: 'Internal error', code: 500 }); } }); router.post('/categories', (req, res) => { try { const vName = str(req.body.name, 'Name', { max: MAX_SHORT }); const vType = oneOf(req.body.type || 'expense', ['expense', 'income'], 'Typ'); const errors = collectErrors([vName, vType]); if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); const conflict = db.get().prepare(` SELECT key FROM budget_categories WHERE type = ? AND name = ? COLLATE NOCASE `).get(vType.value, vName.value); if (conflict) return res.status(409).json({ error: 'Category already exists.', code: 409 }); const maxOrder = db.get().prepare(` SELECT COALESCE(MAX(sort_order), -1) AS m FROM budget_categories WHERE type = ? `).get(vType.value).m; const key = uniqueKey('budget_categories', vName.value); db.get().prepare(` INSERT INTO budget_categories (key, name, type, sort_order) VALUES (?, ?, ?, ?) `).run(key, vName.value, vType.value, maxOrder + 1); const cat = db.get().prepare('SELECT key, name, type, sort_order FROM budget_categories WHERE key = ?').get(key); res.status(201).json({ data: cat }); } catch (err) { log.error('POST /categories error:', err); res.status(500).json({ error: 'Internal error', code: 500 }); } }); router.post('/categories/:categoryKey/subcategories', (req, res) => { try { const cat = db.get().prepare(` SELECT * FROM budget_categories WHERE key = ? AND type = 'expense' `).get(req.params.categoryKey); if (!cat) return res.status(404).json({ error: 'Category not found.', code: 404 }); const vName = str(req.body.name, 'Name', { max: MAX_SHORT }); if (vName.error) return res.status(400).json({ error: vName.error, code: 400 }); const conflict = db.get().prepare(` SELECT key FROM budget_subcategories WHERE category_key = ? AND name = ? COLLATE NOCASE `).get(cat.key, vName.value); if (conflict) return res.status(409).json({ error: 'Subcategory already exists.', code: 409 }); const maxOrder = db.get().prepare(` SELECT COALESCE(MAX(sort_order), -1) AS m FROM budget_subcategories WHERE category_key = ? `).get(cat.key).m; const key = uniqueKey('budget_subcategories', `${cat.key}_${vName.value}`); db.get().prepare(` INSERT INTO budget_subcategories (key, category_key, name, sort_order) VALUES (?, ?, ?, ?) `).run(key, cat.key, vName.value, maxOrder + 1); const sub = db.get().prepare(` SELECT key, category_key, name, sort_order FROM budget_subcategories WHERE key = ? `).get(key); res.status(201).json({ data: sub }); } catch (err) { log.error('POST /categories/:categoryKey/subcategories error:', err); res.status(500).json({ error: 'Internal error', code: 500 }); } }); // -------------------------------------------------------- // CRUD-Routen // -------------------------------------------------------- /** * GET /api/v1/budget * Einträge eines Monats abrufen. * Query: ?month=YYYY-MM&category= * Response: { data: Entry[] } */ router.get('/', (req, res) => { try { const today = new Date().toISOString().slice(0, 7); const month = req.query.month || today; const loanId = req.query.loan_id ? parseInt(req.query.loan_id, 10) : null; if (!loanId && !MONTH_RE.test(month)) return res.status(400).json({ error: 'month muss YYYY-MM sein', code: 400 }); if (!loanId) generateRecurringInstances(db.get(), month); const from = `${month}-01`; const to = `${month}-31`; let sql = ` SELECT b.*, u.display_name AS creator_name, p.id AS loan_payment_id, p.loan_id AS loan_id, p.installment_number AS loan_installment_number, l.title AS loan_title, l.borrower AS loan_borrower FROM budget_entries b LEFT JOIN users u ON u.id = b.created_by LEFT JOIN budget_loan_payments p ON p.budget_entry_id = b.id LEFT JOIN budget_loans l ON l.id = p.loan_id `; const params = []; if (loanId) { sql += ' WHERE p.loan_id = ?'; params.push(loanId); } else { sql += ' WHERE b.date BETWEEN ? AND ?'; params.push(from, to); } if (req.query.category && validCategoryKeys().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: 'Internal error', code: 500 }); } }); /** * POST /api/v1/budget * Neuen Eintrag anlegen. * Body: { title, amount, category?, subcategory?, 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 fallbackCategory = defaultCategory(Number(req.body.amount) < 0 ? 'expense' : 'income'); const vCat = oneOf(req.body.category || fallbackCategory, validCategoryKeys(), '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 subcategory = validateSubcategory(vCat.value, req.body.subcategory); if (subcategory === null) { return res.status(400).json({ error: 'Invalid subcategory.', code: 400 }); } const result = db.get().prepare(` INSERT INTO budget_entries (title, amount, category, subcategory, date, is_recurring, recurrence_rule, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `).run( vTitle.value, vAmount.value, vCat.value || fallbackCategory, subcategory, vDate.value, req.body.is_recurring ? 1 : 0, vRrule.value, req.session.userId ); const entry = entryWithLoanMeta(result.lastInsertRowid); res.status(201).json({ data: entry }); } catch (err) { log.error('', err); res.status(500).json({ error: 'Internal error', 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: 'Entry not found', 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, validCategoryKeys(), '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, subcategory: requestedSubcategory, date, is_recurring, recurrence_rule } = req.body; const linkedPayment = db.get().prepare(` SELECT * FROM budget_loan_payments WHERE budget_entry_id = ? `).get(id); if (linkedPayment && amount !== undefined && Number(amount) <= 0) { return res.status(400).json({ error: 'Loan repayment entries must remain income.', code: 400 }); } if (linkedPayment && amount !== undefined) { const loan = db.get().prepare('SELECT total_amount FROM budget_loans WHERE id = ?').get(linkedPayment.loan_id); const otherPaid = db.get().prepare(` SELECT COALESCE(SUM(amount), 0) AS total FROM budget_loan_payments WHERE loan_id = ? AND id != ? `).get(linkedPayment.loan_id, linkedPayment.id).total; if (Number(amount) - (Number(loan?.total_amount || 0) - Number(otherPaid || 0)) > 0.005) { return res.status(400).json({ error: 'Amount cannot be greater than the remaining loan amount.', code: 400 }); } } const nextCategory = category ?? entry.category; const subcategory = requestedSubcategory !== undefined || category !== undefined ? validateSubcategory(nextCategory, requestedSubcategory ?? entry.subcategory) : undefined; if (subcategory === null) { return res.status(400).json({ error: 'Invalid subcategory.', code: 400 }); } const tx = db.get().transaction(() => { db.get().prepare(` UPDATE budget_entries SET title = COALESCE(?, title), amount = COALESCE(?, amount), category = COALESCE(?, category), subcategory = COALESCE(?, subcategory), date = COALESCE(?, date), is_recurring = COALESCE(?, is_recurring), recurrence_rule = ? WHERE id = ? `).run( title?.trim() ?? null, amount !== undefined ? Number(amount) : null, category ?? null, subcategory !== undefined ? subcategory : null, date ?? null, is_recurring !== undefined ? (is_recurring ? 1 : 0) : null, recurrence_rule !== undefined ? (recurrence_rule || null) : entry.recurrence_rule, id ); if (linkedPayment) { db.get().prepare(` UPDATE budget_loan_payments SET amount = COALESCE(?, amount), paid_date = COALESCE(?, paid_date) WHERE id = ? `).run( amount !== undefined ? cents(amount) : null, date ?? null, linkedPayment.id ); refreshLoanStatus(linkedPayment.loan_id); } }); tx(); const updated = entryWithLoanMeta(id); res.json({ data: updated }); } catch (err) { log.error('', err); res.status(500).json({ error: 'Internal error', 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: 'Entry not found', code: 404 }); const linkedPayment = db.get().prepare(` SELECT * FROM budget_loan_payments WHERE budget_entry_id = ? `).get(id); const tx = db.get().transaction(() => { if (linkedPayment) { db.get().prepare('DELETE FROM budget_loan_payments WHERE id = ?').run(linkedPayment.id); } db.get().prepare('DELETE FROM budget_entries WHERE id = ?').run(id); if (linkedPayment) refreshLoanStatus(linkedPayment.loan_id); }); tx(); // 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: 'Internal error', code: 500 }); } }); export default router;