feat: add Buchhaltung module with fiscal years, budget accounts, transactions, and approval workflow
This commit is contained in:
857
backend/src/services/buchhaltung.service.ts
Normal file
857
backend/src/services/buchhaltung.service.ts
Normal file
@@ -0,0 +1,857 @@
|
||||
// =============================================================================
|
||||
// Buchhaltung (Accounting) Service
|
||||
// =============================================================================
|
||||
|
||||
import pool from '../config/database';
|
||||
import logger from '../utils/logger';
|
||||
import fs from 'fs';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
FROM buchhaltung_konten k
|
||||
LEFT JOIN buchhaltung_konto_typen kt ON k.konto_typ_id = kt.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
|
||||
FROM buchhaltung_konten k
|
||||
LEFT JOIN buchhaltung_konto_typen kt ON k.konto_typ_id = kt.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 createKonto(
|
||||
data: { haushaltsjahr_id: number; konto_typ_id?: number; kontonummer: string; bezeichnung: string; budget_betrag?: number; notizen?: string },
|
||||
userId: string
|
||||
) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO buchhaltung_konten (haushaltsjahr_id, konto_typ_id, kontonummer, bezeichnung, budget_betrag, notizen, erstellt_von)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *`,
|
||||
[data.haushaltsjahr_id, data.konto_typ_id || null, data.kontonummer, data.bezeichnung, data.budget_betrag || 0, data.notizen || null, userId]
|
||||
);
|
||||
return result.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungService.createKonto failed', { error });
|
||||
throw new Error('Konto konnte nicht erstellt werden');
|
||||
}
|
||||
}
|
||||
|
||||
async function updateKonto(
|
||||
id: number,
|
||||
data: { konto_typ_id?: number; kontonummer?: string; bezeichnung?: string; budget_betrag?: number; notizen?: string }
|
||||
) {
|
||||
try {
|
||||
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.budget_betrag !== undefined){ fields.push(`budget_betrag = $${idx++}`); values.push(data.budget_betrag); }
|
||||
if (data.notizen !== undefined) { fields.push(`notizen = $${idx++}`); values.push(data.notizen || null); }
|
||||
if (fields.length === 0) throw new Error('Keine Felder zum Aktualisieren');
|
||||
values.push(id);
|
||||
const result = await pool.query(
|
||||
`UPDATE buchhaltung_konten SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`,
|
||||
values
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungService.updateKonto failed', { error, id });
|
||||
throw new Error('Konto konnte nicht aktualisiert werden');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteKonto(id: number) {
|
||||
try {
|
||||
// Soft delete
|
||||
await pool.query(`UPDATE buchhaltung_konten SET aktiv = FALSE 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_betrag);
|
||||
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.*,
|
||||
k.bezeichnung as konto_bezeichnung,
|
||||
k.kontonummer as konto_kontonummer,
|
||||
bk.bezeichnung as bankkonto_bezeichnung
|
||||
FROM buchhaltung_transaktionen t
|
||||
LEFT JOIN buchhaltung_konten k ON t.konto_id = k.id
|
||||
LEFT JOIN buchhaltung_bankkonten bk ON t.bankkonto_id = bk.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.*,
|
||||
k.bezeichnung as konto_bezeichnung,
|
||||
k.kontonummer as konto_kontonummer,
|
||||
bk.bezeichnung as bankkonto_bezeichnung
|
||||
FROM buchhaltung_transaktionen t
|
||||
LEFT JOIN buchhaltung_konten k ON t.konto_id = k.id
|
||||
LEFT JOIN buchhaltung_bankkonten bk ON t.bankkonto_id = bk.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;
|
||||
},
|
||||
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, erstellt_von)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
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,
|
||||
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);
|
||||
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 = 'storniert'
|
||||
WHERE id = $1 AND status IN ('gebucht', 'freigegeben')
|
||||
RETURNING *`,
|
||||
[id, userId]
|
||||
);
|
||||
if (result.rows[0]) await logAudit(id, 'storniert', {}, 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');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<string, unknown> = {};
|
||||
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<string, unknown>) {
|
||||
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_betrag) - parseFloat(row.gebucht_betrag) - parseFloat(row.ausstehend_betrag),
|
||||
auslastung_prozent: parseFloat(row.budget_betrag) > 0
|
||||
? Math.round((parseFloat(row.gebucht_betrag) / parseFloat(row.budget_betrag)) * 100)
|
||||
: 0,
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungService.getOverview failed', { error, haushaltsjahrId });
|
||||
throw new Error('Übersicht konnte nicht geladen werden');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const buchhaltungService = {
|
||||
getAllHaushaltsjahre,
|
||||
getHaushaltsjahrById,
|
||||
getCurrentHaushaltsjahr,
|
||||
createHaushaltsjahr,
|
||||
updateHaushaltsjahr,
|
||||
closeHaushaltsjahr,
|
||||
getAllKontoTypen,
|
||||
getAllBankkonten,
|
||||
getBankkontoById,
|
||||
createBankkonto,
|
||||
updateBankkonto,
|
||||
deactivateBankkonto,
|
||||
getAllKonten,
|
||||
getKontoById,
|
||||
createKonto,
|
||||
updateKonto,
|
||||
deleteKonto,
|
||||
getBudgetUtilisation,
|
||||
listTransaktionen,
|
||||
getTransaktionById,
|
||||
createTransaktion,
|
||||
updateTransaktion,
|
||||
bookTransaktion,
|
||||
stornoTransaktion,
|
||||
deleteTransaktion,
|
||||
getBelegeByTransaktion,
|
||||
uploadBeleg,
|
||||
deleteBeleg,
|
||||
getFreigabenByTransaktion,
|
||||
createFreigabe,
|
||||
approveFreigabe,
|
||||
rejectFreigabe,
|
||||
getEinstellungen,
|
||||
setEinstellungen,
|
||||
logAudit,
|
||||
getAuditByTransaktion,
|
||||
getOverview,
|
||||
};
|
||||
|
||||
export default buchhaltungService;
|
||||
@@ -41,6 +41,13 @@ const DEFAULT_PERMISSION_DEPS: Record<string, string[]> = {
|
||||
'wissen:widget_recent': ['wissen:view'],
|
||||
'wissen:widget_search': ['wissen:view'],
|
||||
'admin:write': ['admin:view'],
|
||||
'buchhaltung:create': ['buchhaltung:view'],
|
||||
'buchhaltung:edit': ['buchhaltung:view'],
|
||||
'buchhaltung:delete': ['buchhaltung:view', 'buchhaltung:create'],
|
||||
'buchhaltung:manage_accounts': ['buchhaltung:view'],
|
||||
'buchhaltung:manage_settings': ['buchhaltung:view'],
|
||||
'buchhaltung:export': ['buchhaltung:view'],
|
||||
'buchhaltung:widget': ['buchhaltung:view'],
|
||||
};
|
||||
|
||||
export interface FeatureGroupRow {
|
||||
|
||||
Reference in New Issue
Block a user