2040 lines
80 KiB
TypeScript
2040 lines
80 KiB
TypeScript
// =============================================================================
|
|
// 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<number> {
|
|
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<void> {
|
|
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<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_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<string> {
|
|
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<string, string> = {
|
|
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<Transaktion> {
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
// Look up haushaltsjahr_id from the konto
|
|
const kontoResult = await client.query(
|
|
`SELECT haushaltsjahr_id FROM buchhaltung_konten WHERE id = $1`,
|
|
[data.konto_id]
|
|
);
|
|
const haushaltsjahrId = kontoResult.rows[0]?.haushaltsjahr_id;
|
|
|
|
const txResult = await client.query(
|
|
`INSERT INTO buchhaltung_transaktionen (typ, konto_id, bankkonto_id, betrag, datum, beschreibung, empfaenger_auftraggeber, verwendungszweck, erstellt_von, haushaltsjahr_id, status)
|
|
VALUES ('einnahme', $1, $2, $3, $4, $5, $6, $7, $8, $9, 'entwurf')
|
|
RETURNING *`,
|
|
[data.konto_id, data.bankkonto_id, data.betrag, data.datum, data.beschreibung || null, data.empfaenger_auftraggeber || null, data.verwendungszweck || null, data.erstellt_von || null, haushaltsjahrId]
|
|
);
|
|
const tx = txResult.rows[0];
|
|
|
|
for (const ausgabeId of data.ausgabe_ids) {
|
|
await client.query(
|
|
`INSERT INTO buchhaltung_erstattung_zuordnungen (erstattung_transaktion_id, ausgabe_transaktion_id) VALUES ($1, $2)`,
|
|
[tx.id, ausgabeId]
|
|
);
|
|
}
|
|
|
|
await client.query('COMMIT');
|
|
return tx;
|
|
} catch (error) {
|
|
await client.query('ROLLBACK');
|
|
logger.error('BuchhaltungService.createErstattung failed', { error });
|
|
throw new Error('Erstattung konnte nicht erstellt werden');
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
async function getErstattungLinks(transaktionId: number): Promise<{
|
|
erstattung?: any;
|
|
ausgaben?: any[];
|
|
}> {
|
|
try {
|
|
// If this transaction is an Ausgabe, find its reimbursement
|
|
const erstattungResult = await pool.query(
|
|
`SELECT t.* FROM buchhaltung_transaktionen t
|
|
JOIN buchhaltung_erstattung_zuordnungen ez ON t.id = ez.erstattung_transaktion_id
|
|
WHERE ez.ausgabe_transaktion_id = $1`,
|
|
[transaktionId]
|
|
);
|
|
|
|
// If this transaction is an Erstattung, find its linked Ausgaben
|
|
const ausgabenResult = await pool.query(
|
|
`SELECT t.* FROM buchhaltung_transaktionen t
|
|
JOIN buchhaltung_erstattung_zuordnungen ez ON t.id = ez.ausgabe_transaktion_id
|
|
WHERE ez.erstattung_transaktion_id = $1`,
|
|
[transaktionId]
|
|
);
|
|
|
|
return {
|
|
erstattung: erstattungResult.rows[0] || undefined,
|
|
ausgaben: ausgabenResult.rows,
|
|
};
|
|
} catch (error) {
|
|
logger.error('BuchhaltungService.getErstattungLinks failed', { error, transaktionId });
|
|
throw new Error('Erstattungsverknüpfungen konnten nicht geladen werden');
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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<number, number>(); // 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;
|