From d833b3c224001054fb476b00838f2a72e4b1c79c Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Mon, 30 Mar 2026 15:04:06 +0200 Subject: [PATCH] feat(buchhaltung): recurring job, budget alerts, audit endpoint, konto-typen CRUD --- .../src/controllers/buchhaltung.controller.ts | 53 ++++++++ .../081_buchhaltung_alert_threshold.sql | 8 ++ backend/src/jobs/buchhaltung-recurring.job.ts | 126 ++++++++++++++++++ backend/src/routes/buchhaltung.routes.ts | 8 +- backend/src/server.ts | 5 + backend/src/services/buchhaltung.service.ts | 89 +++++++++++++ frontend/src/services/buchhaltung.ts | 18 +++ 7 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 backend/src/database/migrations/081_buchhaltung_alert_threshold.sql create mode 100644 backend/src/jobs/buchhaltung-recurring.job.ts diff --git a/backend/src/controllers/buchhaltung.controller.ts b/backend/src/controllers/buchhaltung.controller.ts index 380a396..660ce1e 100644 --- a/backend/src/controllers/buchhaltung.controller.ts +++ b/backend/src/controllers/buchhaltung.controller.ts @@ -115,6 +115,45 @@ class BuchhaltungController { } } + async createKontoTyp(req: Request, res: Response): Promise { + try { + const data = await buchhaltungService.createKontoTyp(req.body); + res.status(201).json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.createKontoTyp', { error }); + res.status(500).json({ success: false, message: 'Kontotyp konnte nicht erstellt werden' }); + } + } + + async updateKontoTyp(req: Request, res: Response): Promise { + const id = parseInt(param(req, 'id'), 10); + if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } + try { + const data = await buchhaltungService.updateKontoTyp(id, req.body); + res.json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.updateKontoTyp', { error }); + res.status(500).json({ success: false, message: 'Kontotyp konnte nicht aktualisiert werden' }); + } + } + + async deleteKontoTyp(req: Request, res: Response): Promise { + const id = parseInt(param(req, 'id'), 10); + if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } + try { + await buchhaltungService.deleteKontoTyp(id); + res.json({ success: true }); + } catch (error) { + const statusCode = (error as any).statusCode; + if (statusCode === 409) { + res.status(409).json({ success: false, message: (error as Error).message }); + return; + } + logger.error('BuchhaltungController.deleteKontoTyp', { error }); + res.status(500).json({ success: false, message: 'Kontotyp konnte nicht gelöscht werden' }); + } + } + // ── Bankkonten ─────────────────────────────────────────────────────────────── async listBankkonten(_req: Request, res: Response): Promise { @@ -486,6 +525,20 @@ class BuchhaltungController { } } + // ── Audit ──────────────────────────────────────────────────────────────────── + + async getAudit(req: Request, res: Response): Promise { + const id = parseInt(param(req, 'transaktionId'), 10); + if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } + try { + const data = await buchhaltungService.getAuditByTransaktion(id); + res.json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.getAudit', { error }); + res.status(500).json({ success: false, message: 'Audit konnte nicht geladen werden' }); + } + } + // ── Erstattungen ──────────────────────────────────────────────────────────── async createErstattung(req: Request, res: Response): Promise { diff --git a/backend/src/database/migrations/081_buchhaltung_alert_threshold.sql b/backend/src/database/migrations/081_buchhaltung_alert_threshold.sql new file mode 100644 index 0000000..6c8e660 --- /dev/null +++ b/backend/src/database/migrations/081_buchhaltung_alert_threshold.sql @@ -0,0 +1,8 @@ +-- Migration 081: Add alert_threshold to buchhaltung_konten + default setting + +ALTER TABLE buchhaltung_konten + ADD COLUMN IF NOT EXISTS alert_threshold INT CHECK (alert_threshold BETWEEN 0 AND 100); + +INSERT INTO buchhaltung_einstellungen (key, value) +VALUES ('default_alert_threshold', '"80"') +ON CONFLICT (key) DO NOTHING; diff --git a/backend/src/jobs/buchhaltung-recurring.job.ts b/backend/src/jobs/buchhaltung-recurring.job.ts new file mode 100644 index 0000000..446593f --- /dev/null +++ b/backend/src/jobs/buchhaltung-recurring.job.ts @@ -0,0 +1,126 @@ +import pool from '../config/database'; +import buchhaltungService from '../services/buchhaltung.service'; +import logger from '../utils/logger'; + +const INTERVAL_MS = 60 * 60 * 1000; // hourly +let jobInterval: ReturnType | null = null; +let isRunning = false; + +/** Advance a date by N months, then apply ausfuehrungstag. */ +function advanceDate(base: Date, months: number, ausfuehrungstag: 'erster' | 'mitte' | 'letzter'): Date { + const d = new Date(base); + d.setMonth(d.getMonth() + months); + if (ausfuehrungstag === 'erster') { + d.setDate(1); + } else if (ausfuehrungstag === 'mitte') { + d.setDate(15); + } else { + // last day of the month + d.setMonth(d.getMonth() + 1, 0); + } + return d; +} + +async function runRecurringCheck(): Promise { + if (isRunning) { + logger.warn('BuchhaltungRecurringJob: previous run still in progress — skipping'); + return; + } + isRunning = true; + try { + const dueResult = await pool.query( + `SELECT * FROM buchhaltung_wiederkehrend WHERE aktiv = TRUE AND naechste_ausfuehrung <= CURRENT_DATE` + ); + + if (dueResult.rows.length === 0) { + isRunning = false; + return; + } + + const haushaltsjahr = await buchhaltungService.getCurrentHaushaltsjahr(); + if (!haushaltsjahr) { + logger.warn('BuchhaltungRecurringJob: no open fiscal year — skipping'); + isRunning = false; + return; + } + + let processed = 0; + for (const template of dueResult.rows) { + try { + // Create transaction directly to set wiederkehrend_id + const txResult = await pool.query( + `INSERT INTO buchhaltung_transaktionen + (haushaltsjahr_id, konto_id, bankkonto_id, typ, betrag, datum, beschreibung, + empfaenger_auftraggeber, erstellt_von, wiederkehrend_id, status) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'entwurf') + RETURNING *`, + [ + haushaltsjahr.id, + template.konto_id, + template.bankkonto_id, + template.typ, + template.betrag, + template.naechste_ausfuehrung, + template.beschreibung, + template.empfaenger_auftraggeber, + template.erstellt_von, + template.id, + ] + ); + const tx = txResult.rows[0]; + if (tx) { + await buchhaltungService.logAudit(tx.id, 'erstellt_wiederkehrend', { wiederkehrend_id: template.id }, template.erstellt_von); + } + + // Advance naechste_ausfuehrung + const monthsMap: Record = { + monatlich: 1, + quartalsweise: 3, + halbjaehrlich: 6, + jaehrlich: 12, + }; + const months = monthsMap[template.intervall] ?? 1; + const nextDate = advanceDate(new Date(template.naechste_ausfuehrung), months, template.ausfuehrungstag); + const nextDateStr = nextDate.toISOString().split('T')[0]; + + await pool.query( + `UPDATE buchhaltung_wiederkehrend SET naechste_ausfuehrung = $1 WHERE id = $2`, + [nextDateStr, template.id] + ); + processed++; + } catch (templateErr) { + logger.error('BuchhaltungRecurringJob: failed to process template', { + id: template.id, + error: templateErr instanceof Error ? templateErr.message : String(templateErr), + }); + } + } + + logger.info(`BuchhaltungRecurringJob: processed ${processed} recurring transactions`); + } catch (error) { + logger.error('BuchhaltungRecurringJob: unexpected error', { + error: error instanceof Error ? error.message : String(error), + }); + } finally { + isRunning = false; + } +} + +export function startBuchhaltungRecurringJob(): void { + if (jobInterval !== null) { + logger.warn('BuchhaltungRecurringJob: already running — skipping duplicate start'); + return; + } + // Delay initial run to let migrations settle + setTimeout(() => runRecurringCheck(), 60_000); + jobInterval = setInterval(() => runRecurringCheck(), INTERVAL_MS); + logger.info('Buchhaltung recurring job scheduled (hourly)'); +} + +export function stopBuchhaltungRecurringJob(): void { + if (jobInterval !== null) { + clearInterval(jobInterval); + jobInterval = null; + } + logger.info('Buchhaltung recurring job stopped'); +} diff --git a/backend/src/routes/buchhaltung.routes.ts b/backend/src/routes/buchhaltung.routes.ts index d9274d8..95a3cf6 100644 --- a/backend/src/routes/buchhaltung.routes.ts +++ b/backend/src/routes/buchhaltung.routes.ts @@ -23,7 +23,10 @@ router.patch('/haushaltsjahre/:id', authenticate, requirePermission('buchhalt router.post('/haushaltsjahre/:id/close', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.closeHaushaltsjahr.bind(buchhaltungController)); // ── Konto-Typen ─────────────────────────────────────────────────────────────── -router.get('/konto-typen', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.listKontoTypen.bind(buchhaltungController)); +router.get('/konto-typen', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.listKontoTypen.bind(buchhaltungController)); +router.post('/konto-typen', authenticate, requirePermission('buchhaltung:manage_settings'), buchhaltungController.createKontoTyp.bind(buchhaltungController)); +router.patch('/konto-typen/:id', authenticate, requirePermission('buchhaltung:manage_settings'), buchhaltungController.updateKontoTyp.bind(buchhaltungController)); +router.delete('/konto-typen/:id', authenticate, requirePermission('buchhaltung:manage_settings'), buchhaltungController.deleteKontoTyp.bind(buchhaltungController)); // ── Bankkonten ──────────────────────────────────────────────────────────────── router.get('/bankkonten', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.listBankkonten.bind(buchhaltungController)); @@ -57,6 +60,9 @@ router.delete('/wiederkehrend/:id', authenticate, requirePermission('buchhaltung router.post('/erstattungen', authenticate, requirePermission('buchhaltung:create'), buchhaltungController.createErstattung.bind(buchhaltungController)); router.get('/transaktionen/:id/erstattung-links', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getErstattungLinks.bind(buchhaltungController)); +// ── Audit ────────────────────────────────────────────────────────────────────── +router.get('/audit/:transaktionId', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getAudit.bind(buchhaltungController)); + // ── CSV Export ───────────────────────────────────────────────────────────────── router.get('/export/csv', authenticate, requirePermission('buchhaltung:export'), buchhaltungController.exportCsv.bind(buchhaltungController)); diff --git a/backend/src/server.ts b/backend/src/server.ts index 164110c..f91de4d 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -7,6 +7,7 @@ import { startNotificationJob, stopNotificationJob } from './jobs/notification-g import { startReminderJob, stopReminderJob } from './jobs/reminder.job'; import { startIssueReminderJob, stopIssueReminderJob } from './jobs/issue-reminder.job'; import { startChecklistReminderJob, stopChecklistReminderJob } from './jobs/checklist-reminder.job'; +import { startBuchhaltungRecurringJob, stopBuchhaltungRecurringJob } from './jobs/buchhaltung-recurring.job'; import { permissionService } from './services/permission.service'; const startServer = async (): Promise => { @@ -40,6 +41,9 @@ const startServer = async (): Promise => { // Start the checklist reminder job startChecklistReminderJob(); + // Start the buchhaltung recurring transaction job + startBuchhaltungRecurringJob(); + // Start the server const server = app.listen(environment.port, () => { logger.info('Server started successfully', { @@ -66,6 +70,7 @@ const startServer = async (): Promise => { stopReminderJob(); stopIssueReminderJob(); stopChecklistReminderJob(); + stopBuchhaltungRecurringJob(); server.close(async () => { logger.info('HTTP server closed'); diff --git a/backend/src/services/buchhaltung.service.ts b/backend/src/services/buchhaltung.service.ts index 2810a30..5912339 100644 --- a/backend/src/services/buchhaltung.service.ts +++ b/backend/src/services/buchhaltung.service.ts @@ -5,6 +5,8 @@ import pool from '../config/database'; import logger from '../utils/logger'; import fs from 'fs'; +import { permissionService } from './permission.service'; +import notificationService from './notification.service'; // --------------------------------------------------------------------------- // Kategorien (Categories) @@ -197,6 +199,58 @@ async function getAllKontoTypen() { } } +async function createKontoTyp(data: { bezeichnung: string; art: string; sort_order?: number }) { + try { + const result = await pool.query( + `INSERT INTO buchhaltung_konto_typen (bezeichnung, art, sort_order) VALUES ($1, $2, $3) RETURNING *`, + [data.bezeichnung, data.art, data.sort_order ?? 0] + ); + return result.rows[0]; + } catch (error) { + logger.error('BuchhaltungService.createKontoTyp failed', { error }); + throw new Error('Kontotyp konnte nicht erstellt werden'); + } +} + +async function updateKontoTyp(id: number, data: { bezeichnung?: string; art?: string; sort_order?: number }) { + try { + const fields: string[] = []; + const values: unknown[] = []; + let idx = 1; + if (data.bezeichnung !== undefined) { fields.push(`bezeichnung = $${idx++}`); values.push(data.bezeichnung); } + if (data.art !== undefined) { fields.push(`art = $${idx++}`); values.push(data.art); } + if (data.sort_order !== undefined) { fields.push(`sort_order = $${idx++}`); values.push(data.sort_order); } + if (fields.length === 0) throw new Error('Keine Felder zum Aktualisieren'); + values.push(id); + const result = await pool.query( + `UPDATE buchhaltung_konto_typen SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`, + values + ); + return result.rows[0] || null; + } catch (error) { + logger.error('BuchhaltungService.updateKontoTyp failed', { error, id }); + throw new Error('Kontotyp konnte nicht aktualisiert werden'); + } +} + +async function deleteKontoTyp(id: number) { + try { + const check = await pool.query( + `SELECT COUNT(*) FROM buchhaltung_bankkonten WHERE konto_typ_id = $1`, + [id] + ); + if (parseInt(check.rows[0].count) > 0) { + const err = new Error('Kontotyp wird noch verwendet'); + (err as any).statusCode = 409; + throw err; + } + await pool.query(`DELETE FROM buchhaltung_konto_typen WHERE id = $1`, [id]); + } catch (error) { + logger.error('BuchhaltungService.deleteKontoTyp failed', { error, id }); + throw error; + } +} + // --------------------------------------------------------------------------- // Bankkonten (Bank Accounts) // --------------------------------------------------------------------------- @@ -785,6 +839,38 @@ async function bookTransaktion(id: number, userId: string) { [id, userId] ); if (result.rows[0]) await logAudit(id, 'gebucht', {}, userId); + + // Budget alert check (non-fatal) + try { + const tx = result.rows[0]; + if (tx && tx.konto_id) { + const budget = await getBudgetUtilisation(tx.konto_id); + if (budget && budget.auslastung_prozent > 0) { + const settings = await getEinstellungen(); + const globalThreshold = parseInt(settings.default_alert_threshold as string) || 80; + const threshold = budget.alert_threshold ?? globalThreshold; + + if (budget.auslastung_prozent >= threshold) { + const users = await permissionService.getUsersWithPermission('buchhaltung:manage_accounts'); + for (const user of users) { + await notificationService.createNotification({ + user_id: user.id, + typ: 'buchhaltung_budget_warnung', + titel: 'Budget-Warnung', + nachricht: `${budget.bezeichnung}: ${budget.auslastung_prozent}% ausgelastet`, + schwere: 'warnung', + link: `/buchhaltung/konto/${tx.konto_id}`, + quell_id: `budget-alert-${tx.konto_id}`, + quell_typ: 'buchhaltung_budget_warnung', + }); + } + } + } + } + } catch (alertErr) { + logger.warn('BuchhaltungService.bookTransaktion budget alert failed (non-fatal)', { alertErr }); + } + return result.rows[0] || null; } catch (error) { logger.error('BuchhaltungService.bookTransaktion failed', { error, id }); @@ -1365,6 +1451,9 @@ const buchhaltungService = { updateHaushaltsjahr, closeHaushaltsjahr, getAllKontoTypen, + createKontoTyp, + updateKontoTyp, + deleteKontoTyp, getAllBankkonten, getBankkontoById, createBankkonto, diff --git a/frontend/src/services/buchhaltung.ts b/frontend/src/services/buchhaltung.ts index 9565433..c00c2f0 100644 --- a/frontend/src/services/buchhaltung.ts +++ b/frontend/src/services/buchhaltung.ts @@ -11,6 +11,7 @@ import type { Freigabe, Kategorie, ErstattungFormData, ErstattungLinks, + BuchhaltungAudit, } from '../types/buchhaltung.types'; export const buchhaltungApi = { @@ -38,6 +39,17 @@ export const buchhaltungApi = { const r = await api.get('/api/buchhaltung/konto-typen'); return r.data.data; }, + createKontoTyp: async (data: { bezeichnung: string; art: string; sort_order?: number }): Promise => { + const r = await api.post('/api/buchhaltung/konto-typen', data); + return r.data.data; + }, + updateKontoTyp: async (id: number, data: Partial<{ bezeichnung: string; art: string; sort_order: number }>): Promise => { + const r = await api.patch(`/api/buchhaltung/konto-typen/${id}`, data); + return r.data.data; + }, + deleteKontoTyp: async (id: number): Promise => { + await api.delete(`/api/buchhaltung/konto-typen/${id}`); + }, // ── Bankkonten ─────────────────────────────────────────────────────────────── getBankkonten: async (): Promise => { @@ -207,4 +219,10 @@ export const buchhaltungApi = { const r = await api.get(`/api/buchhaltung/transaktionen/${transaktionId}/erstattung-links`); return r.data.data; }, + + // ── Audit ───────────────────────────────────────────────────────────────── + getAudit: async (transaktionId: number): Promise => { + const r = await api.get(`/api/buchhaltung/audit/${transaktionId}`); + return r.data.data; + }, };