feat(buchhaltung): recurring job, budget alerts, audit endpoint, konto-typen CRUD
This commit is contained in:
@@ -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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
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 ────────────────────────────────────────────────────────────
|
||||
|
||||
async createErstattung(req: Request, res: Response): Promise<void> {
|
||||
|
||||
@@ -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;
|
||||
126
backend/src/jobs/buchhaltung-recurring.job.ts
Normal file
126
backend/src/jobs/buchhaltung-recurring.job.ts
Normal 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');
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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<void> => {
|
||||
@@ -40,6 +41,9 @@ const startServer = async (): Promise<void> => {
|
||||
// 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<void> => {
|
||||
stopReminderJob();
|
||||
stopIssueReminderJob();
|
||||
stopChecklistReminderJob();
|
||||
stopBuchhaltungRecurringJob();
|
||||
|
||||
server.close(async () => {
|
||||
logger.info('HTTP server closed');
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user