Initial commit after fork. Moving Budget categories to Database and adding subcategories, with customization options
This commit is contained in:
@@ -121,6 +121,7 @@ const MIGRATIONS_SQL = {
|
||||
title TEXT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT 'Sonstiges',
|
||||
subcategory TEXT NOT NULL DEFAULT '',
|
||||
date TEXT NOT NULL,
|
||||
is_recurring INTEGER NOT NULL DEFAULT 0,
|
||||
recurrence_rule TEXT,
|
||||
@@ -128,6 +129,21 @@ const MIGRATIONS_SQL = {
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS budget_categories (
|
||||
key TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK(type IN ('expense', 'income')),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS budget_subcategories (
|
||||
key TEXT PRIMARY KEY,
|
||||
category_key TEXT NOT NULL REFERENCES budget_categories(key) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
UNIQUE(category_key, name)
|
||||
);
|
||||
CREATE TRIGGER IF NOT EXISTS trg_users_updated_at
|
||||
AFTER UPDATE ON users FOR EACH ROW
|
||||
BEGIN UPDATE users SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
||||
|
||||
+127
@@ -544,6 +544,133 @@ const MIGRATIONS = [
|
||||
CREATE INDEX IF NOT EXISTS idx_cal_events_ref ON calendar_events(calendar_ref_id);
|
||||
`,
|
||||
},
|
||||
{
|
||||
version: 15,
|
||||
description: 'Budget-Ausgabenkategorien als stabile Schlüssel mit Unterkategorien',
|
||||
up: `
|
||||
ALTER TABLE budget_entries ADD COLUMN subcategory TEXT NOT NULL DEFAULT '';
|
||||
|
||||
UPDATE budget_entries
|
||||
SET category = CASE category
|
||||
WHEN 'Lebensmittel' THEN 'food'
|
||||
WHEN 'Miete' THEN 'housing'
|
||||
WHEN 'Versicherung' THEN 'financial_other'
|
||||
WHEN 'Mobilität' THEN 'transport'
|
||||
WHEN 'Freizeit' THEN 'leisure'
|
||||
WHEN 'Kleidung' THEN 'shopping_clothing'
|
||||
WHEN 'Gesundheit' THEN 'personal_health'
|
||||
WHEN 'Bildung' THEN 'education'
|
||||
WHEN 'Sonstiges' THEN 'financial_other'
|
||||
ELSE category
|
||||
END
|
||||
WHERE amount < 0;
|
||||
|
||||
UPDATE budget_entries
|
||||
SET subcategory = CASE category
|
||||
WHEN 'housing' THEN 'rent_mortgage'
|
||||
WHEN 'food' THEN 'groceries'
|
||||
WHEN 'transport' THEN 'fuel'
|
||||
WHEN 'personal_health' THEN 'pharmacy'
|
||||
WHEN 'leisure' THEN 'events'
|
||||
WHEN 'shopping_clothing' THEN 'clothes_shoes'
|
||||
WHEN 'education' THEN 'courses_college'
|
||||
WHEN 'financial_other' THEN 'insurance_other'
|
||||
ELSE ''
|
||||
END
|
||||
WHERE amount < 0 AND subcategory = '';
|
||||
|
||||
UPDATE budget_entries
|
||||
SET category = 'Sonstiges Einkommen'
|
||||
WHERE amount > 0 AND category = 'Sonstiges';
|
||||
`,
|
||||
},
|
||||
{
|
||||
version: 16,
|
||||
description: 'Budget-Kategorien und Unterkategorien in eigene Tabellen auslagern',
|
||||
up: `
|
||||
CREATE TABLE IF NOT EXISTS budget_categories (
|
||||
key TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK(type IN ('expense', 'income')),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS budget_subcategories (
|
||||
key TEXT PRIMARY KEY,
|
||||
category_key TEXT NOT NULL REFERENCES budget_categories(key) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
UNIQUE(category_key, name)
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO budget_categories (key, name, type, sort_order) VALUES
|
||||
('housing', 'Habitação / Casa', 'expense', 0),
|
||||
('food', 'Alimentação', 'expense', 1),
|
||||
('transport', 'Transporte', 'expense', 2),
|
||||
('personal_health', 'Cuidados Pessoais / Saúde', 'expense', 3),
|
||||
('leisure', 'Lazer e Entretenimento', 'expense', 4),
|
||||
('shopping_clothing', 'Compras e Vestuário', 'expense', 5),
|
||||
('education', 'Educação', 'expense', 6),
|
||||
('financial_other', 'Serviços Financeiros e Outros', 'expense', 7),
|
||||
('Erwerbseinkommen', 'Erwerbseinkommen', 'income', 0),
|
||||
('Kapitalerträge', 'Kapitalerträge', 'income', 1),
|
||||
('Geschenke & Transfers', 'Geschenke & Transfers', 'income', 2),
|
||||
('Sozialleistungen', 'Sozialleistungen', 'income', 3),
|
||||
('Sonstiges Einkommen', 'Sonstiges Einkommen', 'income', 4);
|
||||
|
||||
INSERT OR IGNORE INTO budget_subcategories (key, category_key, name, sort_order) VALUES
|
||||
('rent_mortgage', 'housing', 'Aluguel / Prestação', 0),
|
||||
('condominium', 'housing', 'Condomínio', 1),
|
||||
('utilities', 'housing', 'Luz / Água / Gás', 2),
|
||||
('internet_tv_phone', 'housing', 'Internet / TV / Telefone', 3),
|
||||
('renovation_maintenance', 'housing', 'Reforma / Manutenção', 4),
|
||||
('cleaning', 'housing', 'Limpeza', 5),
|
||||
('groceries', 'food', 'Supermercado', 0),
|
||||
('restaurants_bars', 'food', 'Restaurante / Bares', 1),
|
||||
('snacks_fast_food', 'food', 'Lanches / Fast Food', 2),
|
||||
('bakery', 'food', 'Padaria', 3),
|
||||
('fuel', 'transport', 'Combustível', 0),
|
||||
('parking_tolls', 'transport', 'Estacionamento / Pedágio', 1),
|
||||
('public_transport', 'transport', 'Transporte Público', 2),
|
||||
('apps_taxi', 'transport', 'Aplicativos / Táxi', 3),
|
||||
('maintenance_insurance', 'transport', 'Manutenção / Seguro', 4),
|
||||
('pharmacy', 'personal_health', 'Farmácia', 0),
|
||||
('health_insurance', 'personal_health', 'Plano de Saúde', 1),
|
||||
('gym_sports', 'personal_health', 'Academia / Esportes', 2),
|
||||
('beauty_cosmetics', 'personal_health', 'Beleza / Cosméticos', 3),
|
||||
('travel', 'leisure', 'Viagens', 0),
|
||||
('streaming', 'leisure', 'Streaming', 1),
|
||||
('events', 'leisure', 'Eventos', 2),
|
||||
('hobbies', 'leisure', 'Hobbies', 3),
|
||||
('clothes_shoes', 'shopping_clothing', 'Roupas / Calçados', 0),
|
||||
('electronics', 'shopping_clothing', 'Eletrônicos', 1),
|
||||
('gifts', 'shopping_clothing', 'Presentes', 2),
|
||||
('courses_college', 'education', 'Cursos / Faculdade', 0),
|
||||
('school_supplies', 'education', 'Material Escolar', 1),
|
||||
('languages', 'education', 'Idiomas', 2),
|
||||
('loans_interest', 'financial_other', 'Empréstimos / Juros', 0),
|
||||
('bank_fees', 'financial_other', 'Tarifas Bancárias', 1),
|
||||
('insurance_other', 'financial_other', 'Seguros', 2),
|
||||
('investments', 'financial_other', 'Investimentos', 3),
|
||||
('taxes', 'financial_other', 'Impostos', 4);
|
||||
|
||||
INSERT OR IGNORE INTO budget_categories (key, name, type, sort_order)
|
||||
SELECT category, category, CASE WHEN amount < 0 THEN 'expense' ELSE 'income' END, 1000
|
||||
FROM budget_entries
|
||||
WHERE category NOT IN (SELECT key FROM budget_categories)
|
||||
GROUP BY category;
|
||||
|
||||
INSERT OR IGNORE INTO budget_subcategories (key, category_key, name, sort_order)
|
||||
SELECT subcategory, category, subcategory, 1000
|
||||
FROM budget_entries
|
||||
WHERE subcategory != ''
|
||||
AND subcategory NOT IN (SELECT key FROM budget_subcategories)
|
||||
AND category IN (SELECT key FROM budget_categories WHERE type = 'expense')
|
||||
GROUP BY category, subcategory;
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
+165
-23
@@ -7,7 +7,7 @@
|
||||
import { createLogger } from '../logger.js';
|
||||
import express from 'express';
|
||||
import * as db from '../db.js';
|
||||
import { str, oneOf, date as validateDate, num, rrule, collectErrors, MAX_TITLE, MONTH_RE } from '../middleware/validate.js';
|
||||
import { str, oneOf, date as validateDate, num, rrule, collectErrors, MAX_TITLE, MAX_SHORT, MONTH_RE } from '../middleware/validate.js';
|
||||
|
||||
const log = createLogger('Budget');
|
||||
|
||||
@@ -57,23 +57,87 @@ function generateRecurringInstances(database, month) {
|
||||
|
||||
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);
|
||||
(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);
|
||||
}
|
||||
}
|
||||
|
||||
const EXPENSE_CATEGORIES = [
|
||||
'Lebensmittel', 'Miete', 'Versicherung', 'Mobilität',
|
||||
'Freizeit', 'Kleidung', 'Gesundheit', 'Bildung', 'Sonstiges',
|
||||
];
|
||||
function slugify(value) {
|
||||
return String(value || '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
.slice(0, 48) || 'category';
|
||||
}
|
||||
|
||||
const INCOME_CATEGORIES = [
|
||||
'Erwerbseinkommen', 'Kapitalerträge', 'Geschenke & Transfers',
|
||||
'Sozialleistungen', 'Sonstiges Einkommen',
|
||||
];
|
||||
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;
|
||||
}
|
||||
|
||||
const VALID_CATEGORIES = [...EXPENSE_CATEGORIES, ...INCOME_CATEGORIES];
|
||||
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;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Statische Routen vor /:id
|
||||
@@ -155,7 +219,7 @@ router.get('/export', (req, res) => {
|
||||
ORDER BY b.date ASC
|
||||
`).all(from, to);
|
||||
|
||||
const header = 'Datum,Titel,Betrag,Kategorie,Wiederkehrend,Erstellt von\n';
|
||||
const header = 'Datum,Titel,Betrag,Kategorie,Unterkategorie,Wiederkehrend,Erstellt von\n';
|
||||
const csvSafe = (val) => {
|
||||
let s = String(val || '').replace(/"/g, '""');
|
||||
if (/^[=+\-@\t\r]/.test(s)) s = "'" + s;
|
||||
@@ -167,6 +231,7 @@ router.get('/export', (req, res) => {
|
||||
csvSafe(e.title),
|
||||
e.amount.toFixed(2).replace('.', ','),
|
||||
e.category,
|
||||
e.subcategory || '',
|
||||
e.is_recurring ? 'Ja' : 'Nein',
|
||||
csvSafe(e.creator_name),
|
||||
].join(',')
|
||||
@@ -187,7 +252,70 @@ router.get('/export', (req, res) => {
|
||||
* Response: { data: { categories } }
|
||||
*/
|
||||
router.get('/meta', (req, res) => {
|
||||
res.json({ data: { categories: VALID_CATEGORIES } });
|
||||
res.json({ data: loadBudgetMeta() });
|
||||
});
|
||||
|
||||
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: 'Kategorie existiert bereits.', 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 Fehler:', err);
|
||||
res.status(500).json({ error: 'Interner Fehler', 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: 'Kategorie nicht gefunden.', 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: 'Unterkategorie existiert bereits.', 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 Fehler:', err);
|
||||
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
@@ -220,7 +348,7 @@ router.get('/', (req, res) => {
|
||||
`;
|
||||
const params = [from, to];
|
||||
|
||||
if (req.query.category && VALID_CATEGORIES.includes(req.query.category)) {
|
||||
if (req.query.category && validCategoryKeys().includes(req.query.category)) {
|
||||
sql += ' AND b.category = ?';
|
||||
params.push(req.query.category);
|
||||
}
|
||||
@@ -238,24 +366,29 @@ router.get('/', (req, res) => {
|
||||
/**
|
||||
* POST /api/v1/budget
|
||||
* Neuen Eintrag anlegen.
|
||||
* Body: { title, amount, category?, date, is_recurring?, recurrence_rule? }
|
||||
* 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 vCat = oneOf(req.body.category || 'Sonstiges', VALID_CATEGORIES, 'Kategorie');
|
||||
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: 'Ungültige Unterkategorie.', code: 400 });
|
||||
}
|
||||
|
||||
const result = db.get().prepare(`
|
||||
INSERT INTO budget_entries (title, amount, category, date, is_recurring, recurrence_rule, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO budget_entries (title, amount, category, subcategory, date, is_recurring, recurrence_rule, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
vTitle.value, vAmount.value, vCat.value || 'Sonstiges', vDate.value,
|
||||
vTitle.value, vAmount.value, vCat.value || fallbackCategory, subcategory, vDate.value,
|
||||
req.body.is_recurring ? 1 : 0, vRrule.value,
|
||||
req.session.userId
|
||||
);
|
||||
@@ -288,18 +421,26 @@ router.put('/:id', (req, res) => {
|
||||
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.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, date, is_recurring, recurrence_rule } = req.body;
|
||||
const { title, amount, category, subcategory: requestedSubcategory, date, is_recurring, recurrence_rule } = req.body;
|
||||
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: 'Ungültige Unterkategorie.', code: 400 });
|
||||
}
|
||||
|
||||
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 = ?
|
||||
@@ -308,6 +449,7 @@ router.put('/:id', (req, res) => {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user