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

@@ -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');
}