feat(buchhaltung): add transfers, bank statements, Haushaltsplan, and PDF export

This commit is contained in:
Matthias Hochmeister
2026-03-30 17:05:18 +02:00
parent 2eb59e9ff1
commit 5acfd7cc4f
14 changed files with 1911 additions and 10 deletions

View File

@@ -201,6 +201,23 @@ class BuchhaltungController {
}
}
async getBankkontoStatement(req: Request, res: Response): Promise<void> {
const id = parseInt(param(req, 'id'), 10);
if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; }
try {
const filters = {
von: req.query.von as string | undefined,
bis: req.query.bis as string | undefined,
};
const data = await buchhaltungService.getBankkontoStatement(id, filters);
if (!data) { res.status(404).json({ success: false, message: 'Bankkonto nicht gefunden' }); return; }
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.getBankkontoStatement', { error });
res.status(500).json({ success: false, message: 'Kontoauszug konnte nicht geladen werden' });
}
}
// ── Konten ───────────────────────────────────────────────────────────────────
async listKonten(req: Request, res: Response): Promise<void> {
@@ -411,6 +428,18 @@ class BuchhaltungController {
}
}
// ── Transfers ─────────────────────────────────────────────────────────────────
async createTransfer(req: Request, res: Response): Promise<void> {
try {
const data = await buchhaltungService.createTransfer(req.body, req.user!.id);
res.status(201).json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.createTransfer', { error });
res.status(500).json({ success: false, message: 'Umbuchung konnte nicht erstellt werden' });
}
}
// ── Belege ───────────────────────────────────────────────────────────────────
async uploadBeleg(req: Request, res: Response): Promise<void> {
@@ -566,6 +595,128 @@ class BuchhaltungController {
}
}
// ── Planung ─────────────────────────────────────────────────────────────────
async listPlanungen(_req: Request, res: Response): Promise<void> {
try {
const data = await buchhaltungService.listPlanungen();
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.listPlanungen', { error });
res.status(500).json({ success: false, message: 'Planungen konnten nicht geladen werden' });
}
}
async getPlanung(req: Request, res: Response): Promise<void> {
const id = parseInt(param(req, 'id'), 10);
if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; }
try {
const data = await buchhaltungService.getPlanungById(id);
if (!data) { res.status(404).json({ success: false, message: 'Planung nicht gefunden' }); return; }
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.getPlanung', { error });
res.status(500).json({ success: false, message: 'Planung konnte nicht geladen werden' });
}
}
async createPlanung(req: Request, res: Response): Promise<void> {
try {
const data = await buchhaltungService.createPlanung(req.body, req.user!.id);
res.status(201).json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.createPlanung', { error });
res.status(500).json({ success: false, message: 'Planung konnte nicht erstellt werden' });
}
}
async updatePlanung(req: Request, res: Response): Promise<void> {
const id = parseInt(param(req, 'id'), 10);
if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; }
try {
const data = await buchhaltungService.updatePlanung(id, req.body);
if (!data) { res.status(404).json({ success: false, message: 'Planung nicht gefunden' }); return; }
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.updatePlanung', { error });
res.status(500).json({ success: false, message: 'Planung konnte nicht aktualisiert werden' });
}
}
async deletePlanung(req: Request, res: Response): Promise<void> {
const id = parseInt(param(req, 'id'), 10);
if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; }
try {
await buchhaltungService.deletePlanung(id);
res.json({ success: true });
} catch (error) {
logger.error('BuchhaltungController.deletePlanung', { error });
res.status(500).json({ success: false, message: 'Planung konnte nicht gelöscht werden' });
}
}
async createPlanposition(req: Request, res: Response): Promise<void> {
const planungId = parseInt(param(req, 'id'), 10);
if (isNaN(planungId)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; }
try {
const data = await buchhaltungService.createPlanposition(planungId, req.body);
res.status(201).json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.createPlanposition', { error });
res.status(500).json({ success: false, message: 'Planposition konnte nicht erstellt werden' });
}
}
async updatePlanposition(req: Request, res: Response): Promise<void> {
const id = parseInt(param(req, 'id'), 10);
if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; }
try {
const data = await buchhaltungService.updatePlanposition(id, req.body);
if (!data) { res.status(404).json({ success: false, message: 'Planposition nicht gefunden' }); return; }
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.updatePlanposition', { error });
res.status(500).json({ success: false, message: 'Planposition konnte nicht aktualisiert werden' });
}
}
async deletePlanposition(req: Request, res: Response): Promise<void> {
const id = parseInt(param(req, 'id'), 10);
if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; }
try {
await buchhaltungService.deletePlanposition(id);
res.json({ success: true });
} catch (error) {
logger.error('BuchhaltungController.deletePlanposition', { error });
res.status(500).json({ success: false, message: 'Planposition konnte nicht gelöscht werden' });
}
}
async listPlanpositionen(req: Request, res: Response): Promise<void> {
const planungId = parseInt(param(req, 'id'), 10);
if (isNaN(planungId)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; }
try {
const data = await buchhaltungService.getPlanpositionenByPlanung(planungId);
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.listPlanpositionen', { error });
res.status(500).json({ success: false, message: 'Planpositionen konnten nicht geladen werden' });
}
}
async createHaushaltsjahrFromPlan(req: Request, res: Response): Promise<void> {
const id = parseInt(param(req, 'id'), 10);
if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; }
try {
const data = await buchhaltungService.createHaushaltsjahrFromPlan(id, req.user!.id);
res.status(201).json({ success: true, data });
} catch (error: any) {
logger.error('BuchhaltungController.createHaushaltsjahrFromPlan', { error });
const status = error.statusCode || 500;
res.status(status).json({ success: false, message: error.message || 'Haushaltsjahr konnte nicht erstellt werden' });
}
}
// ── Freigaben ────────────────────────────────────────────────────────────────
async requestFreigabe(req: Request, res: Response): Promise<void> {

View File

@@ -0,0 +1,18 @@
-- Migration 082: Add 'transfer' transaction type + target bank account column
-- 1. Drop and recreate the typ CHECK constraint to include 'transfer'
ALTER TABLE buchhaltung_transaktionen
DROP CONSTRAINT IF EXISTS buchhaltung_transaktionen_typ_check;
ALTER TABLE buchhaltung_transaktionen
ADD CONSTRAINT buchhaltung_transaktionen_typ_check
CHECK (typ IN ('einnahme', 'ausgabe', 'transfer'));
-- 2. Add target bank account column for transfers
ALTER TABLE buchhaltung_transaktionen
ADD COLUMN IF NOT EXISTS transfer_ziel_bankkonto_id INT
REFERENCES buchhaltung_bankkonten(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_buch_trans_transfer_ziel
ON buchhaltung_transaktionen(transfer_ziel_bankkonto_id)
WHERE transfer_ziel_bankkonto_id IS NOT NULL;

View File

@@ -33,6 +33,7 @@ router.get('/bankkonten', authenticate, requirePermission('buchhaltung:vie
router.post('/bankkonten', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.createBankkonto.bind(buchhaltungController));
router.patch('/bankkonten/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.updateBankkonto.bind(buchhaltungController));
router.delete('/bankkonten/:id',authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.deleteBankkonto.bind(buchhaltungController));
router.get('/bankkonten/:id/transaktionen', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getBankkontoStatement.bind(buchhaltungController));
// ── Konten ────────────────────────────────────────────────────────────────────
router.get('/konten/tree', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getKontenTree.bind(buchhaltungController));
@@ -60,6 +61,18 @@ router.delete('/wiederkehrend/:id', authenticate, requirePermission('buchhaltung
router.post('/erstattungen', authenticate, requirePermission('buchhaltung:create'), buchhaltungController.createErstattung.bind(buchhaltungController));
router.get('/transaktionen/:id/erstattung-links', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getErstattungLinks.bind(buchhaltungController));
// ── Planung ──────────────────────────────────────────────────────────────────
router.get('/planung', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.listPlanungen.bind(buchhaltungController));
router.post('/planung', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.createPlanung.bind(buchhaltungController));
router.get('/planung/:id', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getPlanung.bind(buchhaltungController));
router.put('/planung/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.updatePlanung.bind(buchhaltungController));
router.delete('/planung/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.deletePlanung.bind(buchhaltungController));
router.get('/planung/:id/positionen', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.listPlanpositionen.bind(buchhaltungController));
router.post('/planung/:id/positionen', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.createPlanposition.bind(buchhaltungController));
router.put('/planung/positionen/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.updatePlanposition.bind(buchhaltungController));
router.delete('/planung/positionen/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.deletePlanposition.bind(buchhaltungController));
router.post('/planung/:id/create-haushaltsjahr', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.createHaushaltsjahrFromPlan.bind(buchhaltungController));
// ── Audit ──────────────────────────────────────────────────────────────────────
router.get('/audit/:transaktionId', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getAudit.bind(buchhaltungController));
@@ -70,6 +83,9 @@ router.get('/export/csv', authenticate, requirePermission('buchhaltung:export'),
router.patch('/freigaben/:id/genehmigen', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.approveFreigabe.bind(buchhaltungController));
router.patch('/freigaben/:id/ablehnen', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.rejectFreigabe.bind(buchhaltungController));
// ── Transfers ─────────────────────────────────────────────────────────────────
router.post('/transfers', authenticate, requirePermission('buchhaltung:create'), buchhaltungController.createTransfer.bind(buchhaltungController));
// ── Transaktionen (list/create — before /:id) ─────────────────────────────────
router.get('/', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.listTransaktionen.bind(buchhaltungController));
router.post('/', authenticate, requirePermission('buchhaltung:create'), buchhaltungController.createTransaktion.bind(buchhaltungController));

View File

@@ -8,6 +8,7 @@ import fs from 'fs';
import notificationService from './notification.service';
import { permissionService } from './permission.service';
import ausruestungsanfrageService from './ausruestungsanfrage.service';
import buchhaltungService from './buchhaltung.service';
// ---------------------------------------------------------------------------
// Catalog (shared ausruestung_artikel via ausruestungsanfrageService)
@@ -381,6 +382,17 @@ async function updateOrderStatus(id: number, status: string, userId: string, for
}
}
// Block transition to abgeschlossen if positions have missing prices
if (status === 'abgeschlossen') {
const posCheck = await pool.query(
`SELECT COUNT(*) FROM bestellpositionen WHERE bestellung_id = $1 AND (einzelpreis IS NULL OR einzelpreis = 0)`,
[id]
);
if (parseInt(posCheck.rows[0].count) > 0) {
throw new Error('Alle Positionen müssen einen Einzelpreis haben, bevor die Bestellung abgeschlossen werden kann.');
}
}
const updates: string[] = ['status = $1', 'aktualisiert_am = NOW()'];
const params: unknown[] = [status];
let paramIndex = 2;
@@ -452,6 +464,13 @@ async function updateOrderStatus(id: number, status: string, userId: string, for
});
}
// Create pending buchhaltung transaction when order is completed
if (status === 'abgeschlossen') {
buchhaltungService.createPendingFromBestellung(id, userId).catch(err =>
logger.error('createPendingFromBestellung failed (non-fatal)', { err, orderId: id })
);
}
return result.rows[0];
} catch (error) {
logger.error('BestellungService.updateOrderStatus failed', { error, id });

View File

@@ -354,6 +354,98 @@ async function deactivateBankkonto(id: number) {
}
}
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.*,
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
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)
// ---------------------------------------------------------------------------
@@ -445,10 +537,12 @@ async function getKontoDetail(kontoId: number) {
`SELECT t.*,
k.bezeichnung as konto_bezeichnung,
k.kontonummer as konto_kontonummer,
bk.bezeichnung as bankkonto_bezeichnung
bk.bezeichnung as bankkonto_bezeichnung,
bk2.bezeichnung AS transfer_ziel_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
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]
@@ -702,10 +796,12 @@ async function listTransaktionen(filters: {
`SELECT t.*,
k.bezeichnung as konto_bezeichnung,
k.kontonummer as konto_kontonummer,
bk.bezeichnung as bankkonto_bezeichnung
bk.bezeichnung as bankkonto_bezeichnung,
bk2.bezeichnung AS transfer_ziel_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
LEFT JOIN buchhaltung_bankkonten bk2 ON t.transfer_ziel_bankkonto_id = bk2.id
${where}
ORDER BY t.datum DESC, t.id DESC`,
values
@@ -723,10 +819,12 @@ async function getTransaktionById(id: number) {
`SELECT t.*,
k.bezeichnung as konto_bezeichnung,
k.kontonummer as konto_kontonummer,
bk.bezeichnung as bankkonto_bezeichnung
bk.bezeichnung as bankkonto_bezeichnung,
bk2.bezeichnung AS transfer_ziel_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
LEFT JOIN buchhaltung_bankkonten bk2 ON t.transfer_ziel_bankkonto_id = bk2.id
WHERE t.id = $1`,
[id]
);
@@ -909,6 +1007,59 @@ async function deleteTransaktion(id: number) {
}
}
// ---------------------------------------------------------------------------
// 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)
// ---------------------------------------------------------------------------
@@ -1435,6 +1586,331 @@ async function getErstattungLinks(transaktionId: number): Promise<{
}
}
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
@@ -1459,6 +1935,7 @@ const buchhaltungService = {
createBankkonto,
updateBankkonto,
deactivateBankkonto,
getBankkontoStatement,
getAllKonten,
getKontoById,
getKontenTree,
@@ -1475,6 +1952,7 @@ const buchhaltungService = {
bookTransaktion,
stornoTransaktion,
deleteTransaktion,
createTransfer,
getBelegeByTransaktion,
uploadBeleg,
deleteBeleg,
@@ -1495,6 +1973,17 @@ const buchhaltungService = {
exportTransaktionenCsv,
createErstattung,
getErstattungLinks,
createPendingFromBestellung,
listPlanungen,
getPlanungById,
createPlanung,
updatePlanung,
deletePlanung,
createPlanposition,
updatePlanposition,
deletePlanposition,
getPlanpositionenByPlanung,
createHaushaltsjahrFromPlan,
};
export default buchhaltungService;