// ============================================================================= // Buchhaltung (Accounting) Service // ============================================================================= 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) // --------------------------------------------------------------------------- async function getKategorien(haushaltsjahr_id: number) { try { const result = await pool.query( `SELECT * FROM buchhaltung_kategorien WHERE haushaltsjahr_id = $1 ORDER BY sortierung, bezeichnung`, [haushaltsjahr_id] ); return result.rows; } catch (error) { logger.error('BuchhaltungService.getKategorien failed', { error }); throw new Error('Kategorien konnten nicht geladen werden'); } } async function createKategorie(data: { haushaltsjahr_id: number; bezeichnung: string; sortierung?: number }) { try { const result = await pool.query( `INSERT INTO buchhaltung_kategorien (haushaltsjahr_id, bezeichnung, sortierung) VALUES ($1, $2, $3) RETURNING *`, [data.haushaltsjahr_id, data.bezeichnung, data.sortierung ?? 0] ); return result.rows[0]; } catch (error) { logger.error('BuchhaltungService.createKategorie failed', { error }); throw new Error('Kategorie konnte nicht erstellt werden'); } } async function updateKategorie(id: number, data: { bezeichnung?: string; sortierung?: 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.sortierung !== undefined) { fields.push(`sortierung = $${idx++}`); values.push(data.sortierung); } if (fields.length === 0) throw new Error('Keine Felder zum Aktualisieren'); values.push(id); const result = await pool.query( `UPDATE buchhaltung_kategorien SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`, values ); return result.rows[0] || null; } catch (error) { logger.error('BuchhaltungService.updateKategorie failed', { error, id }); throw new Error('Kategorie konnte nicht aktualisiert werden'); } } async function deleteKategorie(id: number) { try { await pool.query(`UPDATE buchhaltung_konten SET kategorie_id = NULL WHERE kategorie_id = $1`, [id]); await pool.query(`DELETE FROM buchhaltung_kategorien WHERE id = $1`, [id]); } catch (error) { logger.error('BuchhaltungService.deleteKategorie failed', { error, id }); throw new Error('Kategorie konnte nicht gelöscht werden'); } } // --------------------------------------------------------------------------- // Haushaltsjahre (Fiscal Years) // --------------------------------------------------------------------------- async function getAllHaushaltsjahre() { try { const result = await pool.query( `SELECT * FROM buchhaltung_haushaltsjahre ORDER BY jahr DESC` ); return result.rows; } catch (error) { logger.error('BuchhaltungService.getAllHaushaltsjahre failed', { error }); throw new Error('Haushaltsjahre konnten nicht geladen werden'); } } async function getHaushaltsjahrById(id: number) { try { const result = await pool.query( `SELECT * FROM buchhaltung_haushaltsjahre WHERE id = $1`, [id] ); return result.rows[0] || null; } catch (error) { logger.error('BuchhaltungService.getHaushaltsjahrById failed', { error, id }); throw new Error('Haushaltsjahr konnte nicht geladen werden'); } } async function getCurrentHaushaltsjahr() { try { const result = await pool.query( `SELECT * FROM buchhaltung_haushaltsjahre WHERE abgeschlossen = FALSE ORDER BY jahr DESC LIMIT 1` ); return result.rows[0] || null; } catch (error) { logger.error('BuchhaltungService.getCurrentHaushaltsjahr failed', { error }); throw new Error('Aktuelles Haushaltsjahr konnte nicht geladen werden'); } } async function createHaushaltsjahr( data: { jahr: number; bezeichnung: string; beginn: string; ende: string }, userId: string ) { try { const result = await pool.query( `INSERT INTO buchhaltung_haushaltsjahre (jahr, bezeichnung, beginn, ende, erstellt_von) VALUES ($1, $2, $3, $4, $5) RETURNING *`, [data.jahr, data.bezeichnung, data.beginn, data.ende, userId] ); return result.rows[0]; } catch (error) { logger.error('BuchhaltungService.createHaushaltsjahr failed', { error }); throw new Error('Haushaltsjahr konnte nicht erstellt werden'); } } async function updateHaushaltsjahr( id: number, data: { bezeichnung?: string; beginn?: string; ende?: string } ) { try { const fields: string[] = []; const values: unknown[] = []; let idx = 1; if (data.bezeichnung !== undefined) { fields.push(`bezeichnung = $${idx++}`); values.push(data.bezeichnung); } if (data.beginn !== undefined) { fields.push(`beginn = $${idx++}`); values.push(data.beginn); } if (data.ende !== undefined) { fields.push(`ende = $${idx++}`); values.push(data.ende); } if (fields.length === 0) throw new Error('Keine Felder zum Aktualisieren'); values.push(id); const result = await pool.query( `UPDATE buchhaltung_haushaltsjahre SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`, values ); return result.rows[0] || null; } catch (error) { logger.error('BuchhaltungService.updateHaushaltsjahr failed', { error, id }); throw new Error('Haushaltsjahr konnte nicht aktualisiert werden'); } } async function closeHaushaltsjahr(id: number) { const client = await pool.connect(); try { await client.query('BEGIN'); // Check no open entwurf transactions const check = await client.query( `SELECT COUNT(*) FROM buchhaltung_transaktionen WHERE haushaltsjahr_id = $1 AND status = 'entwurf'`, [id] ); if (parseInt(check.rows[0].count, 10) > 0) { throw new Error('Es gibt noch offene Entwürfe in diesem Haushaltsjahr'); } const result = await client.query( `UPDATE buchhaltung_haushaltsjahre SET abgeschlossen = TRUE WHERE id = $1 RETURNING *`, [id] ); await client.query('COMMIT'); return result.rows[0] || null; } catch (error) { await client.query('ROLLBACK'); logger.error('BuchhaltungService.closeHaushaltsjahr failed', { error, id }); throw error; } finally { client.release(); } } // --------------------------------------------------------------------------- // Konto-Typen (Account Types — static lookup) // --------------------------------------------------------------------------- async function getAllKontoTypen() { try { const result = await pool.query( `SELECT * FROM buchhaltung_konto_typen ORDER BY sort_order` ); return result.rows; } catch (error) { logger.error('BuchhaltungService.getAllKontoTypen failed', { error }); throw new Error('Kontotypen konnten nicht geladen werden'); } } 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) // --------------------------------------------------------------------------- async function getAllBankkonten() { try { const result = await pool.query( `SELECT * FROM buchhaltung_bankkonten WHERE aktiv = TRUE ORDER BY ist_standard DESC, bezeichnung` ); return result.rows; } catch (error) { logger.error('BuchhaltungService.getAllBankkonten failed', { error }); throw new Error('Bankkonten konnten nicht geladen werden'); } } async function getBankkontoById(id: number) { try { const result = await pool.query(`SELECT * FROM buchhaltung_bankkonten WHERE id = $1`, [id]); return result.rows[0] || null; } catch (error) { logger.error('BuchhaltungService.getBankkontoById failed', { error, id }); throw new Error('Bankkonto konnte nicht geladen werden'); } } async function createBankkonto( data: { bezeichnung: string; iban?: string; bic?: string; institut?: string; ist_standard?: boolean }, userId: string ) { const client = await pool.connect(); try { await client.query('BEGIN'); if (data.ist_standard) { await client.query(`UPDATE buchhaltung_bankkonten SET ist_standard = FALSE`); } const result = await client.query( `INSERT INTO buchhaltung_bankkonten (bezeichnung, iban, bic, institut, ist_standard, erstellt_von) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, [data.bezeichnung, data.iban || null, data.bic || null, data.institut || null, data.ist_standard || false, userId] ); await client.query('COMMIT'); return result.rows[0]; } catch (error) { await client.query('ROLLBACK'); logger.error('BuchhaltungService.createBankkonto failed', { error }); throw new Error('Bankkonto konnte nicht erstellt werden'); } finally { client.release(); } } async function updateBankkonto( id: number, data: { bezeichnung?: string; iban?: string; bic?: string; institut?: string; ist_standard?: boolean } ) { const client = await pool.connect(); try { await client.query('BEGIN'); if (data.ist_standard) { await client.query(`UPDATE buchhaltung_bankkonten SET ist_standard = FALSE WHERE id != $1`, [id]); } const fields: string[] = []; const values: unknown[] = []; let idx = 1; if (data.bezeichnung !== undefined) { fields.push(`bezeichnung = $${idx++}`); values.push(data.bezeichnung); } if (data.iban !== undefined) { fields.push(`iban = $${idx++}`); values.push(data.iban || null); } if (data.bic !== undefined) { fields.push(`bic = $${idx++}`); values.push(data.bic || null); } if (data.institut !== undefined) { fields.push(`institut = $${idx++}`); values.push(data.institut || null); } if (data.ist_standard !== undefined) { fields.push(`ist_standard = $${idx++}`); values.push(data.ist_standard); } if (fields.length > 0) { values.push(id); await client.query( `UPDATE buchhaltung_bankkonten SET ${fields.join(', ')} WHERE id = $${idx}`, values ); } const result = await client.query(`SELECT * FROM buchhaltung_bankkonten WHERE id = $1`, [id]); await client.query('COMMIT'); return result.rows[0] || null; } catch (error) { await client.query('ROLLBACK'); logger.error('BuchhaltungService.updateBankkonto failed', { error, id }); throw new Error('Bankkonto konnte nicht aktualisiert werden'); } finally { client.release(); } } async function deactivateBankkonto(id: number) { try { const result = await pool.query( `UPDATE buchhaltung_bankkonten SET aktiv = FALSE WHERE id = $1 RETURNING *`, [id] ); return result.rows[0] || null; } catch (error) { logger.error('BuchhaltungService.deactivateBankkonto failed', { error, id }); throw new Error('Bankkonto konnte nicht deaktiviert werden'); } } async function getBankkontoStatement( bankkontoId: number, filters?: { von?: string; bis?: string } ) { try { // 1. Get the bank account const kontoResult = await pool.query( `SELECT * FROM buchhaltung_bankkonten WHERE id = $1`, [bankkontoId] ); if (!kontoResult.rows[0]) return null; // 2. Fetch all transactions involving this bank account (both directions for transfers) const conditions: string[] = [ `(t.bankkonto_id = $1 OR t.transfer_ziel_bankkonto_id = $1)` ]; const values: unknown[] = [bankkontoId]; let idx = 2; if (filters?.von) { conditions.push(`t.datum >= $${idx++}`); values.push(filters.von); } if (filters?.bis) { conditions.push(`t.datum <= $${idx++}`); values.push(filters.bis); } // Only include booked/approved transactions for the statement conditions.push(`t.status IN ('gebucht', 'freigegeben')`); const where = `WHERE ${conditions.join(' AND ')}`; const txResult = await pool.query( `SELECT t.*, hy.jahr as haushaltsjahr_jahr, k.bezeichnung as konto_bezeichnung, k.kontonummer as konto_kontonummer, bk.bezeichnung as bankkonto_bezeichnung, bk2.bezeichnung AS transfer_ziel_bezeichnung FROM buchhaltung_transaktionen t JOIN buchhaltung_haushaltsjahre hy ON t.haushaltsjahr_id = hy.id LEFT JOIN buchhaltung_konten k ON t.konto_id = k.id LEFT JOIN buchhaltung_bankkonten bk ON t.bankkonto_id = bk.id LEFT JOIN buchhaltung_bankkonten bk2 ON t.transfer_ziel_bankkonto_id = bk2.id ${where} ORDER BY t.datum ASC, t.id ASC`, values ); // 3. Compute running balance let laufenderSaldo = 0; let gesamteinnahmen = 0; let gesamtausgaben = 0; const rows = txResult.rows.map(row => { const betrag = parseFloat(row.betrag); let aenderung = 0; if (row.typ === 'transfer') { if (row.bankkonto_id === bankkontoId) { // Money leaving this account aenderung = -betrag; gesamtausgaben += betrag; } else { // Money arriving at this account (transfer_ziel_bankkonto_id matches) aenderung = betrag; gesamteinnahmen += betrag; } } else if (row.typ === 'einnahme') { aenderung = betrag; gesamteinnahmen += betrag; } else if (row.typ === 'ausgabe') { aenderung = -betrag; gesamtausgaben += betrag; } laufenderSaldo += aenderung; return { ...row, aenderung, laufender_saldo: Math.round(laufenderSaldo * 100) / 100, }; }); return { konto: kontoResult.rows[0], rows, summary: { gesamteinnahmen: Math.round(gesamteinnahmen * 100) / 100, gesamtausgaben: Math.round(gesamtausgaben * 100) / 100, saldo: Math.round(laufenderSaldo * 100) / 100, }, }; } catch (error) { logger.error('BuchhaltungService.getBankkontoStatement failed', { error, bankkontoId }); throw new Error('Kontoauszug konnte nicht geladen werden'); } } // --------------------------------------------------------------------------- // Konten (Budget Accounts) // --------------------------------------------------------------------------- async function getAllKonten(haushaltsjahrId: number) { try { const result = await pool.query( `SELECT k.*, kt.bezeichnung as konto_typ_bezeichnung, kt.art as konto_typ_art, k.parent_id, pk.bezeichnung AS parent_bezeichnung FROM buchhaltung_konten k LEFT JOIN buchhaltung_konto_typen kt ON k.konto_typ_id = kt.id LEFT JOIN buchhaltung_konten pk ON pk.id = k.parent_id WHERE k.haushaltsjahr_id = $1 AND k.aktiv = TRUE ORDER BY k.kontonummer`, [haushaltsjahrId] ); return result.rows; } catch (error) { logger.error('BuchhaltungService.getAllKonten failed', { error, haushaltsjahrId }); throw new Error('Konten konnten nicht geladen werden'); } } async function getKontoById(id: number) { try { const result = await pool.query( `SELECT k.*, kt.bezeichnung as konto_typ_bezeichnung, kt.art as konto_typ_art, pk.bezeichnung AS parent_bezeichnung FROM buchhaltung_konten k LEFT JOIN buchhaltung_konto_typen kt ON k.konto_typ_id = kt.id LEFT JOIN buchhaltung_konten pk ON pk.id = k.parent_id WHERE k.id = $1`, [id] ); return result.rows[0] || null; } catch (error) { logger.error('BuchhaltungService.getKontoById failed', { error, id }); throw new Error('Konto konnte nicht geladen werden'); } } async function getKontenTree(haushaltsjahrId: number) { try { const result = await pool.query( `SELECT k.*, kt.bezeichnung as konto_typ_bezeichnung, kt.art as konto_typ_art, pk.bezeichnung AS parent_bezeichnung, k.kategorie_id, kat.bezeichnung AS kategorie_bezeichnung, COALESCE(SUM(CASE WHEN t.typ='ausgabe' AND t.ausgaben_typ='gwg' AND t.status IN ('gebucht','freigegeben') THEN t.betrag ELSE 0 END), 0) AS spent_gwg, COALESCE(SUM(CASE WHEN t.typ='ausgabe' AND t.ausgaben_typ='anlagen' AND t.status IN ('gebucht','freigegeben') THEN t.betrag ELSE 0 END), 0) AS spent_anlagen, COALESCE(SUM(CASE WHEN t.typ='ausgabe' AND t.ausgaben_typ='instandhaltung' AND t.status IN ('gebucht','freigegeben') THEN t.betrag ELSE 0 END), 0) AS spent_instandhaltung, COALESCE(SUM(CASE WHEN t.typ='ausgabe' AND t.status IN ('gebucht','freigegeben') THEN t.betrag ELSE 0 END), 0) AS spent_gesamt, COALESCE(SUM(CASE WHEN t.typ='einnahme' AND t.status IN ('gebucht','freigegeben') THEN t.betrag ELSE 0 END), 0) AS einnahmen_betrag FROM buchhaltung_konten k LEFT JOIN buchhaltung_konto_typen kt ON k.konto_typ_id = kt.id LEFT JOIN buchhaltung_konten pk ON pk.id = k.parent_id LEFT JOIN buchhaltung_kategorien kat ON kat.id = k.kategorie_id LEFT JOIN buchhaltung_transaktionen t ON t.konto_id = k.id AND t.haushaltsjahr_id = $1 WHERE k.haushaltsjahr_id = $1 AND k.aktiv = TRUE GROUP BY k.id, kt.bezeichnung, kt.art, pk.bezeichnung, kat.bezeichnung ORDER BY k.kontonummer`, [haushaltsjahrId] ); return result.rows; } catch (error) { logger.error('BuchhaltungService.getKontenTree failed', { error, haushaltsjahrId }); throw new Error('Kontenbaum konnte nicht geladen werden'); } } async function getKontoDetail(kontoId: number) { try { const kontoResult = await pool.query( `SELECT k.*, kt.bezeichnung as konto_typ_bezeichnung, kt.art as konto_typ_art, pk.bezeichnung AS parent_bezeichnung FROM buchhaltung_konten k LEFT JOIN buchhaltung_konto_typen kt ON k.konto_typ_id = kt.id LEFT JOIN buchhaltung_konten pk ON pk.id = k.parent_id WHERE k.id = $1`, [kontoId] ); if (!kontoResult.rows[0]) return null; const childrenResult = await pool.query( `SELECT * FROM buchhaltung_konten WHERE parent_id = $1 AND aktiv = TRUE ORDER BY kontonummer`, [kontoId] ); const transaktionenResult = await pool.query( `SELECT t.*, hy.jahr as haushaltsjahr_jahr, k.bezeichnung as konto_bezeichnung, k.kontonummer as konto_kontonummer, bk.bezeichnung as bankkonto_bezeichnung, bk2.bezeichnung AS transfer_ziel_bezeichnung FROM buchhaltung_transaktionen t JOIN buchhaltung_haushaltsjahre hy ON t.haushaltsjahr_id = hy.id LEFT JOIN buchhaltung_konten k ON t.konto_id = k.id LEFT JOIN buchhaltung_bankkonten bk ON t.bankkonto_id = bk.id LEFT JOIN buchhaltung_bankkonten bk2 ON t.transfer_ziel_bankkonto_id = bk2.id WHERE t.konto_id = $1 ORDER BY t.datum DESC, t.id DESC`, [kontoId] ); return { konto: kontoResult.rows[0], children: childrenResult.rows, transaktionen: transaktionenResult.rows, }; } catch (error) { logger.error('BuchhaltungService.getKontoDetail failed', { error, kontoId }); throw new Error('Kontodetails konnten nicht geladen werden'); } } async function getPendingCount(haushaltsjahrId?: number): Promise { try { let query = `SELECT COUNT(*) FROM buchhaltung_transaktionen WHERE status = 'entwurf'`; const params: unknown[] = []; if (haushaltsjahrId) { query += ` AND haushaltsjahr_id = $1`; params.push(haushaltsjahrId); } const result = await pool.query(query, params); return parseInt(result.rows[0].count, 10); } catch (error) { logger.error('BuchhaltungService.getPendingCount failed', { error }); throw new Error('Anzahl offener Entwürfe konnte nicht geladen werden'); } } async function validateSubPotBudget( parentId: number, budgetGwg: number, budgetAnlagen: number, budgetInstandhaltung: number, excludeKontoId?: number ) { const parentResult = await pool.query( `SELECT budget_gwg, budget_anlagen, budget_instandhaltung FROM buchhaltung_konten WHERE id = $1`, [parentId] ); if (!parentResult.rows[0]) return; // parent not found, skip validation const parent = parentResult.rows[0]; const excludeClause = excludeKontoId ? ` AND id != $2` : ''; const siblingParams: unknown[] = [parentId]; if (excludeKontoId) siblingParams.push(excludeKontoId); const siblingResult = await pool.query( `SELECT COALESCE(SUM(budget_gwg), 0) AS sum_gwg, COALESCE(SUM(budget_anlagen), 0) AS sum_anlagen, COALESCE(SUM(budget_instandhaltung), 0) AS sum_instandhaltung FROM buchhaltung_konten WHERE parent_id = $1${excludeClause}`, siblingParams ); const sibling = siblingResult.rows[0]; const checks: { label: string; sum: number; parentBudget: number }[] = [ { label: 'GWG', sum: parseFloat(sibling.sum_gwg) + budgetGwg, parentBudget: parseFloat(parent.budget_gwg) }, { label: 'Anlagen', sum: parseFloat(sibling.sum_anlagen) + budgetAnlagen, parentBudget: parseFloat(parent.budget_anlagen) }, { label: 'Instandhaltung', sum: parseFloat(sibling.sum_instandhaltung) + budgetInstandhaltung, parentBudget: parseFloat(parent.budget_instandhaltung) }, ]; for (const c of checks) { if (c.parentBudget > 0 && c.sum > c.parentBudget) { throw Object.assign( new Error(`Budget ${c.label}: Summe der Untertöpfe (${c.sum.toFixed(2)} €) übersteigt das übergeordnete Budget (${c.parentBudget.toFixed(2)} €)`), { statusCode: 400 } ); } } } 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; 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, 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.kategorie_id ?? null, budgetTyp, data.budget_gesamt || 0] ); return result.rows[0]; } catch (error: any) { logger.error('BuchhaltungService.createKonto failed', { error }); if (error.statusCode) throw error; if (error.code === '23505' && error.constraint === 'buchhaltung_konten_haushaltsjahr_id_kontonummer_key') { throw Object.assign(new Error(`Kontonummer "${data.kontonummer}" existiert bereits in diesem Haushaltsjahr`), { statusCode: 409 }); } throw new Error('Konto konnte nicht erstellt werden'); } } async function cascadeBudgetTypToChildren(parentId: number, newType: string): Promise { const children = await pool.query( 'SELECT id, budget_typ, budget_gwg, budget_anlagen, budget_instandhaltung, budget_gesamt FROM buchhaltung_konten WHERE parent_id = $1', [parentId] ); for (const child of children.rows) { const oldType = child.budget_typ || 'detailliert'; if (oldType === newType) continue; let gwg = 0, anlagen = 0, instandhaltung = 0, gesamt = 0; if (oldType === 'detailliert' && newType === 'einfach') { // Sum detail fields into gesamt gesamt = (parseFloat(child.budget_gwg) || 0) + (parseFloat(child.budget_anlagen) || 0) + (parseFloat(child.budget_instandhaltung) || 0); } // einfach → detailliert: zero everything (user redistributes manually) await pool.query( `UPDATE buchhaltung_konten SET budget_typ = $1, budget_gwg = $2, budget_anlagen = $3, budget_instandhaltung = $4, budget_gesamt = $5 WHERE id = $6`, [newType, gwg, anlagen, instandhaltung, gesamt, child.id] ); // Recurse into grandchildren await cascadeBudgetTypToChildren(child.id, newType); } } 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; kategorie_id?: number | null; budget_typ?: string; budget_gesamt?: number } ) { try { // Budget validation for sub-pots const hasBudgetChange = data.budget_gwg !== undefined || data.budget_anlagen !== undefined || data.budget_instandhaltung !== undefined; const hasParentChange = data.parent_id !== undefined; if (hasBudgetChange || hasParentChange) { const current = await pool.query(`SELECT parent_id, budget_gwg, budget_anlagen, budget_instandhaltung FROM buchhaltung_konten WHERE id = $1`, [id]); if (current.rows[0]) { const parentId = data.parent_id !== undefined ? data.parent_id : current.rows[0].parent_id; if (parentId) { const bGwg = data.budget_gwg !== undefined ? data.budget_gwg : parseFloat(current.rows[0].budget_gwg); const bAnl = data.budget_anlagen !== undefined ? data.budget_anlagen : parseFloat(current.rows[0].budget_anlagen); const bIns = data.budget_instandhaltung !== undefined ? data.budget_instandhaltung : parseFloat(current.rows[0].budget_instandhaltung); await validateSubPotBudget(parentId, bGwg, bAnl, bIns, id); } } } const fields: string[] = []; const values: unknown[] = []; let idx = 1; if (data.konto_typ_id !== undefined) { fields.push(`konto_typ_id = $${idx++}`); values.push(data.konto_typ_id || null); } if (data.kontonummer !== undefined) { fields.push(`kontonummer = $${idx++}`); values.push(data.kontonummer); } if (data.bezeichnung !== undefined) { fields.push(`bezeichnung = $${idx++}`); values.push(data.bezeichnung); } if (data.parent_id !== undefined) { fields.push(`parent_id = $${idx++}`); values.push(data.parent_id || null); } if (data.budget_gwg !== undefined) { fields.push(`budget_gwg = $${idx++}`); values.push(data.budget_gwg); } 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 *`, values ); // Cascade budget_typ change to all children recursively if (data.budget_typ !== undefined && result.rows[0]) { const newBudgetTyp = result.rows[0].budget_typ; // Fetch what the old type was before the update (the RETURNING row has the new value) // We know a change happened if the user sent budget_typ, so cascade unconditionally await cascadeBudgetTypToChildren(id, newBudgetTyp); } return result.rows[0] || null; } catch (error: any) { logger.error('BuchhaltungService.updateKonto failed', { error, id }); if (error.statusCode) throw error; throw new Error('Konto konnte nicht aktualisiert werden'); } } async function deleteKonto(id: number) { try { await pool.query(`DELETE FROM buchhaltung_konten WHERE id = $1`, [id]); } catch (error) { logger.error('BuchhaltungService.deleteKonto failed', { error, id }); throw new Error('Konto konnte nicht gelöscht werden'); } } async function getBudgetUtilisation(id: number) { try { const result = await pool.query( `SELECT k.*, kt.bezeichnung as konto_typ_bezeichnung, kt.art as konto_typ_art, COALESCE(SUM(CASE WHEN t.typ = 'ausgabe' AND t.status IN ('gebucht','freigegeben') THEN t.betrag ELSE 0 END), 0) as gebucht_betrag, COALESCE(SUM(CASE WHEN t.typ = 'ausgabe' AND t.status = 'entwurf' THEN t.betrag ELSE 0 END), 0) as ausstehend_betrag FROM buchhaltung_konten k LEFT JOIN buchhaltung_konto_typen kt ON k.konto_typ_id = kt.id LEFT JOIN buchhaltung_transaktionen t ON t.konto_id = k.id WHERE k.id = $1 GROUP BY k.id, kt.bezeichnung, kt.art`, [id] ); if (!result.rows[0]) return null; const row = result.rows[0]; const gebucht = parseFloat(row.gebucht_betrag); const ausstehend = parseFloat(row.ausstehend_betrag); const budget = parseFloat(row.budget_gwg) + parseFloat(row.budget_anlagen) + parseFloat(row.budget_instandhaltung); return { ...row, gebucht_betrag: gebucht, ausstehend_betrag: ausstehend, verfuegbar_betrag: budget - gebucht - ausstehend, auslastung_prozent: budget > 0 ? Math.round((gebucht / budget) * 100) : 0, }; } catch (error) { logger.error('BuchhaltungService.getBudgetUtilisation failed', { error, id }); throw new Error('Budgetauslastung konnte nicht geladen werden'); } } // --------------------------------------------------------------------------- // Transaktionen (Transactions) // --------------------------------------------------------------------------- async function listTransaktionen(filters: { haushaltsjahr_id?: number; konto_id?: number; status?: string; typ?: string; datum_von?: string; datum_bis?: string; search?: string; }) { try { const conditions: string[] = []; const values: unknown[] = []; let idx = 1; if (filters.haushaltsjahr_id) { conditions.push(`t.haushaltsjahr_id = $${idx++}`); values.push(filters.haushaltsjahr_id); } if (filters.konto_id) { conditions.push(`t.konto_id = $${idx++}`); values.push(filters.konto_id); } if (filters.status) { conditions.push(`t.status = $${idx++}`); values.push(filters.status); } if (filters.typ) { conditions.push(`t.typ = $${idx++}`); values.push(filters.typ); } if (filters.datum_von) { conditions.push(`t.datum >= $${idx++}`); values.push(filters.datum_von); } if (filters.datum_bis) { conditions.push(`t.datum <= $${idx++}`); values.push(filters.datum_bis); } if (filters.search) { conditions.push(`(t.beschreibung ILIKE $${idx} OR t.empfaenger_auftraggeber ILIKE $${idx} OR t.beleg_nr ILIKE $${idx})`); values.push(`%${filters.search}%`); idx++; } const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; const result = await pool.query( `SELECT t.*, hy.jahr as haushaltsjahr_jahr, k.bezeichnung as konto_bezeichnung, k.kontonummer as konto_kontonummer, bk.bezeichnung as bankkonto_bezeichnung, bk2.bezeichnung AS transfer_ziel_bezeichnung FROM buchhaltung_transaktionen t JOIN buchhaltung_haushaltsjahre hy ON t.haushaltsjahr_id = hy.id LEFT JOIN buchhaltung_konten k ON t.konto_id = k.id LEFT JOIN buchhaltung_bankkonten bk ON t.bankkonto_id = bk.id LEFT JOIN buchhaltung_bankkonten bk2 ON t.transfer_ziel_bankkonto_id = bk2.id ${where} ORDER BY t.datum DESC, t.id DESC`, values ); return result.rows; } catch (error) { logger.error('BuchhaltungService.listTransaktionen failed', { error }); throw new Error('Transaktionen konnten nicht geladen werden'); } } async function getTransaktionById(id: number) { try { const txResult = await pool.query( `SELECT t.*, hy.jahr as haushaltsjahr_jahr, k.bezeichnung as konto_bezeichnung, k.kontonummer as konto_kontonummer, bk.bezeichnung as bankkonto_bezeichnung, bk2.bezeichnung AS transfer_ziel_bezeichnung FROM buchhaltung_transaktionen t JOIN buchhaltung_haushaltsjahre hy ON t.haushaltsjahr_id = hy.id LEFT JOIN buchhaltung_konten k ON t.konto_id = k.id LEFT JOIN buchhaltung_bankkonten bk ON t.bankkonto_id = bk.id LEFT JOIN buchhaltung_bankkonten bk2 ON t.transfer_ziel_bankkonto_id = bk2.id WHERE t.id = $1`, [id] ); if (!txResult.rows[0]) return null; const tx = txResult.rows[0]; const belegeResult = await pool.query( `SELECT * FROM buchhaltung_belege WHERE transaktion_id = $1 ORDER BY erstellt_am`, [id] ); tx.belege = belegeResult.rows; return tx; } catch (error) { logger.error('BuchhaltungService.getTransaktionById failed', { error, id }); throw new Error('Transaktion konnte nicht geladen werden'); } } async function createTransaktion( data: { haushaltsjahr_id: number; konto_id?: number | null; bankkonto_id?: number | null; typ: 'einnahme' | 'ausgabe'; betrag: number; datum: string; beschreibung?: string; empfaenger_auftraggeber?: string; verwendungszweck?: string; beleg_nr?: string; ausgaben_typ?: string | null; }, userId: string ) { try { const result = await pool.query( `INSERT INTO buchhaltung_transaktionen (haushaltsjahr_id, konto_id, bankkonto_id, typ, betrag, datum, beschreibung, empfaenger_auftraggeber, verwendungszweck, beleg_nr, ausgaben_typ, erstellt_von) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *`, [ data.haushaltsjahr_id, data.konto_id || null, data.bankkonto_id || null, data.typ, data.betrag, data.datum, data.beschreibung || null, data.empfaenger_auftraggeber || null, data.verwendungszweck || null, data.beleg_nr || null, data.typ === 'ausgabe' ? (data.ausgaben_typ || null) : null, userId, ] ); await logAudit(result.rows[0].id, 'erstellt', { betrag: data.betrag }, userId); return result.rows[0]; } catch (error) { logger.error('BuchhaltungService.createTransaktion failed', { error }); throw new Error('Transaktion konnte nicht erstellt werden'); } } async function updateTransaktion( id: number, data: { konto_id?: number | null; bankkonto_id?: number | null; betrag?: number; datum?: string; beschreibung?: string; empfaenger_auftraggeber?: string; verwendungszweck?: string; beleg_nr?: string; }, userId: string ) { try { const fields: string[] = []; const values: unknown[] = []; let idx = 1; if (data.konto_id !== undefined) { fields.push(`konto_id = $${idx++}`); values.push(data.konto_id); } if (data.bankkonto_id !== undefined) { fields.push(`bankkonto_id = $${idx++}`); values.push(data.bankkonto_id); } if (data.betrag !== undefined) { fields.push(`betrag = $${idx++}`); values.push(data.betrag); } if (data.datum !== undefined) { fields.push(`datum = $${idx++}`); values.push(data.datum); } if (data.beschreibung !== undefined) { fields.push(`beschreibung = $${idx++}`); values.push(data.beschreibung || null); } if (data.empfaenger_auftraggeber !== undefined){ fields.push(`empfaenger_auftraggeber = $${idx++}`); values.push(data.empfaenger_auftraggeber || null); } if (data.verwendungszweck !== undefined) { fields.push(`verwendungszweck = $${idx++}`); values.push(data.verwendungszweck || null); } if (data.beleg_nr !== undefined) { fields.push(`beleg_nr = $${idx++}`); values.push(data.beleg_nr || null); } if (fields.length === 0) throw new Error('Keine Felder zum Aktualisieren'); values.push(id); const result = await pool.query( `UPDATE buchhaltung_transaktionen SET ${fields.join(', ')} WHERE id = $${idx} AND status = 'entwurf' RETURNING *`, values ); if (result.rows[0]) await logAudit(id, 'aktualisiert', data, userId); return result.rows[0] || null; } catch (error) { logger.error('BuchhaltungService.updateTransaktion failed', { error, id }); throw new Error('Transaktion konnte nicht aktualisiert werden'); } } async function bookTransaktion(id: number, userId: string) { try { const result = await pool.query( `UPDATE buchhaltung_transaktionen SET status = 'gebucht', buchungsdatum = NOW(), gebucht_von = $2 WHERE id = $1 AND status = 'entwurf' RETURNING *`, [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 }); throw new Error('Transaktion konnte nicht gebucht werden'); } } async function stornoTransaktion(id: number, userId: string) { try { const result = await pool.query( `UPDATE buchhaltung_transaktionen SET status = 'entwurf' WHERE id = $1 AND status IN ('gebucht', 'freigegeben') RETURNING *`, [id, userId] ); if (result.rows[0]) await logAudit(id, 'storniert_zu_entwurf', {}, userId); return result.rows[0] || null; } catch (error) { logger.error('BuchhaltungService.stornoTransaktion failed', { error, id }); throw new Error('Transaktion konnte nicht storniert werden'); } } async function deleteTransaktion(id: number) { try { // Only entwurf can be deleted const result = await pool.query( `DELETE FROM buchhaltung_transaktionen WHERE id = $1 AND status = 'entwurf' RETURNING id`, [id] ); return result.rows.length > 0; } catch (error) { logger.error('BuchhaltungService.deleteTransaktion failed', { error, id }); throw new Error('Transaktion konnte nicht gelöscht werden'); } } // --------------------------------------------------------------------------- // Transfers (Umbuchungen) // --------------------------------------------------------------------------- async function createTransfer( data: { haushaltsjahr_id: number; bankkonto_id: number; transfer_ziel_bankkonto_id: number; betrag: number; datum: string; beschreibung?: string; beleg_nr?: string; }, userId: string ) { const client = await pool.connect(); try { await client.query('BEGIN'); // Debit row (money leaves source account) const debitResult = await client.query( `INSERT INTO buchhaltung_transaktionen (haushaltsjahr_id, bankkonto_id, transfer_ziel_bankkonto_id, typ, betrag, datum, beschreibung, beleg_nr, erstellt_von, status) VALUES ($1, $2, $3, 'transfer', $4, $5, $6, $7, $8, 'gebucht') RETURNING *`, [data.haushaltsjahr_id, data.bankkonto_id, data.transfer_ziel_bankkonto_id, data.betrag, data.datum, data.beschreibung || null, data.beleg_nr || null, userId] ); // Credit row (money arrives at target account) const creditResult = await client.query( `INSERT INTO buchhaltung_transaktionen (haushaltsjahr_id, bankkonto_id, transfer_ziel_bankkonto_id, typ, betrag, datum, beschreibung, beleg_nr, erstellt_von, status) VALUES ($1, $2, $3, 'transfer', $4, $5, $6, $7, $8, 'gebucht') RETURNING *`, [data.haushaltsjahr_id, data.transfer_ziel_bankkonto_id, data.bankkonto_id, data.betrag, data.datum, data.beschreibung || null, data.beleg_nr || null, userId] ); await client.query('COMMIT'); await logAudit(debitResult.rows[0].id, 'transfer_erstellt', { betrag: data.betrag, von: data.bankkonto_id, nach: data.transfer_ziel_bankkonto_id }, userId); await logAudit(creditResult.rows[0].id, 'transfer_erstellt', { betrag: data.betrag, von: data.bankkonto_id, nach: data.transfer_ziel_bankkonto_id }, userId); return { debit: debitResult.rows[0], credit: creditResult.rows[0] }; } catch (error) { await client.query('ROLLBACK'); logger.error('BuchhaltungService.createTransfer failed', { error }); throw new Error('Umbuchung konnte nicht erstellt werden'); } finally { client.release(); } } // --------------------------------------------------------------------------- // Belege (Receipts) // --------------------------------------------------------------------------- async function getBelegeByTransaktion(transaktionId: number) { try { const result = await pool.query( `SELECT * FROM buchhaltung_belege WHERE transaktion_id = $1 ORDER BY erstellt_am`, [transaktionId] ); return result.rows; } catch (error) { logger.error('BuchhaltungService.getBelegeByTransaktion failed', { error }); throw new Error('Belege konnten nicht geladen werden'); } } async function uploadBeleg( transaktionId: number, file: { filename: string; originalname: string; mimetype: string; size: number }, userId: string ) { try { const result = await pool.query( `INSERT INTO buchhaltung_belege (transaktion_id, dateiname, original_name, dateityp, dateigroesse, erstellt_von) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, [transaktionId, file.filename, file.originalname, file.mimetype, file.size, userId] ); return result.rows[0]; } catch (error) { logger.error('BuchhaltungService.uploadBeleg failed', { error }); throw new Error('Beleg konnte nicht hochgeladen werden'); } } async function deleteBeleg(id: number) { try { const result = await pool.query( `DELETE FROM buchhaltung_belege WHERE id = $1 RETURNING *`, [id] ); if (result.rows[0]) { // Delete file from disk const filePath = process.env.NODE_ENV === 'production' ? `/app/uploads/buchhaltung/${result.rows[0].dateiname}` : `${process.cwd()}/uploads/buchhaltung/${result.rows[0].dateiname}`; try { fs.unlinkSync(filePath); } catch { /* ignore */ } } return result.rows.length > 0; } catch (error) { logger.error('BuchhaltungService.deleteBeleg failed', { error, id }); throw new Error('Beleg konnte nicht gelöscht werden'); } } // --------------------------------------------------------------------------- // Freigaben (Approvals) // --------------------------------------------------------------------------- async function getFreigabenByTransaktion(transaktionId: number) { try { const result = await pool.query( `SELECT * FROM buchhaltung_freigaben WHERE transaktion_id = $1 ORDER BY erstellt_am DESC`, [transaktionId] ); return result.rows; } catch (error) { logger.error('BuchhaltungService.getFreigabenByTransaktion failed', { error }); throw new Error('Freigaben konnten nicht geladen werden'); } } async function createFreigabe(transaktionId: number, userId: string) { try { const result = await pool.query( `INSERT INTO buchhaltung_freigaben (transaktion_id) VALUES ($1) RETURNING *`, [transaktionId] ); await logAudit(transaktionId, 'freigabe_angefragt', {}, userId); return result.rows[0]; } catch (error) { logger.error('BuchhaltungService.createFreigabe failed', { error }); throw new Error('Freigabe konnte nicht erstellt werden'); } } async function approveFreigabe(id: number, kommentar: string | undefined, userId: string) { const client = await pool.connect(); try { await client.query('BEGIN'); const result = await client.query( `UPDATE buchhaltung_freigaben SET status = 'genehmigt', kommentar = $2, freigegeben_von = $3, freigegeben_am = NOW() WHERE id = $1 RETURNING *`, [id, kommentar || null, userId] ); if (result.rows[0]) { await client.query( `UPDATE buchhaltung_transaktionen SET status = 'freigegeben' WHERE id = $1`, [result.rows[0].transaktion_id] ); await logAudit(result.rows[0].transaktion_id, 'freigegeben', { kommentar }, userId); } await client.query('COMMIT'); return result.rows[0] || null; } catch (error) { await client.query('ROLLBACK'); logger.error('BuchhaltungService.approveFreigabe failed', { error, id }); throw new Error('Freigabe konnte nicht genehmigt werden'); } finally { client.release(); } } async function rejectFreigabe(id: number, kommentar: string | undefined, userId: string) { try { const result = await pool.query( `UPDATE buchhaltung_freigaben SET status = 'abgelehnt', kommentar = $2, freigegeben_von = $3, freigegeben_am = NOW() WHERE id = $1 RETURNING *`, [id, kommentar || null, userId] ); if (result.rows[0]) await logAudit(result.rows[0].transaktion_id, 'freigabe_abgelehnt', { kommentar }, userId); return result.rows[0] || null; } catch (error) { logger.error('BuchhaltungService.rejectFreigabe failed', { error, id }); throw new Error('Freigabe konnte nicht abgelehnt werden'); } } // --------------------------------------------------------------------------- // Einstellungen (Settings) // --------------------------------------------------------------------------- async function getEinstellungen() { try { const result = await pool.query(`SELECT * FROM buchhaltung_einstellungen`); const settings: Record = {}; for (const row of result.rows) settings[row.key] = row.value; return settings; } catch (error) { logger.error('BuchhaltungService.getEinstellungen failed', { error }); throw new Error('Einstellungen konnten nicht geladen werden'); } } async function setEinstellungen(data: Record) { try { for (const [key, value] of Object.entries(data)) { await pool.query( `INSERT INTO buchhaltung_einstellungen (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = $2, aktualisiert_am = NOW()`, [key, JSON.stringify(value)] ); } } catch (error) { logger.error('BuchhaltungService.setEinstellungen failed', { error }); throw new Error('Einstellungen konnten nicht gespeichert werden'); } } // --------------------------------------------------------------------------- // Audit // --------------------------------------------------------------------------- async function logAudit(transaktionId: number | null, aktion: string, details: unknown, userId: string) { try { await pool.query( `INSERT INTO buchhaltung_audit (transaktion_id, aktion, details, erstellt_von) VALUES ($1, $2, $3, $4)`, [transaktionId, aktion, JSON.stringify(details), userId] ); } catch (err) { logger.warn('BuchhaltungService.logAudit failed (non-fatal)', { err }); } } async function getAuditByTransaktion(transaktionId: number) { try { const result = await pool.query( `SELECT * FROM buchhaltung_audit WHERE transaktion_id = $1 ORDER BY erstellt_am DESC`, [transaktionId] ); return result.rows; } catch (error) { logger.error('BuchhaltungService.getAuditByTransaktion failed', { error }); throw new Error('Audit-Einträge konnten nicht geladen werden'); } } // --------------------------------------------------------------------------- // Stats / Overview // --------------------------------------------------------------------------- async function getOverview(haushaltsjahrId: number) { try { const totalsResult = await pool.query( `SELECT COALESCE(SUM(CASE WHEN typ = 'einnahme' AND status IN ('gebucht','freigegeben') THEN betrag ELSE 0 END), 0) as total_einnahmen, COALESCE(SUM(CASE WHEN typ = 'ausgabe' AND status IN ('gebucht','freigegeben') THEN betrag ELSE 0 END), 0) as total_ausgaben FROM buchhaltung_transaktionen WHERE haushaltsjahr_id = $1`, [haushaltsjahrId] ); const kontenResult = await pool.query( `SELECT k.*, kt.bezeichnung as konto_typ_bezeichnung, kt.art as konto_typ_art, COALESCE(SUM(CASE WHEN t.typ = 'ausgabe' AND t.status IN ('gebucht','freigegeben') THEN t.betrag ELSE 0 END), 0) as gebucht_betrag, COALESCE(SUM(CASE WHEN t.typ = 'ausgabe' AND t.status = 'entwurf' THEN t.betrag ELSE 0 END), 0) as ausstehend_betrag FROM buchhaltung_konten k LEFT JOIN buchhaltung_konto_typen kt ON k.konto_typ_id = kt.id LEFT JOIN buchhaltung_transaktionen t ON t.konto_id = k.id AND t.haushaltsjahr_id = $1 WHERE k.haushaltsjahr_id = $1 AND k.aktiv = TRUE GROUP BY k.id, kt.bezeichnung, kt.art ORDER BY k.kontonummer`, [haushaltsjahrId] ); const totalEinnahmen = parseFloat(totalsResult.rows[0].total_einnahmen); const totalAusgaben = parseFloat(totalsResult.rows[0].total_ausgaben); return { haushaltsjahr_id: haushaltsjahrId, total_einnahmen: totalEinnahmen, total_ausgaben: totalAusgaben, saldo: totalEinnahmen - totalAusgaben, konten_budget: kontenResult.rows.map(row => ({ ...row, gebucht_betrag: parseFloat(row.gebucht_betrag), ausstehend_betrag: parseFloat(row.ausstehend_betrag), verfuegbar_betrag: (parseFloat(row.budget_gwg) + parseFloat(row.budget_anlagen) + parseFloat(row.budget_instandhaltung)) - parseFloat(row.gebucht_betrag) - parseFloat(row.ausstehend_betrag), auslastung_prozent: (parseFloat(row.budget_gwg) + parseFloat(row.budget_anlagen) + parseFloat(row.budget_instandhaltung)) > 0 ? Math.round((parseFloat(row.gebucht_betrag) / (parseFloat(row.budget_gwg) + parseFloat(row.budget_anlagen) + parseFloat(row.budget_instandhaltung))) * 100) : 0, })), }; } catch (error) { logger.error('BuchhaltungService.getOverview failed', { error, haushaltsjahrId }); throw new Error('Übersicht konnte nicht geladen werden'); } } // --------------------------------------------------------------------------- // Wiederkehrend (Recurring Bookings) // --------------------------------------------------------------------------- async function getAllWiederkehrend() { try { const result = await pool.query( `SELECT w.*, k.bezeichnung as konto_bezeichnung, k.kontonummer as konto_kontonummer, bk.bezeichnung as bankkonto_bezeichnung FROM buchhaltung_wiederkehrend w LEFT JOIN buchhaltung_konten k ON w.konto_id = k.id LEFT JOIN buchhaltung_bankkonten bk ON w.bankkonto_id = bk.id ORDER BY w.naechste_ausfuehrung, w.bezeichnung` ); return result.rows; } catch (error) { logger.error('BuchhaltungService.getAllWiederkehrend failed', { error }); throw new Error('Wiederkehrende Buchungen konnten nicht geladen werden'); } } async function getWiederkehrendById(id: number) { try { const result = await pool.query( `SELECT * FROM buchhaltung_wiederkehrend WHERE id = $1`, [id] ); return result.rows[0] || null; } catch (error) { logger.error('BuchhaltungService.getWiederkehrendById failed', { error, id }); throw new Error('Wiederkehrende Buchung konnte nicht geladen werden'); } } async function createWiederkehrend( data: { bezeichnung: string; konto_id?: number | null; bankkonto_id?: number | null; typ: 'einnahme' | 'ausgabe'; betrag: number; beschreibung?: string; empfaenger_auftraggeber?: string; intervall: string; naechste_ausfuehrung: string; aktiv?: boolean; ausfuehrungstag?: string; ausfuehrungs_monat?: number; }, userId: string ) { try { const result = await pool.query( `INSERT INTO buchhaltung_wiederkehrend (bezeichnung, konto_id, bankkonto_id, typ, betrag, beschreibung, empfaenger_auftraggeber, intervall, naechste_ausfuehrung, aktiv, erstellt_von, ausfuehrungstag, ausfuehrungs_monat) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING *`, [ data.bezeichnung, data.konto_id || null, data.bankkonto_id || null, data.typ, data.betrag, data.beschreibung || null, data.empfaenger_auftraggeber || null, data.intervall, data.naechste_ausfuehrung, data.aktiv !== false, userId, data.ausfuehrungstag || 'erster', data.ausfuehrungs_monat ?? null, ] ); return result.rows[0]; } catch (error) { logger.error('BuchhaltungService.createWiederkehrend failed', { error }); throw new Error('Wiederkehrende Buchung konnte nicht erstellt werden'); } } async function updateWiederkehrend( id: number, data: { bezeichnung?: string; konto_id?: number | null; bankkonto_id?: number | null; typ?: string; betrag?: number; beschreibung?: string; empfaenger_auftraggeber?: string; intervall?: string; naechste_ausfuehrung?: string; aktiv?: boolean; ausfuehrungstag?: string; ausfuehrungs_monat?: number | null; } ) { try { const fields: string[] = []; const values: unknown[] = []; let idx = 1; if (data.bezeichnung !== undefined) { fields.push(`bezeichnung = $${idx++}`); values.push(data.bezeichnung); } if (data.konto_id !== undefined) { fields.push(`konto_id = $${idx++}`); values.push(data.konto_id); } if (data.bankkonto_id !== undefined) { fields.push(`bankkonto_id = $${idx++}`); values.push(data.bankkonto_id); } if (data.typ !== undefined) { fields.push(`typ = $${idx++}`); values.push(data.typ); } if (data.betrag !== undefined) { fields.push(`betrag = $${idx++}`); values.push(data.betrag); } if (data.beschreibung !== undefined) { fields.push(`beschreibung = $${idx++}`); values.push(data.beschreibung || null); } if (data.empfaenger_auftraggeber !== undefined) { fields.push(`empfaenger_auftraggeber = $${idx++}`); values.push(data.empfaenger_auftraggeber || null); } if (data.intervall !== undefined) { fields.push(`intervall = $${idx++}`); values.push(data.intervall); } if (data.naechste_ausfuehrung !== undefined) { fields.push(`naechste_ausfuehrung = $${idx++}`); values.push(data.naechste_ausfuehrung); } if (data.aktiv !== undefined) { fields.push(`aktiv = $${idx++}`); values.push(data.aktiv); } if (data.ausfuehrungstag !== undefined) { fields.push(`ausfuehrungstag = $${idx++}`); values.push(data.ausfuehrungstag); } if (data.ausfuehrungs_monat !== undefined) { fields.push(`ausfuehrungs_monat = $${idx++}`); values.push(data.ausfuehrungs_monat); } if (fields.length === 0) throw new Error('Keine Felder zum Aktualisieren'); values.push(id); const result = await pool.query( `UPDATE buchhaltung_wiederkehrend SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`, values ); return result.rows[0] || null; } catch (error) { logger.error('BuchhaltungService.updateWiederkehrend failed', { error, id }); throw new Error('Wiederkehrende Buchung konnte nicht aktualisiert werden'); } } async function deleteWiederkehrend(id: number) { try { await pool.query(`DELETE FROM buchhaltung_wiederkehrend WHERE id = $1`, [id]); } catch (error) { logger.error('BuchhaltungService.deleteWiederkehrend failed', { error, id }); throw new Error('Wiederkehrende Buchung konnte nicht gelöscht werden'); } } // --------------------------------------------------------------------------- // CSV Export // --------------------------------------------------------------------------- async function exportTransaktionenCsv(haushaltsjahrId: number): Promise { try { const result = await pool.query( `SELECT t.*, hy.jahr as haushaltsjahr_jahr, k.bezeichnung as konto_bezeichnung, k.kontonummer as konto_kontonummer, bk.bezeichnung as bankkonto_bezeichnung FROM buchhaltung_transaktionen t JOIN buchhaltung_haushaltsjahre hy ON t.haushaltsjahr_id = hy.id LEFT JOIN buchhaltung_konten k ON t.konto_id = k.id LEFT JOIN buchhaltung_bankkonten bk ON t.bankkonto_id = bk.id WHERE t.haushaltsjahr_id = $1 ORDER BY t.datum DESC, t.id DESC`, [haushaltsjahrId] ); const statusLabels: Record = { entwurf: 'Entwurf', gebucht: 'Gebucht', freigegeben: 'Freigegeben', storniert: 'Storniert', }; const header = [ 'Nr.', 'Datum', 'Typ', 'Beschreibung', 'Empfänger/Auftraggeber', 'Verwendungszweck', 'Belegnummer', 'Konto', 'Bankkonto', 'Betrag', 'Status', ].join(';'); const rows = result.rows.map(row => { const betrag = (row.typ === 'ausgabe' ? '-' : '') + parseFloat(row.betrag).toFixed(2).replace('.', ','); const konto = row.konto_kontonummer ? `${row.konto_kontonummer} ${row.konto_bezeichnung}` : ''; const datum = row.datum ? new Date(row.datum).toLocaleDateString('de-DE') : ''; const escCsv = (val: unknown) => { const s = String(val ?? ''); return s.includes(';') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s; }; return [ row.laufende_nummer != null ? `${row.haushaltsjahr_jahr}/${row.laufende_nummer}` : `E${row.id}`, datum, row.typ === 'einnahme' ? 'Einnahme' : 'Ausgabe', escCsv(row.beschreibung), escCsv(row.empfaenger_auftraggeber), escCsv(row.verwendungszweck), escCsv(row.beleg_nr), escCsv(konto), escCsv(row.bankkonto_bezeichnung), betrag, statusLabels[row.status] || row.status, ].join(';'); }); return '\uFEFF' + header + '\r\n' + rows.join('\r\n'); } catch (error) { logger.error('BuchhaltungService.exportTransaktionenCsv failed', { error }); throw new Error('CSV-Export fehlgeschlagen'); } } // --------------------------------------------------------------------------- // 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 { 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'); } } // --------------------------------------------------------------------------- // Bestellung → Buchhaltung Integration // --------------------------------------------------------------------------- async function createPendingFromBestellung(bestellungId: number, userId: string) { try { // 1. Fetch the bestellung with total const bestResult = await pool.query( `SELECT b.*, l.name AS lieferant_name, COALESCE(SUM(bp.menge * bp.einzelpreis), 0) AS gesamtbetrag FROM bestellungen b LEFT JOIN lieferanten l ON l.id = b.lieferant_id LEFT JOIN bestellpositionen bp ON bp.bestellung_id = b.id WHERE b.id = $1 GROUP BY b.id, l.name`, [bestellungId] ); if (!bestResult.rows[0]) { logger.warn('createPendingFromBestellung: Bestellung not found', { bestellungId }); return; } const bestellung = bestResult.rows[0]; const gesamtbetrag = parseFloat(bestellung.gesamtbetrag); if (gesamtbetrag <= 0) { logger.info('createPendingFromBestellung: Gesamtbetrag is 0, skipping', { bestellungId }); return; } // 2. Find active Haushaltsjahr const hjResult = await pool.query( `SELECT id FROM buchhaltung_haushaltsjahre WHERE abgeschlossen = FALSE ORDER BY jahr DESC LIMIT 1` ); if (!hjResult.rows[0]) { logger.info('createPendingFromBestellung: No active Haushaltsjahr found, skipping', { bestellungId }); return; } const haushaltsjahrId = hjResult.rows[0].id; // 3. Find default bank account const bkResult = await pool.query( `SELECT id FROM buchhaltung_bankkonten WHERE aktiv = TRUE ORDER BY ist_standard DESC LIMIT 1` ); const bankkontoId = bkResult.rows[0]?.id || null; // 4. Insert pending transaction const label = bestellung.laufende_nummer ? `Bestellung #${bestellung.laufende_nummer}` : `Bestellung #${bestellung.id}`; await pool.query( `INSERT INTO buchhaltung_transaktionen (haushaltsjahr_id, bankkonto_id, typ, betrag, datum, beschreibung, empfaenger_auftraggeber, bestellung_id, erstellt_von, status) VALUES ($1, $2, 'ausgabe', $3, NOW(), $4, $5, $6, $7, 'entwurf')`, [haushaltsjahrId, bankkontoId, gesamtbetrag, label, bestellung.lieferant_name || null, bestellungId, userId] ); logger.info('createPendingFromBestellung: Created pending transaction', { bestellungId, gesamtbetrag }); } catch (error) { logger.error('createPendingFromBestellung failed (non-fatal)', { error, bestellungId }); // Non-fatal: don't throw } } // --------------------------------------------------------------------------- // Planung (Budget Plans) // --------------------------------------------------------------------------- async function listPlanungen() { try { const result = await pool.query( `SELECT p.*, hj.bezeichnung AS haushaltsjahr_bezeichnung, hj.jahr AS haushaltsjahr_jahr, COALESCE(pos.positionen_count, 0) AS positionen_count, COALESCE(pos.total_gwg, 0) AS total_gwg, COALESCE(pos.total_anlagen, 0) AS total_anlagen, COALESCE(pos.total_instandhaltung, 0) AS total_instandhaltung FROM buchhaltung_planung p LEFT JOIN buchhaltung_haushaltsjahre hj ON hj.id = p.haushaltsjahr_id LEFT JOIN LATERAL ( SELECT COUNT(*) AS positionen_count, SUM(budget_gwg) AS total_gwg, SUM(budget_anlagen) AS total_anlagen, SUM(budget_instandhaltung) AS total_instandhaltung FROM buchhaltung_planpositionen WHERE planung_id = p.id ) pos ON true ORDER BY p.erstellt_am DESC` ); return result.rows; } catch (error) { logger.error('BuchhaltungService.listPlanungen failed', { error }); throw new Error('Planungen konnten nicht geladen werden'); } } async function getPlanungById(id: number) { try { const planungResult = await pool.query( `SELECT p.*, hj.bezeichnung AS haushaltsjahr_bezeichnung, hj.jahr AS haushaltsjahr_jahr FROM buchhaltung_planung p LEFT JOIN buchhaltung_haushaltsjahre hj ON hj.id = p.haushaltsjahr_id WHERE p.id = $1`, [id] ); if (!planungResult.rows[0]) return null; const positionenResult = await pool.query( `SELECT pp.*, k.bezeichnung AS konto_bezeichnung, k.kontonummer AS konto_kontonummer FROM buchhaltung_planpositionen pp LEFT JOIN buchhaltung_konten k ON pp.konto_id = k.id WHERE pp.planung_id = $1 ORDER BY pp.sort_order, pp.id`, [id] ); return { ...planungResult.rows[0], positionen: positionenResult.rows, }; } catch (error) { logger.error('BuchhaltungService.getPlanungById failed', { error, id }); throw new Error('Planung konnte nicht geladen werden'); } } async function createPlanung(data: { haushaltsjahr_id: number; bezeichnung: string }, userId: string) { try { const result = await pool.query( `INSERT INTO buchhaltung_planung (haushaltsjahr_id, bezeichnung, erstellt_von) VALUES ($1, $2, $3) RETURNING *`, [data.haushaltsjahr_id, data.bezeichnung, userId] ); return result.rows[0]; } catch (error) { logger.error('BuchhaltungService.createPlanung failed', { error }); throw new Error('Planung konnte nicht erstellt werden'); } } async function updatePlanung(id: number, data: { bezeichnung?: string; status?: string }) { try { const fields: string[] = []; const values: unknown[] = []; let idx = 1; if (data.bezeichnung !== undefined) { fields.push(`bezeichnung = $${idx++}`); values.push(data.bezeichnung); } if (data.status !== undefined) { fields.push(`status = $${idx++}`); values.push(data.status); } if (fields.length === 0) throw new Error('Keine Felder zum Aktualisieren'); values.push(id); const result = await pool.query( `UPDATE buchhaltung_planung SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`, values ); return result.rows[0] || null; } catch (error) { logger.error('BuchhaltungService.updatePlanung failed', { error, id }); throw new Error('Planung konnte nicht aktualisiert werden'); } } async function deletePlanung(id: number) { try { await pool.query(`DELETE FROM buchhaltung_planung WHERE id = $1`, [id]); } catch (error) { logger.error('BuchhaltungService.deletePlanung failed', { error, id }); throw new Error('Planung konnte nicht gelöscht werden'); } } async function createPlanposition(planungId: number, data: { konto_id?: number | null; bezeichnung: string; budget_gwg?: number; budget_anlagen?: number; budget_instandhaltung?: number; notizen?: string; sort_order?: number }) { try { const result = await pool.query( `INSERT INTO buchhaltung_planpositionen (planung_id, konto_id, bezeichnung, budget_gwg, budget_anlagen, budget_instandhaltung, notizen, sort_order) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, [planungId, data.konto_id || null, data.bezeichnung, data.budget_gwg || 0, data.budget_anlagen || 0, data.budget_instandhaltung || 0, data.notizen || null, data.sort_order || 0] ); return result.rows[0]; } catch (error) { logger.error('BuchhaltungService.createPlanposition failed', { error, planungId }); throw new Error('Planposition konnte nicht erstellt werden'); } } async function updatePlanposition(id: number, data: { konto_id?: number | null; bezeichnung?: string; budget_gwg?: number; budget_anlagen?: number; budget_instandhaltung?: number; notizen?: string; sort_order?: number }) { try { const fields: string[] = []; const values: unknown[] = []; let idx = 1; if (data.konto_id !== undefined) { fields.push(`konto_id = $${idx++}`); values.push(data.konto_id); } if (data.bezeichnung !== undefined) { fields.push(`bezeichnung = $${idx++}`); values.push(data.bezeichnung); } if (data.budget_gwg !== undefined) { fields.push(`budget_gwg = $${idx++}`); values.push(data.budget_gwg); } 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 (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_planpositionen SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`, values ); return result.rows[0] || null; } catch (error) { logger.error('BuchhaltungService.updatePlanposition failed', { error, id }); throw new Error('Planposition konnte nicht aktualisiert werden'); } } async function deletePlanposition(id: number) { try { await pool.query(`DELETE FROM buchhaltung_planpositionen WHERE id = $1`, [id]); } catch (error) { logger.error('BuchhaltungService.deletePlanposition failed', { error, id }); throw new Error('Planposition konnte nicht gelöscht werden'); } } async function getPlanpositionenByPlanung(planungId: number) { try { const result = await pool.query( `SELECT pp.*, k.bezeichnung AS konto_bezeichnung, k.kontonummer AS konto_kontonummer FROM buchhaltung_planpositionen pp LEFT JOIN buchhaltung_konten k ON pp.konto_id = k.id WHERE pp.planung_id = $1 ORDER BY pp.sort_order, pp.id`, [planungId] ); return result.rows; } catch (error) { logger.error('BuchhaltungService.getPlanpositionenByPlanung failed', { error, planungId }); throw new Error('Planpositionen konnten nicht geladen werden'); } } async function createHaushaltsjahrFromPlan(planungId: number, userId: string) { const client = await pool.connect(); try { await client.query('BEGIN'); const planResult = await client.query( `SELECT p.*, hj.jahr, hj.beginn, hj.ende FROM buchhaltung_planung p LEFT JOIN buchhaltung_haushaltsjahre hj ON hj.id = p.haushaltsjahr_id WHERE p.id = $1`, [planungId] ); if (!planResult.rows[0]) throw new Error('Planung nicht gefunden'); const plan = planResult.rows[0]; const nextYear = plan.jahr + 1; // Check if Haushaltsjahr for next year already exists const existingResult = await client.query( `SELECT id FROM buchhaltung_haushaltsjahre WHERE jahr = $1`, [nextYear] ); if (existingResult.rows.length > 0) { throw Object.assign(new Error(`Haushaltsjahr ${nextYear} existiert bereits`), { statusCode: 409 }); } // Create new Haushaltsjahr const hjResult = await client.query( `INSERT INTO buchhaltung_haushaltsjahre (jahr, bezeichnung, beginn, ende, erstellt_von) VALUES ($1, $2, $3, $4, $5) RETURNING *`, [nextYear, `Haushaltsjahr ${nextYear}`, `${nextYear}-01-01`, `${nextYear}-12-31`, userId] ); const newHj = hjResult.rows[0]; // Get all positions from the plan const posResult = await client.query( `SELECT pp.*, k.kontonummer, k.bezeichnung AS konto_bezeichnung, k.konto_typ_id, k.parent_id, k.kategorie_id, k.budget_typ FROM buchhaltung_planpositionen pp LEFT JOIN buchhaltung_konten k ON pp.konto_id = k.id WHERE pp.planung_id = $1 ORDER BY pp.sort_order, pp.id`, [planungId] ); // Create konten in the new Haushaltsjahr from plan positions const kontoIdMap = new Map(); // old konto_id -> new konto_id for (const pos of posResult.rows) { if (!pos.konto_id) continue; // Map parent_id to new parent if it was already created const newParentId = pos.parent_id ? (kontoIdMap.get(pos.parent_id) || null) : null; const kontoResult = await client.query( `INSERT INTO buchhaltung_konten (haushaltsjahr_id, konto_typ_id, kontonummer, bezeichnung, parent_id, budget_gwg, budget_anlagen, budget_instandhaltung, erstellt_von, kategorie_id, budget_typ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ON CONFLICT (haushaltsjahr_id, kontonummer) DO UPDATE SET budget_gwg = EXCLUDED.budget_gwg, budget_anlagen = EXCLUDED.budget_anlagen, budget_instandhaltung = EXCLUDED.budget_instandhaltung RETURNING id`, [newHj.id, pos.konto_typ_id, pos.kontonummer, pos.konto_bezeichnung || pos.bezeichnung, newParentId, pos.budget_gwg, pos.budget_anlagen, pos.budget_instandhaltung, userId, pos.kategorie_id, pos.budget_typ || 'detailliert'] ); kontoIdMap.set(pos.konto_id, kontoResult.rows[0].id); } // Mark plan as abgeschlossen await client.query( `UPDATE buchhaltung_planung SET status = 'abgeschlossen' WHERE id = $1`, [planungId] ); await client.query('COMMIT'); await logAudit(null, 'haushaltsjahr_aus_plan_erstellt', { planung_id: planungId, haushaltsjahr_id: newHj.id, jahr: nextYear }, userId); return { haushaltsjahr: newHj }; } catch (error: any) { await client.query('ROLLBACK'); logger.error('BuchhaltungService.createHaushaltsjahrFromPlan failed', { error, planungId }); if (error.statusCode) throw error; throw new Error('Haushaltsjahr konnte nicht aus Planung erstellt werden'); } finally { client.release(); } } // --------------------------------------------------------------------------- // Export // --------------------------------------------------------------------------- const buchhaltungService = { getKategorien, createKategorie, updateKategorie, deleteKategorie, getAllHaushaltsjahre, getHaushaltsjahrById, getCurrentHaushaltsjahr, createHaushaltsjahr, updateHaushaltsjahr, closeHaushaltsjahr, getAllKontoTypen, createKontoTyp, updateKontoTyp, deleteKontoTyp, getAllBankkonten, getBankkontoById, createBankkonto, updateBankkonto, deactivateBankkonto, getBankkontoStatement, getAllKonten, getKontoById, getKontenTree, getKontoDetail, getPendingCount, createKonto, updateKonto, deleteKonto, getBudgetUtilisation, listTransaktionen, getTransaktionById, createTransaktion, updateTransaktion, bookTransaktion, stornoTransaktion, deleteTransaktion, createTransfer, getBelegeByTransaktion, uploadBeleg, deleteBeleg, getFreigabenByTransaktion, createFreigabe, approveFreigabe, rejectFreigabe, getEinstellungen, setEinstellungen, logAudit, getAuditByTransaktion, getOverview, getAllWiederkehrend, getWiederkehrendById, createWiederkehrend, updateWiederkehrend, deleteWiederkehrend, exportTransaktionenCsv, createErstattung, getErstattungLinks, createPendingFromBestellung, listPlanungen, getPlanungById, createPlanung, updatePlanung, deletePlanung, createPlanposition, updatePlanposition, deletePlanposition, getPlanpositionenByPlanung, createHaushaltsjahrFromPlan, }; export default buchhaltungService;