feat(buchhaltung): budget types, erstattungen, recurring tab move, overview dividers, order completion guard

This commit is contained in:
Matthias Hochmeister
2026-03-30 14:07:04 +02:00
parent 13aa4be599
commit b21abce9e3
10 changed files with 615 additions and 140 deletions

View File

@@ -486,6 +486,33 @@ class BuchhaltungController {
}
}
// ── Erstattungen ────────────────────────────────────────────────────────────
async createErstattung(req: Request, res: Response): Promise<void> {
try {
const data = await buchhaltungService.createErstattung({
...req.body,
erstellt_von: req.user!.id,
});
res.status(201).json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.createErstattung', { error });
res.status(500).json({ success: false, message: 'Erstattung konnte nicht erstellt werden' });
}
}
async getErstattungLinks(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.getErstattungLinks(id);
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.getErstattungLinks', { error });
res.status(500).json({ success: false, message: 'Erstattungsverknüpfungen konnten nicht geladen werden' });
}
}
// ── Freigaben ────────────────────────────────────────────────────────────────
async requestFreigabe(req: Request, res: Response): Promise<void> {

View File

@@ -0,0 +1,13 @@
-- Add budget type support to buchhaltung_konten
ALTER TABLE buchhaltung_konten
ADD COLUMN IF NOT EXISTS budget_typ TEXT NOT NULL DEFAULT 'detailliert';
ALTER TABLE buchhaltung_konten
ADD COLUMN IF NOT EXISTS budget_gesamt NUMERIC(12,2) NOT NULL DEFAULT 0;
-- Erstattung (reimbursement) linking table
CREATE TABLE IF NOT EXISTS buchhaltung_erstattung_zuordnungen (
erstattung_transaktion_id INT NOT NULL REFERENCES buchhaltung_transaktionen(id) ON DELETE CASCADE,
ausgabe_transaktion_id INT NOT NULL REFERENCES buchhaltung_transaktionen(id) ON DELETE CASCADE,
PRIMARY KEY (erstattung_transaktion_id, ausgabe_transaktion_id)
);

View File

@@ -53,6 +53,10 @@ router.post('/wiederkehrend', authenticate, requirePermission('buchhaltung
router.patch('/wiederkehrend/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.updateWiederkehrend.bind(buchhaltungController));
router.delete('/wiederkehrend/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.deleteWiederkehrend.bind(buchhaltungController));
// ── Erstattungen ──────────────────────────────────────────────────────────────
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));
// ── CSV Export ─────────────────────────────────────────────────────────────────
router.get('/export/csv', authenticate, requirePermission('buchhaltung:export'), buchhaltungController.exportCsv.bind(buchhaltungController));

View File

@@ -471,18 +471,28 @@ async function validateSubPotBudget(
}
async function createKonto(
data: { haushaltsjahr_id: number; konto_typ_id?: number; kontonummer: string; bezeichnung: string; parent_id?: number | null; budget_gwg?: number; budget_anlagen?: number; budget_instandhaltung?: number; notizen?: string },
data: { haushaltsjahr_id: number; konto_typ_id?: number; kontonummer: string; bezeichnung: string; parent_id?: number | null; budget_gwg?: number; budget_anlagen?: number; budget_instandhaltung?: number; notizen?: string; kategorie_id?: number | null; budget_typ?: string; budget_gesamt?: number },
userId: string
) {
try {
if (data.parent_id && (data.budget_gwg || data.budget_anlagen || data.budget_instandhaltung)) {
await validateSubPotBudget(data.parent_id, data.budget_gwg || 0, data.budget_anlagen || 0, data.budget_instandhaltung || 0);
}
// Child konten inherit parent's budget_typ
let budgetTyp = data.budget_typ || 'detailliert';
if (data.parent_id) {
const parentRow = await pool.query(`SELECT budget_typ FROM buchhaltung_konten WHERE id = $1`, [data.parent_id]);
if (parentRow.rows[0]) {
budgetTyp = parentRow.rows[0].budget_typ;
}
}
const result = await pool.query(
`INSERT INTO buchhaltung_konten (haushaltsjahr_id, konto_typ_id, kontonummer, bezeichnung, parent_id, budget_gwg, budget_anlagen, budget_instandhaltung, notizen, erstellt_von)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
`INSERT INTO buchhaltung_konten (haushaltsjahr_id, konto_typ_id, kontonummer, bezeichnung, parent_id, budget_gwg, budget_anlagen, budget_instandhaltung, notizen, erstellt_von, kategorie_id, budget_typ, budget_gesamt)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING *`,
[data.haushaltsjahr_id, data.konto_typ_id || null, data.kontonummer, data.bezeichnung, data.parent_id || null, data.budget_gwg || 0, data.budget_anlagen || 0, data.budget_instandhaltung || 0, data.notizen || null, userId]
[data.haushaltsjahr_id, data.konto_typ_id || null, data.kontonummer, data.bezeichnung, data.parent_id || null, data.budget_gwg || 0, data.budget_anlagen || 0, data.budget_instandhaltung || 0, data.notizen || null, userId, data.kategorie_id ?? null, budgetTyp, data.budget_gesamt || 0]
);
return result.rows[0];
} catch (error: any) {
@@ -497,7 +507,7 @@ async function createKonto(
async function updateKonto(
id: number,
data: { konto_typ_id?: number; kontonummer?: string; bezeichnung?: string; parent_id?: number | null; budget_gwg?: number; budget_anlagen?: number; budget_instandhaltung?: number; notizen?: string }
data: { konto_typ_id?: number; kontonummer?: string; bezeichnung?: string; parent_id?: number | null; budget_gwg?: number; budget_anlagen?: number; budget_instandhaltung?: number; notizen?: string; kategorie_id?: number | null; budget_typ?: string; budget_gesamt?: number }
) {
try {
// Budget validation for sub-pots
@@ -527,7 +537,27 @@ async function updateKonto(
if (data.budget_anlagen !== undefined) { fields.push(`budget_anlagen = $${idx++}`); values.push(data.budget_anlagen); }
if (data.budget_instandhaltung !== undefined) { fields.push(`budget_instandhaltung = $${idx++}`); values.push(data.budget_instandhaltung); }
if (data.notizen !== undefined) { fields.push(`notizen = $${idx++}`); values.push(data.notizen || null); }
if ('kategorie_id' in data) { fields.push(`kategorie_id = $${idx++}`); values.push(data.kategorie_id ?? null); }
if (data.budget_typ !== undefined) { fields.push(`budget_typ = $${idx++}`); values.push(data.budget_typ); }
if (data.budget_gesamt !== undefined) { fields.push(`budget_gesamt = $${idx++}`); values.push(data.budget_gesamt); }
if (fields.length === 0) throw new Error('Keine Felder zum Aktualisieren');
// Child konten must inherit parent's budget_typ
if (data.budget_typ !== undefined) {
const currentRow = await pool.query(`SELECT parent_id FROM buchhaltung_konten WHERE id = $1`, [id]);
const parentId = data.parent_id !== undefined ? data.parent_id : currentRow.rows[0]?.parent_id;
if (parentId) {
const parentRow = await pool.query(`SELECT budget_typ FROM buchhaltung_konten WHERE id = $1`, [parentId]);
if (parentRow.rows[0]) {
// Override budget_typ with parent's value
const btIdx = fields.findIndex(f => f.startsWith('budget_typ'));
if (btIdx !== -1) {
values[btIdx] = parentRow.rows[0].budget_typ;
}
}
}
}
values.push(id);
const result = await pool.query(
`UPDATE buchhaltung_konten SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`,
@@ -1231,6 +1261,94 @@ async function exportTransaktionenCsv(haushaltsjahrId: number): Promise<string>
}
}
// ---------------------------------------------------------------------------
// Erstattungen (Reimbursements)
// ---------------------------------------------------------------------------
interface Transaktion {
id: number;
[key: string]: unknown;
}
async function createErstattung(data: {
konto_id: number;
bankkonto_id: number;
betrag: number;
datum: string;
beschreibung?: string;
empfaenger_auftraggeber?: string;
verwendungszweck?: string;
ausgabe_ids: number[];
erstellt_von?: number;
}): Promise<Transaktion> {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Look up haushaltsjahr_id from the konto
const kontoResult = await client.query(
`SELECT haushaltsjahr_id FROM buchhaltung_konten WHERE id = $1`,
[data.konto_id]
);
const haushaltsjahrId = kontoResult.rows[0]?.haushaltsjahr_id;
const txResult = await client.query(
`INSERT INTO buchhaltung_transaktionen (typ, konto_id, bankkonto_id, betrag, datum, beschreibung, empfaenger_auftraggeber, verwendungszweck, erstellt_von, haushaltsjahr_id, status)
VALUES ('einnahme', $1, $2, $3, $4, $5, $6, $7, $8, $9, 'entwurf')
RETURNING *`,
[data.konto_id, data.bankkonto_id, data.betrag, data.datum, data.beschreibung || null, data.empfaenger_auftraggeber || null, data.verwendungszweck || null, data.erstellt_von || null, haushaltsjahrId]
);
const tx = txResult.rows[0];
for (const ausgabeId of data.ausgabe_ids) {
await client.query(
`INSERT INTO buchhaltung_erstattung_zuordnungen (erstattung_transaktion_id, ausgabe_transaktion_id) VALUES ($1, $2)`,
[tx.id, ausgabeId]
);
}
await client.query('COMMIT');
return tx;
} catch (error) {
await client.query('ROLLBACK');
logger.error('BuchhaltungService.createErstattung failed', { error });
throw new Error('Erstattung konnte nicht erstellt werden');
} finally {
client.release();
}
}
async function getErstattungLinks(transaktionId: number): Promise<{
erstattung?: any;
ausgaben?: any[];
}> {
try {
// If this transaction is an Ausgabe, find its reimbursement
const erstattungResult = await pool.query(
`SELECT t.* FROM buchhaltung_transaktionen t
JOIN buchhaltung_erstattung_zuordnungen ez ON t.id = ez.erstattung_transaktion_id
WHERE ez.ausgabe_transaktion_id = $1`,
[transaktionId]
);
// If this transaction is an Erstattung, find its linked Ausgaben
const ausgabenResult = await pool.query(
`SELECT t.* FROM buchhaltung_transaktionen t
JOIN buchhaltung_erstattung_zuordnungen ez ON t.id = ez.ausgabe_transaktion_id
WHERE ez.erstattung_transaktion_id = $1`,
[transaktionId]
);
return {
erstattung: erstattungResult.rows[0] || undefined,
ausgaben: ausgabenResult.rows,
};
} catch (error) {
logger.error('BuchhaltungService.getErstattungLinks failed', { error, transaktionId });
throw new Error('Erstattungsverknüpfungen konnten nicht geladen werden');
}
}
// ---------------------------------------------------------------------------
// Export
// ---------------------------------------------------------------------------
@@ -1286,6 +1404,8 @@ const buchhaltungService = {
updateWiederkehrend,
deleteWiederkehrend,
exportTransaktionenCsv,
createErstattung,
getErstattungLinks,
};
export default buchhaltungService;