feat(buchhaltung): recurring job, budget alerts, audit endpoint, konto-typen CRUD

This commit is contained in:
Matthias Hochmeister
2026-03-30 15:04:06 +02:00
parent bbbfc8eaaa
commit d833b3c224
7 changed files with 306 additions and 1 deletions

View File

@@ -115,6 +115,45 @@ class BuchhaltungController {
} }
} }
async createKontoTyp(req: Request, res: Response): Promise<void> {
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<void> {
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<void> {
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 ─────────────────────────────────────────────────────────────── // ── Bankkonten ───────────────────────────────────────────────────────────────
async listBankkonten(_req: Request, res: Response): Promise<void> { async listBankkonten(_req: Request, res: Response): Promise<void> {
@@ -486,6 +525,20 @@ class BuchhaltungController {
} }
} }
// ── Audit ────────────────────────────────────────────────────────────────────
async getAudit(req: Request, res: Response): Promise<void> {
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 ──────────────────────────────────────────────────────────── // ── Erstattungen ────────────────────────────────────────────────────────────
async createErstattung(req: Request, res: Response): Promise<void> { async createErstattung(req: Request, res: Response): Promise<void> {

View File

@@ -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;

View File

@@ -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<typeof setInterval> | 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<void> {
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<string, number> = {
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');
}

View File

@@ -24,6 +24,9 @@ router.post('/haushaltsjahre/:id/close', authenticate, requirePermission('buchha
// ── Konto-Typen ─────────────────────────────────────────────────────────────── // ── 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 ──────────────────────────────────────────────────────────────── // ── Bankkonten ────────────────────────────────────────────────────────────────
router.get('/bankkonten', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.listBankkonten.bind(buchhaltungController)); 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.post('/erstattungen', authenticate, requirePermission('buchhaltung:create'), buchhaltungController.createErstattung.bind(buchhaltungController));
router.get('/transaktionen/:id/erstattung-links', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getErstattungLinks.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 ───────────────────────────────────────────────────────────────── // ── CSV Export ─────────────────────────────────────────────────────────────────
router.get('/export/csv', authenticate, requirePermission('buchhaltung:export'), buchhaltungController.exportCsv.bind(buchhaltungController)); router.get('/export/csv', authenticate, requirePermission('buchhaltung:export'), buchhaltungController.exportCsv.bind(buchhaltungController));

View File

@@ -7,6 +7,7 @@ import { startNotificationJob, stopNotificationJob } from './jobs/notification-g
import { startReminderJob, stopReminderJob } from './jobs/reminder.job'; import { startReminderJob, stopReminderJob } from './jobs/reminder.job';
import { startIssueReminderJob, stopIssueReminderJob } from './jobs/issue-reminder.job'; import { startIssueReminderJob, stopIssueReminderJob } from './jobs/issue-reminder.job';
import { startChecklistReminderJob, stopChecklistReminderJob } from './jobs/checklist-reminder.job'; import { startChecklistReminderJob, stopChecklistReminderJob } from './jobs/checklist-reminder.job';
import { startBuchhaltungRecurringJob, stopBuchhaltungRecurringJob } from './jobs/buchhaltung-recurring.job';
import { permissionService } from './services/permission.service'; import { permissionService } from './services/permission.service';
const startServer = async (): Promise<void> => { const startServer = async (): Promise<void> => {
@@ -40,6 +41,9 @@ const startServer = async (): Promise<void> => {
// Start the checklist reminder job // Start the checklist reminder job
startChecklistReminderJob(); startChecklistReminderJob();
// Start the buchhaltung recurring transaction job
startBuchhaltungRecurringJob();
// Start the server // Start the server
const server = app.listen(environment.port, () => { const server = app.listen(environment.port, () => {
logger.info('Server started successfully', { logger.info('Server started successfully', {
@@ -66,6 +70,7 @@ const startServer = async (): Promise<void> => {
stopReminderJob(); stopReminderJob();
stopIssueReminderJob(); stopIssueReminderJob();
stopChecklistReminderJob(); stopChecklistReminderJob();
stopBuchhaltungRecurringJob();
server.close(async () => { server.close(async () => {
logger.info('HTTP server closed'); logger.info('HTTP server closed');

View File

@@ -5,6 +5,8 @@
import pool from '../config/database'; import pool from '../config/database';
import logger from '../utils/logger'; import logger from '../utils/logger';
import fs from 'fs'; import fs from 'fs';
import { permissionService } from './permission.service';
import notificationService from './notification.service';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Kategorien (Categories) // 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) // Bankkonten (Bank Accounts)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -785,6 +839,38 @@ async function bookTransaktion(id: number, userId: string) {
[id, userId] [id, userId]
); );
if (result.rows[0]) await logAudit(id, 'gebucht', {}, 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; return result.rows[0] || null;
} catch (error) { } catch (error) {
logger.error('BuchhaltungService.bookTransaktion failed', { error, id }); logger.error('BuchhaltungService.bookTransaktion failed', { error, id });
@@ -1365,6 +1451,9 @@ const buchhaltungService = {
updateHaushaltsjahr, updateHaushaltsjahr,
closeHaushaltsjahr, closeHaushaltsjahr,
getAllKontoTypen, getAllKontoTypen,
createKontoTyp,
updateKontoTyp,
deleteKontoTyp,
getAllBankkonten, getAllBankkonten,
getBankkontoById, getBankkontoById,
createBankkonto, createBankkonto,

View File

@@ -11,6 +11,7 @@ import type {
Freigabe, Freigabe,
Kategorie, Kategorie,
ErstattungFormData, ErstattungLinks, ErstattungFormData, ErstattungLinks,
BuchhaltungAudit,
} from '../types/buchhaltung.types'; } from '../types/buchhaltung.types';
export const buchhaltungApi = { export const buchhaltungApi = {
@@ -38,6 +39,17 @@ export const buchhaltungApi = {
const r = await api.get('/api/buchhaltung/konto-typen'); const r = await api.get('/api/buchhaltung/konto-typen');
return r.data.data; return r.data.data;
}, },
createKontoTyp: async (data: { bezeichnung: string; art: string; sort_order?: number }): Promise<KontoTyp> => {
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<KontoTyp> => {
const r = await api.patch(`/api/buchhaltung/konto-typen/${id}`, data);
return r.data.data;
},
deleteKontoTyp: async (id: number): Promise<void> => {
await api.delete(`/api/buchhaltung/konto-typen/${id}`);
},
// ── Bankkonten ─────────────────────────────────────────────────────────────── // ── Bankkonten ───────────────────────────────────────────────────────────────
getBankkonten: async (): Promise<Bankkonto[]> => { getBankkonten: async (): Promise<Bankkonto[]> => {
@@ -207,4 +219,10 @@ export const buchhaltungApi = {
const r = await api.get(`/api/buchhaltung/transaktionen/${transaktionId}/erstattung-links`); const r = await api.get(`/api/buchhaltung/transaktionen/${transaktionId}/erstattung-links`);
return r.data.data; return r.data.data;
}, },
// ── Audit ─────────────────────────────────────────────────────────────────
getAudit: async (transaktionId: number): Promise<BuchhaltungAudit[]> => {
const r = await api.get(`/api/buchhaltung/audit/${transaktionId}`);
return r.data.data;
},
}; };