feat(buchhaltung): recurring job, budget alerts, audit endpoint, konto-typen CRUD
This commit is contained in:
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');
|
||||
}
|
||||
Reference in New Issue
Block a user