feat: add Buchhaltung module with fiscal years, budget accounts, transactions, and approval workflow

This commit is contained in:
Matthias Hochmeister
2026-03-28 19:48:32 +01:00
parent 4349de9bc9
commit 18b1300de8
14 changed files with 2791 additions and 1 deletions

View File

@@ -107,6 +107,7 @@ import buchungskategorieRoutes from './routes/buchungskategorie.routes';
import checklistRoutes from './routes/checklist.routes';
import fahrzeugTypRoutes from './routes/fahrzeugTyp.routes';
import ausruestungTypRoutes from './routes/ausruestungTyp.routes';
import buchhaltungRoutes from './routes/buchhaltung.routes';
app.use('/api/auth', authRoutes);
app.use('/api/user', userRoutes);
@@ -136,6 +137,7 @@ app.use('/api/buchungskategorien', buchungskategorieRoutes);
app.use('/api/checklisten', checklistRoutes);
app.use('/api/fahrzeug-typen', fahrzeugTypRoutes);
app.use('/api/ausruestung-typen', ausruestungTypRoutes);
app.use('/api/buchhaltung', buchhaltungRoutes);
// Static file serving for uploads (authenticated)
const uploadsDir = process.env.NODE_ENV === 'production' ? '/app/uploads' : path.resolve(__dirname, '../../uploads');

View File

@@ -0,0 +1,339 @@
import { Request, Response } from 'express';
import buchhaltungService from '../services/buchhaltung.service';
import logger from '../utils/logger';
const param = (req: Request, key: string): string => req.params[key] as string;
class BuchhaltungController {
// ── Haushaltsjahre ──────────────────────────────────────────────────────────
async listHaushaltsjahre(_req: Request, res: Response): Promise<void> {
try {
const data = await buchhaltungService.getAllHaushaltsjahre();
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.listHaushaltsjahre', { error });
res.status(500).json({ success: false, message: 'Haushaltsjahre konnten nicht geladen werden' });
}
}
async createHaushaltsjahr(req: Request, res: Response): Promise<void> {
try {
const data = await buchhaltungService.createHaushaltsjahr(req.body, req.user!.id);
res.status(201).json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.createHaushaltsjahr', { error });
res.status(500).json({ success: false, message: 'Haushaltsjahr konnte nicht erstellt werden' });
}
}
async updateHaushaltsjahr(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.updateHaushaltsjahr(id, req.body);
if (!data) { res.status(404).json({ success: false, message: 'Haushaltsjahr nicht gefunden' }); return; }
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.updateHaushaltsjahr', { error });
res.status(500).json({ success: false, message: String(error) });
}
}
async closeHaushaltsjahr(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.closeHaushaltsjahr(id);
if (!data) { res.status(404).json({ success: false, message: 'Haushaltsjahr nicht gefunden' }); return; }
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.closeHaushaltsjahr', { error });
res.status(400).json({ success: false, message: String(error) });
}
}
// ── Konto-Typen ─────────────────────────────────────────────────────────────
async listKontoTypen(_req: Request, res: Response): Promise<void> {
try {
const data = await buchhaltungService.getAllKontoTypen();
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.listKontoTypen', { error });
res.status(500).json({ success: false, message: 'Kontotypen konnten nicht geladen werden' });
}
}
// ── Bankkonten ───────────────────────────────────────────────────────────────
async listBankkonten(_req: Request, res: Response): Promise<void> {
try {
const data = await buchhaltungService.getAllBankkonten();
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.listBankkonten', { error });
res.status(500).json({ success: false, message: 'Bankkonten konnten nicht geladen werden' });
}
}
async createBankkonto(req: Request, res: Response): Promise<void> {
try {
const data = await buchhaltungService.createBankkonto(req.body, req.user!.id);
res.status(201).json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.createBankkonto', { error });
res.status(500).json({ success: false, message: 'Bankkonto konnte nicht erstellt werden' });
}
}
async updateBankkonto(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.updateBankkonto(id, req.body);
if (!data) { res.status(404).json({ success: false, message: 'Bankkonto nicht gefunden' }); return; }
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.updateBankkonto', { error });
res.status(500).json({ success: false, message: 'Bankkonto konnte nicht aktualisiert werden' });
}
}
async deleteBankkonto(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.deactivateBankkonto(id);
res.json({ success: true });
} catch (error) {
logger.error('BuchhaltungController.deleteBankkonto', { error });
res.status(500).json({ success: false, message: 'Bankkonto konnte nicht deaktiviert werden' });
}
}
// ── Konten ───────────────────────────────────────────────────────────────────
async listKonten(req: Request, res: Response): Promise<void> {
const haushaltsjahrId = parseInt(req.query.haushaltsjahr_id as string, 10);
if (isNaN(haushaltsjahrId)) { res.status(400).json({ success: false, message: 'haushaltsjahr_id erforderlich' }); return; }
try {
const data = await buchhaltungService.getAllKonten(haushaltsjahrId);
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.listKonten', { error });
res.status(500).json({ success: false, message: 'Konten konnten nicht geladen werden' });
}
}
async createKonto(req: Request, res: Response): Promise<void> {
try {
const data = await buchhaltungService.createKonto(req.body, req.user!.id);
res.status(201).json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.createKonto', { error });
res.status(500).json({ success: false, message: 'Konto konnte nicht erstellt werden' });
}
}
async updateKonto(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.updateKonto(id, req.body);
if (!data) { res.status(404).json({ success: false, message: 'Konto nicht gefunden' }); return; }
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.updateKonto', { error });
res.status(500).json({ success: false, message: 'Konto konnte nicht aktualisiert werden' });
}
}
async deleteKonto(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.deleteKonto(id);
res.json({ success: true });
} catch (error) {
logger.error('BuchhaltungController.deleteKonto', { error });
res.status(500).json({ success: false, message: 'Konto konnte nicht gelöscht werden' });
}
}
async getKontoBudget(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.getBudgetUtilisation(id);
if (!data) { res.status(404).json({ success: false, message: 'Konto nicht gefunden' }); return; }
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.getKontoBudget', { error });
res.status(500).json({ success: false, message: 'Budgetauslastung konnte nicht geladen werden' });
}
}
// ── Stats ────────────────────────────────────────────────────────────────────
async getStats(req: Request, res: Response): Promise<void> {
const haushaltsjahrId = parseInt(req.query.haushaltsjahr_id as string, 10);
if (isNaN(haushaltsjahrId)) { res.status(400).json({ success: false, message: 'haushaltsjahr_id erforderlich' }); return; }
try {
const data = await buchhaltungService.getOverview(haushaltsjahrId);
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.getStats', { error });
res.status(500).json({ success: false, message: 'Statistik konnte nicht geladen werden' });
}
}
// ── Transaktionen ────────────────────────────────────────────────────────────
async listTransaktionen(req: Request, res: Response): Promise<void> {
try {
const filters = {
haushaltsjahr_id: req.query.haushaltsjahr_id ? parseInt(req.query.haushaltsjahr_id as string, 10) : undefined,
konto_id: req.query.konto_id ? parseInt(req.query.konto_id as string, 10) : undefined,
status: req.query.status as string | undefined,
typ: req.query.typ as string | undefined,
datum_von: req.query.datum_von as string | undefined,
datum_bis: req.query.datum_bis as string | undefined,
search: req.query.search as string | undefined,
};
const data = await buchhaltungService.listTransaktionen(filters);
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.listTransaktionen', { error });
res.status(500).json({ success: false, message: 'Transaktionen konnten nicht geladen werden' });
}
}
async getTransaktion(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.getTransaktionById(id);
if (!data) { res.status(404).json({ success: false, message: 'Transaktion nicht gefunden' }); return; }
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.getTransaktion', { error });
res.status(500).json({ success: false, message: 'Transaktion konnte nicht geladen werden' });
}
}
async createTransaktion(req: Request, res: Response): Promise<void> {
try {
const data = await buchhaltungService.createTransaktion(req.body, req.user!.id);
res.status(201).json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.createTransaktion', { error });
res.status(500).json({ success: false, message: 'Transaktion konnte nicht erstellt werden' });
}
}
async updateTransaktion(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.updateTransaktion(id, req.body, req.user!.id);
if (!data) { res.status(404).json({ success: false, message: 'Transaktion nicht gefunden oder nicht mehr bearbeitbar' }); return; }
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.updateTransaktion', { error });
res.status(500).json({ success: false, message: 'Transaktion konnte nicht aktualisiert werden' });
}
}
async deleteTransaktion(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 deleted = await buchhaltungService.deleteTransaktion(id);
if (!deleted) { res.status(404).json({ success: false, message: 'Transaktion nicht gefunden oder nicht löschbar' }); return; }
res.json({ success: true });
} catch (error) {
logger.error('BuchhaltungController.deleteTransaktion', { error });
res.status(500).json({ success: false, message: 'Transaktion konnte nicht gelöscht werden' });
}
}
async buchenTransaktion(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.bookTransaktion(id, req.user!.id);
if (!data) { res.status(404).json({ success: false, message: 'Transaktion nicht gefunden oder bereits gebucht' }); return; }
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.buchenTransaktion', { error });
res.status(500).json({ success: false, message: 'Transaktion konnte nicht gebucht werden' });
}
}
async stornoTransaktion(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.stornoTransaktion(id, req.user!.id);
if (!data) { res.status(404).json({ success: false, message: 'Transaktion nicht stornierbar' }); return; }
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.stornoTransaktion', { error });
res.status(500).json({ success: false, message: 'Transaktion konnte nicht storniert werden' });
}
}
// ── Belege ───────────────────────────────────────────────────────────────────
async uploadBeleg(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; }
if (!req.file) { res.status(400).json({ success: false, message: 'Keine Datei hochgeladen' }); return; }
try {
const data = await buchhaltungService.uploadBeleg(id, req.file, req.user!.id);
res.status(201).json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.uploadBeleg', { error });
res.status(500).json({ success: false, message: 'Beleg konnte nicht hochgeladen werden' });
}
}
async deleteBeleg(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 deleted = await buchhaltungService.deleteBeleg(id);
if (!deleted) { res.status(404).json({ success: false, message: 'Beleg nicht gefunden' }); return; }
res.json({ success: true });
} catch (error) {
logger.error('BuchhaltungController.deleteBeleg', { error });
res.status(500).json({ success: false, message: 'Beleg konnte nicht gelöscht werden' });
}
}
// ── Einstellungen ────────────────────────────────────────────────────────────
async getEinstellungen(_req: Request, res: Response): Promise<void> {
try {
const data = await buchhaltungService.getEinstellungen();
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.getEinstellungen', { error });
res.status(500).json({ success: false, message: 'Einstellungen konnten nicht geladen werden' });
}
}
async setEinstellungen(req: Request, res: Response): Promise<void> {
try {
await buchhaltungService.setEinstellungen(req.body);
res.json({ success: true });
} catch (error) {
logger.error('BuchhaltungController.setEinstellungen', { error });
res.status(500).json({ success: false, message: 'Einstellungen konnten nicht gespeichert werden' });
}
}
}
export default new BuchhaltungController();

View File

@@ -0,0 +1,231 @@
-- =============================================================================
-- Migration 075: Buchhaltung (Accounting) Schema
-- =============================================================================
-- 1. Account types (lookup table)
CREATE TABLE IF NOT EXISTS buchhaltung_konto_typen (
id SERIAL PRIMARY KEY,
bezeichnung TEXT NOT NULL UNIQUE,
art TEXT NOT NULL CHECK (art IN ('einnahme', 'ausgabe', 'vermoegen', 'verbindlichkeit')),
sort_order INT NOT NULL DEFAULT 0
);
INSERT INTO buchhaltung_konto_typen (bezeichnung, art, sort_order) VALUES
('Einnahmen', 'einnahme', 1),
('Ausgaben', 'ausgabe', 2),
('Vermögen', 'vermoegen', 3),
('Verbindlichkeiten','verbindlichkeit', 4)
ON CONFLICT (bezeichnung) DO NOTHING;
-- 2. Bank accounts
CREATE TABLE IF NOT EXISTS buchhaltung_bankkonten (
id SERIAL PRIMARY KEY,
bezeichnung TEXT NOT NULL,
iban TEXT,
bic TEXT,
institut TEXT,
ist_standard BOOLEAN NOT NULL DEFAULT FALSE,
aktiv BOOLEAN NOT NULL DEFAULT TRUE,
erstellt_von UUID REFERENCES users(id) ON DELETE SET NULL,
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 3. Fiscal years
CREATE TABLE IF NOT EXISTS buchhaltung_haushaltsjahre (
id SERIAL PRIMARY KEY,
jahr INT NOT NULL UNIQUE,
bezeichnung TEXT NOT NULL,
beginn DATE NOT NULL,
ende DATE NOT NULL,
abgeschlossen BOOLEAN NOT NULL DEFAULT FALSE,
erstellt_von UUID,
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 4. Budget accounts
CREATE TABLE IF NOT EXISTS buchhaltung_konten (
id SERIAL PRIMARY KEY,
haushaltsjahr_id INT NOT NULL REFERENCES buchhaltung_haushaltsjahre(id) ON DELETE CASCADE,
konto_typ_id INT REFERENCES buchhaltung_konto_typen(id) ON DELETE SET NULL,
kontonummer TEXT NOT NULL,
bezeichnung TEXT NOT NULL,
budget_betrag NUMERIC(12,2) NOT NULL DEFAULT 0,
notizen TEXT,
aktiv BOOLEAN NOT NULL DEFAULT TRUE,
erstellt_von UUID,
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (haushaltsjahr_id, kontonummer)
);
-- 5. Transactions
CREATE TABLE IF NOT EXISTS buchhaltung_transaktionen (
id SERIAL PRIMARY KEY,
haushaltsjahr_id INT NOT NULL REFERENCES buchhaltung_haushaltsjahre(id) ON DELETE RESTRICT,
konto_id INT REFERENCES buchhaltung_konten(id) ON DELETE SET NULL,
bankkonto_id INT REFERENCES buchhaltung_bankkonten(id) ON DELETE SET NULL,
laufende_nummer INT,
typ TEXT NOT NULL CHECK (typ IN ('einnahme', 'ausgabe')),
betrag NUMERIC(12,2) NOT NULL,
datum DATE NOT NULL,
buchungsdatum DATE,
beschreibung TEXT,
empfaenger_auftraggeber TEXT,
verwendungszweck TEXT,
beleg_nr TEXT,
status TEXT NOT NULL CHECK (status IN ('entwurf', 'gebucht', 'freigegeben', 'storniert')) DEFAULT 'entwurf',
bestellung_id INT REFERENCES bestellungen(id) ON DELETE SET NULL,
erstellt_von UUID,
gebucht_von UUID,
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 6. Receipts / attachments
CREATE TABLE IF NOT EXISTS buchhaltung_belege (
id SERIAL PRIMARY KEY,
transaktion_id INT NOT NULL REFERENCES buchhaltung_transaktionen(id) ON DELETE CASCADE,
dateiname TEXT NOT NULL,
original_name TEXT NOT NULL,
dateityp TEXT NOT NULL,
dateigroesse INT NOT NULL,
erstellt_von UUID,
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 7. Approvals
CREATE TABLE IF NOT EXISTS buchhaltung_freigaben (
id SERIAL PRIMARY KEY,
transaktion_id INT NOT NULL REFERENCES buchhaltung_transaktionen(id) ON DELETE CASCADE,
status TEXT NOT NULL CHECK (status IN ('ausstehend', 'genehmigt', 'abgelehnt')) DEFAULT 'ausstehend',
kommentar TEXT,
freigegeben_von UUID REFERENCES users(id) ON DELETE SET NULL,
freigegeben_am TIMESTAMPTZ,
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 8. Recurring bookings
CREATE TABLE IF NOT EXISTS buchhaltung_wiederkehrend (
id SERIAL PRIMARY KEY,
bezeichnung TEXT NOT NULL,
konto_id INT REFERENCES buchhaltung_konten(id) ON DELETE SET NULL,
bankkonto_id INT REFERENCES buchhaltung_bankkonten(id) ON DELETE SET NULL,
typ TEXT NOT NULL CHECK (typ IN ('einnahme', 'ausgabe')),
betrag NUMERIC(12,2) NOT NULL,
beschreibung TEXT,
empfaenger_auftraggeber TEXT,
intervall TEXT NOT NULL CHECK (intervall IN ('monatlich', 'quartalsweise', 'halbjaehrlich', 'jaehrlich')),
naechste_ausfuehrung DATE NOT NULL,
aktiv BOOLEAN NOT NULL DEFAULT TRUE,
erstellt_von UUID,
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 9. Audit log
CREATE TABLE IF NOT EXISTS buchhaltung_audit (
id SERIAL PRIMARY KEY,
transaktion_id INT REFERENCES buchhaltung_transaktionen(id) ON DELETE SET NULL,
aktion TEXT NOT NULL,
details JSONB,
erstellt_von UUID REFERENCES users(id) ON DELETE SET NULL,
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 10. Settings
CREATE TABLE IF NOT EXISTS buchhaltung_einstellungen (
key TEXT PRIMARY KEY,
value JSONB NOT NULL,
aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 11. Budget planning (Phase 7 prep)
CREATE TABLE IF NOT EXISTS buchhaltung_planung (
id SERIAL PRIMARY KEY,
haushaltsjahr_id INT NOT NULL REFERENCES buchhaltung_haushaltsjahre(id) ON DELETE CASCADE,
bezeichnung TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('entwurf', 'aktiv', 'abgeschlossen')) DEFAULT 'entwurf',
erstellt_von UUID,
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 12. Planning line items
CREATE TABLE IF NOT EXISTS buchhaltung_planpositionen (
id SERIAL PRIMARY KEY,
planung_id INT NOT NULL REFERENCES buchhaltung_planung(id) ON DELETE CASCADE,
konto_id INT REFERENCES buchhaltung_konten(id) ON DELETE SET NULL,
bezeichnung TEXT NOT NULL,
plan_betrag NUMERIC(12,2) NOT NULL DEFAULT 0,
notizen TEXT,
sort_order INT NOT NULL DEFAULT 0
);
-- =============================================================================
-- Indexes
-- =============================================================================
CREATE INDEX IF NOT EXISTS idx_buch_trans_haushaltsjahr ON buchhaltung_transaktionen(haushaltsjahr_id);
CREATE INDEX IF NOT EXISTS idx_buch_trans_konto ON buchhaltung_transaktionen(konto_id);
CREATE INDEX IF NOT EXISTS idx_buch_trans_bankkonto ON buchhaltung_transaktionen(bankkonto_id);
CREATE INDEX IF NOT EXISTS idx_buch_trans_status ON buchhaltung_transaktionen(status);
CREATE INDEX IF NOT EXISTS idx_buch_trans_datum ON buchhaltung_transaktionen(datum);
CREATE INDEX IF NOT EXISTS idx_buch_trans_bestellung ON buchhaltung_transaktionen(bestellung_id);
CREATE INDEX IF NOT EXISTS idx_buch_konten_haushaltsjahr ON buchhaltung_konten(haushaltsjahr_id);
-- =============================================================================
-- Triggers — aktualisiert_am (reuse existing update_aktualisiert_am function)
-- =============================================================================
CREATE TRIGGER trg_buch_bankkonten_updated
BEFORE UPDATE ON buchhaltung_bankkonten
FOR EACH ROW EXECUTE FUNCTION update_aktualisiert_am();
CREATE TRIGGER trg_buch_haushaltsjahre_updated
BEFORE UPDATE ON buchhaltung_haushaltsjahre
FOR EACH ROW EXECUTE FUNCTION update_aktualisiert_am();
CREATE TRIGGER trg_buch_konten_updated
BEFORE UPDATE ON buchhaltung_konten
FOR EACH ROW EXECUTE FUNCTION update_aktualisiert_am();
CREATE TRIGGER trg_buch_transaktionen_updated
BEFORE UPDATE ON buchhaltung_transaktionen
FOR EACH ROW EXECUTE FUNCTION update_aktualisiert_am();
CREATE TRIGGER trg_buch_wiederkehrend_updated
BEFORE UPDATE ON buchhaltung_wiederkehrend
FOR EACH ROW EXECUTE FUNCTION update_aktualisiert_am();
CREATE TRIGGER trg_buch_planung_updated
BEFORE UPDATE ON buchhaltung_planung
FOR EACH ROW EXECUTE FUNCTION update_aktualisiert_am();
-- =============================================================================
-- Trigger — sequential laufende_nummer per fiscal year
-- =============================================================================
CREATE OR REPLACE FUNCTION buchhaltung_assign_laufende_nummer()
RETURNS TRIGGER AS $$
DECLARE
next_num INT;
BEGIN
-- Assign laufende_nummer when status becomes 'gebucht' and not yet assigned
IF NEW.status = 'gebucht' AND NEW.laufende_nummer IS NULL THEN
IF TG_OP = 'INSERT' OR (TG_OP = 'UPDATE' AND (OLD.status IS NULL OR OLD.status != 'gebucht')) THEN
SELECT COALESCE(MAX(laufende_nummer), 0) + 1
INTO next_num
FROM buchhaltung_transaktionen
WHERE haushaltsjahr_id = NEW.haushaltsjahr_id;
NEW.laufende_nummer := next_num;
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_buch_transaktionen_laufende_nummer
BEFORE INSERT OR UPDATE ON buchhaltung_transaktionen
FOR EACH ROW EXECUTE FUNCTION buchhaltung_assign_laufende_nummer();

View File

@@ -0,0 +1,33 @@
-- =============================================================================
-- Migration 076: Buchhaltung Permissions
-- =============================================================================
-- Feature group
INSERT INTO feature_groups (id, label, sort_order)
VALUES ('buchhaltung', 'Buchhaltung', 13)
ON CONFLICT (id) DO NOTHING;
-- 8 permissions
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('buchhaltung:view', 'buchhaltung', 'Ansehen', 'Buchhaltungsdaten einsehen', 1),
('buchhaltung:create', 'buchhaltung', 'Erstellen', 'Transaktionen anlegen', 2),
('buchhaltung:edit', 'buchhaltung', 'Bearbeiten', 'Transaktionen bearbeiten', 3),
('buchhaltung:delete', 'buchhaltung', 'Löschen', 'Transaktionen löschen', 4),
('buchhaltung:manage_accounts', 'buchhaltung', 'Konten verwalten', 'Konten und Bankkonten verwalten', 5),
('buchhaltung:manage_settings', 'buchhaltung', 'Einstellungen', 'Buchhaltungs-Einstellungen verwalten', 6),
('buchhaltung:export', 'buchhaltung', 'Exportieren', 'Daten exportieren (CSV/PDF)', 7),
('buchhaltung:widget', 'buchhaltung', 'Widget', 'Dashboard-Widget anzeigen', 8)
ON CONFLICT (id) DO NOTHING;
-- Grant all permissions to dashboard_kommando
INSERT INTO group_permissions (authentik_group, permission_id)
SELECT 'dashboard_kommando', id FROM permissions WHERE feature_group_id = 'buchhaltung'
ON CONFLICT DO NOTHING;
-- Grant view, create, edit, widget to dashboard_chargen
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
('dashboard_chargen', 'buchhaltung:view'),
('dashboard_chargen', 'buchhaltung:create'),
('dashboard_chargen', 'buchhaltung:edit'),
('dashboard_chargen', 'buchhaltung:widget')
ON CONFLICT DO NOTHING;

View File

@@ -132,4 +132,42 @@ const issueOptions: any = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const uploadIssue: any = multer(issueOptions);
export { UPLOAD_DIR, THUMBNAIL_DIR, WARTUNG_DIR, ISSUE_DIR };
// ── Buchhaltung uploads (accounting receipts / Belege) ───────────────────────
const BUCHHALTUNG_DIR = path.join(APP_ROOT, 'uploads', 'buchhaltung');
try {
if (!fs.existsSync(BUCHHALTUNG_DIR)) {
fs.mkdirSync(BUCHHALTUNG_DIR, { recursive: true });
}
} catch (err) {
logger.warn(`Could not create buchhaltung upload directory`, { err });
}
const buchhaltungStorage = multer.diskStorage({
destination(_req: any, _file: any, cb: any) {
cb(null, BUCHHALTUNG_DIR);
},
filename(_req: any, file: any, cb: any) {
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
const ext = path.extname(file.originalname);
cb(null, `${uniqueSuffix}${ext}`);
},
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const buchhaltungOptions: any = {
storage: buchhaltungStorage,
fileFilter(_req: any, file: any, cb: any) {
if (ALLOWED_TYPES.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error(`Dateityp ${file.mimetype} ist nicht erlaubt.`));
}
},
limits: { fileSize: 20 * 1024 * 1024 },
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const uploadBuchhaltung: any = multer(buchhaltungOptions);
export { UPLOAD_DIR, THUMBNAIL_DIR, WARTUNG_DIR, ISSUE_DIR, BUCHHALTUNG_DIR };

View File

@@ -0,0 +1,53 @@
import { Router } from 'express';
import buchhaltungController from '../controllers/buchhaltung.controller';
import { authenticate } from '../middleware/auth.middleware';
import { requirePermission } from '../middleware/rbac.middleware';
import { uploadBuchhaltung } from '../middleware/upload';
const router = Router();
// ── Stats ─────────────────────────────────────────────────────────────────────
router.get('/stats', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getStats.bind(buchhaltungController));
// ── Haushaltsjahre ─────────────────────────────────────────────────────────────
router.get('/haushaltsjahre', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.listHaushaltsjahre.bind(buchhaltungController));
router.post('/haushaltsjahre', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.createHaushaltsjahr.bind(buchhaltungController));
router.patch('/haushaltsjahre/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.updateHaushaltsjahr.bind(buchhaltungController));
router.post('/haushaltsjahre/:id/close', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.closeHaushaltsjahr.bind(buchhaltungController));
// ── Konto-Typen ───────────────────────────────────────────────────────────────
router.get('/konto-typen', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.listKontoTypen.bind(buchhaltungController));
// ── Bankkonten ────────────────────────────────────────────────────────────────
router.get('/bankkonten', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.listBankkonten.bind(buchhaltungController));
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));
// ── Konten ────────────────────────────────────────────────────────────────────
router.get('/konten', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.listKonten.bind(buchhaltungController));
router.post('/konten', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.createKonto.bind(buchhaltungController));
router.patch('/konten/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.updateKonto.bind(buchhaltungController));
router.delete('/konten/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.deleteKonto.bind(buchhaltungController));
router.get('/konten/:id/budget', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getKontoBudget.bind(buchhaltungController));
// ── Belege (specific routes BEFORE /:id to prevent param capture) ─────────────
router.delete('/belege/:id', authenticate, requirePermission('buchhaltung:delete'), buchhaltungController.deleteBeleg.bind(buchhaltungController));
// ── Einstellungen ─────────────────────────────────────────────────────────────
router.get('/einstellungen', authenticate, requirePermission('buchhaltung:manage_settings'), buchhaltungController.getEinstellungen.bind(buchhaltungController));
router.put('/einstellungen', authenticate, requirePermission('buchhaltung:manage_settings'), buchhaltungController.setEinstellungen.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));
// ── Transaktionen (detail — /:id must come last) ──────────────────────────────
router.get('/:id', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getTransaktion.bind(buchhaltungController));
router.patch('/:id', authenticate, requirePermission('buchhaltung:edit'), buchhaltungController.updateTransaktion.bind(buchhaltungController));
router.delete('/:id', authenticate, requirePermission('buchhaltung:delete'), buchhaltungController.deleteTransaktion.bind(buchhaltungController));
router.post('/:id/buchen', authenticate, requirePermission('buchhaltung:edit'), buchhaltungController.buchenTransaktion.bind(buchhaltungController));
router.post('/:id/storno', authenticate, requirePermission('buchhaltung:edit'), buchhaltungController.stornoTransaktion.bind(buchhaltungController));
router.post('/:id/belege', authenticate, requirePermission('buchhaltung:create'), uploadBuchhaltung.single('datei'), buchhaltungController.uploadBeleg.bind(buchhaltungController));
export default router;

View File

@@ -0,0 +1,857 @@
// =============================================================================
// Buchhaltung (Accounting) Service
// =============================================================================
import pool from '../config/database';
import logger from '../utils/logger';
import fs from 'fs';
// ---------------------------------------------------------------------------
// Haushaltsjahre (Fiscal Years)
// ---------------------------------------------------------------------------
async function getAllHaushaltsjahre() {
try {
const result = await pool.query(
`SELECT * FROM buchhaltung_haushaltsjahre ORDER BY jahr DESC`
);
return result.rows;
} catch (error) {
logger.error('BuchhaltungService.getAllHaushaltsjahre failed', { error });
throw new Error('Haushaltsjahre konnten nicht geladen werden');
}
}
async function getHaushaltsjahrById(id: number) {
try {
const result = await pool.query(
`SELECT * FROM buchhaltung_haushaltsjahre WHERE id = $1`,
[id]
);
return result.rows[0] || null;
} catch (error) {
logger.error('BuchhaltungService.getHaushaltsjahrById failed', { error, id });
throw new Error('Haushaltsjahr konnte nicht geladen werden');
}
}
async function getCurrentHaushaltsjahr() {
try {
const result = await pool.query(
`SELECT * FROM buchhaltung_haushaltsjahre
WHERE abgeschlossen = FALSE
ORDER BY jahr DESC
LIMIT 1`
);
return result.rows[0] || null;
} catch (error) {
logger.error('BuchhaltungService.getCurrentHaushaltsjahr failed', { error });
throw new Error('Aktuelles Haushaltsjahr konnte nicht geladen werden');
}
}
async function createHaushaltsjahr(
data: { jahr: number; bezeichnung: string; beginn: string; ende: string },
userId: string
) {
try {
const result = await pool.query(
`INSERT INTO buchhaltung_haushaltsjahre (jahr, bezeichnung, beginn, ende, erstellt_von)
VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[data.jahr, data.bezeichnung, data.beginn, data.ende, userId]
);
return result.rows[0];
} catch (error) {
logger.error('BuchhaltungService.createHaushaltsjahr failed', { error });
throw new Error('Haushaltsjahr konnte nicht erstellt werden');
}
}
async function updateHaushaltsjahr(
id: number,
data: { bezeichnung?: string; beginn?: string; ende?: string }
) {
try {
const fields: string[] = [];
const values: unknown[] = [];
let idx = 1;
if (data.bezeichnung !== undefined) { fields.push(`bezeichnung = $${idx++}`); values.push(data.bezeichnung); }
if (data.beginn !== undefined) { fields.push(`beginn = $${idx++}`); values.push(data.beginn); }
if (data.ende !== undefined) { fields.push(`ende = $${idx++}`); values.push(data.ende); }
if (fields.length === 0) throw new Error('Keine Felder zum Aktualisieren');
values.push(id);
const result = await pool.query(
`UPDATE buchhaltung_haushaltsjahre SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`,
values
);
return result.rows[0] || null;
} catch (error) {
logger.error('BuchhaltungService.updateHaushaltsjahr failed', { error, id });
throw new Error('Haushaltsjahr konnte nicht aktualisiert werden');
}
}
async function closeHaushaltsjahr(id: number) {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Check no open entwurf transactions
const check = await client.query(
`SELECT COUNT(*) FROM buchhaltung_transaktionen WHERE haushaltsjahr_id = $1 AND status = 'entwurf'`,
[id]
);
if (parseInt(check.rows[0].count, 10) > 0) {
throw new Error('Es gibt noch offene Entwürfe in diesem Haushaltsjahr');
}
const result = await client.query(
`UPDATE buchhaltung_haushaltsjahre SET abgeschlossen = TRUE WHERE id = $1 RETURNING *`,
[id]
);
await client.query('COMMIT');
return result.rows[0] || null;
} catch (error) {
await client.query('ROLLBACK');
logger.error('BuchhaltungService.closeHaushaltsjahr failed', { error, id });
throw error;
} finally {
client.release();
}
}
// ---------------------------------------------------------------------------
// Konto-Typen (Account Types — static lookup)
// ---------------------------------------------------------------------------
async function getAllKontoTypen() {
try {
const result = await pool.query(
`SELECT * FROM buchhaltung_konto_typen ORDER BY sort_order`
);
return result.rows;
} catch (error) {
logger.error('BuchhaltungService.getAllKontoTypen failed', { error });
throw new Error('Kontotypen konnten nicht geladen werden');
}
}
// ---------------------------------------------------------------------------
// Bankkonten (Bank Accounts)
// ---------------------------------------------------------------------------
async function getAllBankkonten() {
try {
const result = await pool.query(
`SELECT * FROM buchhaltung_bankkonten WHERE aktiv = TRUE ORDER BY ist_standard DESC, bezeichnung`
);
return result.rows;
} catch (error) {
logger.error('BuchhaltungService.getAllBankkonten failed', { error });
throw new Error('Bankkonten konnten nicht geladen werden');
}
}
async function getBankkontoById(id: number) {
try {
const result = await pool.query(`SELECT * FROM buchhaltung_bankkonten WHERE id = $1`, [id]);
return result.rows[0] || null;
} catch (error) {
logger.error('BuchhaltungService.getBankkontoById failed', { error, id });
throw new Error('Bankkonto konnte nicht geladen werden');
}
}
async function createBankkonto(
data: { bezeichnung: string; iban?: string; bic?: string; institut?: string; ist_standard?: boolean },
userId: string
) {
const client = await pool.connect();
try {
await client.query('BEGIN');
if (data.ist_standard) {
await client.query(`UPDATE buchhaltung_bankkonten SET ist_standard = FALSE`);
}
const result = await client.query(
`INSERT INTO buchhaltung_bankkonten (bezeichnung, iban, bic, institut, ist_standard, erstellt_von)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[data.bezeichnung, data.iban || null, data.bic || null, data.institut || null, data.ist_standard || false, userId]
);
await client.query('COMMIT');
return result.rows[0];
} catch (error) {
await client.query('ROLLBACK');
logger.error('BuchhaltungService.createBankkonto failed', { error });
throw new Error('Bankkonto konnte nicht erstellt werden');
} finally {
client.release();
}
}
async function updateBankkonto(
id: number,
data: { bezeichnung?: string; iban?: string; bic?: string; institut?: string; ist_standard?: boolean }
) {
const client = await pool.connect();
try {
await client.query('BEGIN');
if (data.ist_standard) {
await client.query(`UPDATE buchhaltung_bankkonten SET ist_standard = FALSE WHERE id != $1`, [id]);
}
const fields: string[] = [];
const values: unknown[] = [];
let idx = 1;
if (data.bezeichnung !== undefined) { fields.push(`bezeichnung = $${idx++}`); values.push(data.bezeichnung); }
if (data.iban !== undefined) { fields.push(`iban = $${idx++}`); values.push(data.iban || null); }
if (data.bic !== undefined) { fields.push(`bic = $${idx++}`); values.push(data.bic || null); }
if (data.institut !== undefined) { fields.push(`institut = $${idx++}`); values.push(data.institut || null); }
if (data.ist_standard !== undefined) { fields.push(`ist_standard = $${idx++}`); values.push(data.ist_standard); }
if (fields.length > 0) {
values.push(id);
await client.query(
`UPDATE buchhaltung_bankkonten SET ${fields.join(', ')} WHERE id = $${idx}`,
values
);
}
const result = await client.query(`SELECT * FROM buchhaltung_bankkonten WHERE id = $1`, [id]);
await client.query('COMMIT');
return result.rows[0] || null;
} catch (error) {
await client.query('ROLLBACK');
logger.error('BuchhaltungService.updateBankkonto failed', { error, id });
throw new Error('Bankkonto konnte nicht aktualisiert werden');
} finally {
client.release();
}
}
async function deactivateBankkonto(id: number) {
try {
const result = await pool.query(
`UPDATE buchhaltung_bankkonten SET aktiv = FALSE WHERE id = $1 RETURNING *`,
[id]
);
return result.rows[0] || null;
} catch (error) {
logger.error('BuchhaltungService.deactivateBankkonto failed', { error, id });
throw new Error('Bankkonto konnte nicht deaktiviert werden');
}
}
// ---------------------------------------------------------------------------
// Konten (Budget Accounts)
// ---------------------------------------------------------------------------
async function getAllKonten(haushaltsjahrId: number) {
try {
const result = await pool.query(
`SELECT k.*, kt.bezeichnung as konto_typ_bezeichnung, kt.art as konto_typ_art
FROM buchhaltung_konten k
LEFT JOIN buchhaltung_konto_typen kt ON k.konto_typ_id = kt.id
WHERE k.haushaltsjahr_id = $1 AND k.aktiv = TRUE
ORDER BY k.kontonummer`,
[haushaltsjahrId]
);
return result.rows;
} catch (error) {
logger.error('BuchhaltungService.getAllKonten failed', { error, haushaltsjahrId });
throw new Error('Konten konnten nicht geladen werden');
}
}
async function getKontoById(id: number) {
try {
const result = await pool.query(
`SELECT k.*, kt.bezeichnung as konto_typ_bezeichnung, kt.art as konto_typ_art
FROM buchhaltung_konten k
LEFT JOIN buchhaltung_konto_typen kt ON k.konto_typ_id = kt.id
WHERE k.id = $1`,
[id]
);
return result.rows[0] || null;
} catch (error) {
logger.error('BuchhaltungService.getKontoById failed', { error, id });
throw new Error('Konto konnte nicht geladen werden');
}
}
async function createKonto(
data: { haushaltsjahr_id: number; konto_typ_id?: number; kontonummer: string; bezeichnung: string; budget_betrag?: number; notizen?: string },
userId: string
) {
try {
const result = await pool.query(
`INSERT INTO buchhaltung_konten (haushaltsjahr_id, konto_typ_id, kontonummer, bezeichnung, budget_betrag, notizen, erstellt_von)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *`,
[data.haushaltsjahr_id, data.konto_typ_id || null, data.kontonummer, data.bezeichnung, data.budget_betrag || 0, data.notizen || null, userId]
);
return result.rows[0];
} catch (error) {
logger.error('BuchhaltungService.createKonto failed', { error });
throw new Error('Konto konnte nicht erstellt werden');
}
}
async function updateKonto(
id: number,
data: { konto_typ_id?: number; kontonummer?: string; bezeichnung?: string; budget_betrag?: number; notizen?: string }
) {
try {
const fields: string[] = [];
const values: unknown[] = [];
let idx = 1;
if (data.konto_typ_id !== undefined) { fields.push(`konto_typ_id = $${idx++}`); values.push(data.konto_typ_id || null); }
if (data.kontonummer !== undefined) { fields.push(`kontonummer = $${idx++}`); values.push(data.kontonummer); }
if (data.bezeichnung !== undefined) { fields.push(`bezeichnung = $${idx++}`); values.push(data.bezeichnung); }
if (data.budget_betrag !== undefined){ fields.push(`budget_betrag = $${idx++}`); values.push(data.budget_betrag); }
if (data.notizen !== undefined) { fields.push(`notizen = $${idx++}`); values.push(data.notizen || null); }
if (fields.length === 0) throw new Error('Keine Felder zum Aktualisieren');
values.push(id);
const result = await pool.query(
`UPDATE buchhaltung_konten SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`,
values
);
return result.rows[0] || null;
} catch (error) {
logger.error('BuchhaltungService.updateKonto failed', { error, id });
throw new Error('Konto konnte nicht aktualisiert werden');
}
}
async function deleteKonto(id: number) {
try {
// Soft delete
await pool.query(`UPDATE buchhaltung_konten SET aktiv = FALSE WHERE id = $1`, [id]);
} catch (error) {
logger.error('BuchhaltungService.deleteKonto failed', { error, id });
throw new Error('Konto konnte nicht gelöscht werden');
}
}
async function getBudgetUtilisation(id: number) {
try {
const result = await pool.query(
`SELECT k.*,
kt.bezeichnung as konto_typ_bezeichnung,
kt.art as konto_typ_art,
COALESCE(SUM(CASE WHEN t.typ = 'ausgabe' AND t.status IN ('gebucht','freigegeben') THEN t.betrag ELSE 0 END), 0) as gebucht_betrag,
COALESCE(SUM(CASE WHEN t.typ = 'ausgabe' AND t.status = 'entwurf' THEN t.betrag ELSE 0 END), 0) as ausstehend_betrag
FROM buchhaltung_konten k
LEFT JOIN buchhaltung_konto_typen kt ON k.konto_typ_id = kt.id
LEFT JOIN buchhaltung_transaktionen t ON t.konto_id = k.id
WHERE k.id = $1
GROUP BY k.id, kt.bezeichnung, kt.art`,
[id]
);
if (!result.rows[0]) return null;
const row = result.rows[0];
const gebucht = parseFloat(row.gebucht_betrag);
const ausstehend = parseFloat(row.ausstehend_betrag);
const budget = parseFloat(row.budget_betrag);
return {
...row,
gebucht_betrag: gebucht,
ausstehend_betrag: ausstehend,
verfuegbar_betrag: budget - gebucht - ausstehend,
auslastung_prozent: budget > 0 ? Math.round((gebucht / budget) * 100) : 0,
};
} catch (error) {
logger.error('BuchhaltungService.getBudgetUtilisation failed', { error, id });
throw new Error('Budgetauslastung konnte nicht geladen werden');
}
}
// ---------------------------------------------------------------------------
// Transaktionen (Transactions)
// ---------------------------------------------------------------------------
async function listTransaktionen(filters: {
haushaltsjahr_id?: number;
konto_id?: number;
status?: string;
typ?: string;
datum_von?: string;
datum_bis?: string;
search?: string;
}) {
try {
const conditions: string[] = [];
const values: unknown[] = [];
let idx = 1;
if (filters.haushaltsjahr_id) { conditions.push(`t.haushaltsjahr_id = $${idx++}`); values.push(filters.haushaltsjahr_id); }
if (filters.konto_id) { conditions.push(`t.konto_id = $${idx++}`); values.push(filters.konto_id); }
if (filters.status) { conditions.push(`t.status = $${idx++}`); values.push(filters.status); }
if (filters.typ) { conditions.push(`t.typ = $${idx++}`); values.push(filters.typ); }
if (filters.datum_von) { conditions.push(`t.datum >= $${idx++}`); values.push(filters.datum_von); }
if (filters.datum_bis) { conditions.push(`t.datum <= $${idx++}`); values.push(filters.datum_bis); }
if (filters.search) {
conditions.push(`(t.beschreibung ILIKE $${idx} OR t.empfaenger_auftraggeber ILIKE $${idx} OR t.beleg_nr ILIKE $${idx})`);
values.push(`%${filters.search}%`);
idx++;
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const result = await pool.query(
`SELECT t.*,
k.bezeichnung as konto_bezeichnung,
k.kontonummer as konto_kontonummer,
bk.bezeichnung as bankkonto_bezeichnung
FROM buchhaltung_transaktionen t
LEFT JOIN buchhaltung_konten k ON t.konto_id = k.id
LEFT JOIN buchhaltung_bankkonten bk ON t.bankkonto_id = bk.id
${where}
ORDER BY t.datum DESC, t.id DESC`,
values
);
return result.rows;
} catch (error) {
logger.error('BuchhaltungService.listTransaktionen failed', { error });
throw new Error('Transaktionen konnten nicht geladen werden');
}
}
async function getTransaktionById(id: number) {
try {
const txResult = await pool.query(
`SELECT t.*,
k.bezeichnung as konto_bezeichnung,
k.kontonummer as konto_kontonummer,
bk.bezeichnung as bankkonto_bezeichnung
FROM buchhaltung_transaktionen t
LEFT JOIN buchhaltung_konten k ON t.konto_id = k.id
LEFT JOIN buchhaltung_bankkonten bk ON t.bankkonto_id = bk.id
WHERE t.id = $1`,
[id]
);
if (!txResult.rows[0]) return null;
const tx = txResult.rows[0];
const belegeResult = await pool.query(
`SELECT * FROM buchhaltung_belege WHERE transaktion_id = $1 ORDER BY erstellt_am`,
[id]
);
tx.belege = belegeResult.rows;
return tx;
} catch (error) {
logger.error('BuchhaltungService.getTransaktionById failed', { error, id });
throw new Error('Transaktion konnte nicht geladen werden');
}
}
async function createTransaktion(
data: {
haushaltsjahr_id: number;
konto_id?: number | null;
bankkonto_id?: number | null;
typ: 'einnahme' | 'ausgabe';
betrag: number;
datum: string;
beschreibung?: string;
empfaenger_auftraggeber?: string;
verwendungszweck?: string;
beleg_nr?: string;
},
userId: string
) {
try {
const result = await pool.query(
`INSERT INTO buchhaltung_transaktionen
(haushaltsjahr_id, konto_id, bankkonto_id, typ, betrag, datum, beschreibung, empfaenger_auftraggeber, verwendungszweck, beleg_nr, erstellt_von)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING *`,
[
data.haushaltsjahr_id,
data.konto_id || null,
data.bankkonto_id || null,
data.typ,
data.betrag,
data.datum,
data.beschreibung || null,
data.empfaenger_auftraggeber || null,
data.verwendungszweck || null,
data.beleg_nr || null,
userId,
]
);
await logAudit(result.rows[0].id, 'erstellt', { betrag: data.betrag }, userId);
return result.rows[0];
} catch (error) {
logger.error('BuchhaltungService.createTransaktion failed', { error });
throw new Error('Transaktion konnte nicht erstellt werden');
}
}
async function updateTransaktion(
id: number,
data: {
konto_id?: number | null;
bankkonto_id?: number | null;
betrag?: number;
datum?: string;
beschreibung?: string;
empfaenger_auftraggeber?: string;
verwendungszweck?: string;
beleg_nr?: string;
},
userId: string
) {
try {
const fields: string[] = [];
const values: unknown[] = [];
let idx = 1;
if (data.konto_id !== undefined) { fields.push(`konto_id = $${idx++}`); values.push(data.konto_id); }
if (data.bankkonto_id !== undefined) { fields.push(`bankkonto_id = $${idx++}`); values.push(data.bankkonto_id); }
if (data.betrag !== undefined) { fields.push(`betrag = $${idx++}`); values.push(data.betrag); }
if (data.datum !== undefined) { fields.push(`datum = $${idx++}`); values.push(data.datum); }
if (data.beschreibung !== undefined) { fields.push(`beschreibung = $${idx++}`); values.push(data.beschreibung || null); }
if (data.empfaenger_auftraggeber !== undefined){ fields.push(`empfaenger_auftraggeber = $${idx++}`); values.push(data.empfaenger_auftraggeber || null); }
if (data.verwendungszweck !== undefined) { fields.push(`verwendungszweck = $${idx++}`); values.push(data.verwendungszweck || null); }
if (data.beleg_nr !== undefined) { fields.push(`beleg_nr = $${idx++}`); values.push(data.beleg_nr || null); }
if (fields.length === 0) throw new Error('Keine Felder zum Aktualisieren');
values.push(id);
const result = await pool.query(
`UPDATE buchhaltung_transaktionen SET ${fields.join(', ')} WHERE id = $${idx} AND status = 'entwurf' RETURNING *`,
values
);
if (result.rows[0]) await logAudit(id, 'aktualisiert', data, userId);
return result.rows[0] || null;
} catch (error) {
logger.error('BuchhaltungService.updateTransaktion failed', { error, id });
throw new Error('Transaktion konnte nicht aktualisiert werden');
}
}
async function bookTransaktion(id: number, userId: string) {
try {
const result = await pool.query(
`UPDATE buchhaltung_transaktionen
SET status = 'gebucht', buchungsdatum = NOW(), gebucht_von = $2
WHERE id = $1 AND status = 'entwurf'
RETURNING *`,
[id, userId]
);
if (result.rows[0]) await logAudit(id, 'gebucht', {}, userId);
return result.rows[0] || null;
} catch (error) {
logger.error('BuchhaltungService.bookTransaktion failed', { error, id });
throw new Error('Transaktion konnte nicht gebucht werden');
}
}
async function stornoTransaktion(id: number, userId: string) {
try {
const result = await pool.query(
`UPDATE buchhaltung_transaktionen
SET status = 'storniert'
WHERE id = $1 AND status IN ('gebucht', 'freigegeben')
RETURNING *`,
[id, userId]
);
if (result.rows[0]) await logAudit(id, 'storniert', {}, userId);
return result.rows[0] || null;
} catch (error) {
logger.error('BuchhaltungService.stornoTransaktion failed', { error, id });
throw new Error('Transaktion konnte nicht storniert werden');
}
}
async function deleteTransaktion(id: number) {
try {
// Only entwurf can be deleted
const result = await pool.query(
`DELETE FROM buchhaltung_transaktionen WHERE id = $1 AND status = 'entwurf' RETURNING id`,
[id]
);
return result.rows.length > 0;
} catch (error) {
logger.error('BuchhaltungService.deleteTransaktion failed', { error, id });
throw new Error('Transaktion konnte nicht gelöscht werden');
}
}
// ---------------------------------------------------------------------------
// Belege (Receipts)
// ---------------------------------------------------------------------------
async function getBelegeByTransaktion(transaktionId: number) {
try {
const result = await pool.query(
`SELECT * FROM buchhaltung_belege WHERE transaktion_id = $1 ORDER BY erstellt_am`,
[transaktionId]
);
return result.rows;
} catch (error) {
logger.error('BuchhaltungService.getBelegeByTransaktion failed', { error });
throw new Error('Belege konnten nicht geladen werden');
}
}
async function uploadBeleg(
transaktionId: number,
file: { filename: string; originalname: string; mimetype: string; size: number },
userId: string
) {
try {
const result = await pool.query(
`INSERT INTO buchhaltung_belege (transaktion_id, dateiname, original_name, dateityp, dateigroesse, erstellt_von)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[transaktionId, file.filename, file.originalname, file.mimetype, file.size, userId]
);
return result.rows[0];
} catch (error) {
logger.error('BuchhaltungService.uploadBeleg failed', { error });
throw new Error('Beleg konnte nicht hochgeladen werden');
}
}
async function deleteBeleg(id: number) {
try {
const result = await pool.query(
`DELETE FROM buchhaltung_belege WHERE id = $1 RETURNING *`,
[id]
);
if (result.rows[0]) {
// Delete file from disk
const filePath = process.env.NODE_ENV === 'production'
? `/app/uploads/buchhaltung/${result.rows[0].dateiname}`
: `${process.cwd()}/uploads/buchhaltung/${result.rows[0].dateiname}`;
try { fs.unlinkSync(filePath); } catch { /* ignore */ }
}
return result.rows.length > 0;
} catch (error) {
logger.error('BuchhaltungService.deleteBeleg failed', { error, id });
throw new Error('Beleg konnte nicht gelöscht werden');
}
}
// ---------------------------------------------------------------------------
// Freigaben (Approvals)
// ---------------------------------------------------------------------------
async function getFreigabenByTransaktion(transaktionId: number) {
try {
const result = await pool.query(
`SELECT * FROM buchhaltung_freigaben WHERE transaktion_id = $1 ORDER BY erstellt_am DESC`,
[transaktionId]
);
return result.rows;
} catch (error) {
logger.error('BuchhaltungService.getFreigabenByTransaktion failed', { error });
throw new Error('Freigaben konnten nicht geladen werden');
}
}
async function createFreigabe(transaktionId: number, userId: string) {
try {
const result = await pool.query(
`INSERT INTO buchhaltung_freigaben (transaktion_id) VALUES ($1) RETURNING *`,
[transaktionId]
);
await logAudit(transaktionId, 'freigabe_angefragt', {}, userId);
return result.rows[0];
} catch (error) {
logger.error('BuchhaltungService.createFreigabe failed', { error });
throw new Error('Freigabe konnte nicht erstellt werden');
}
}
async function approveFreigabe(id: number, kommentar: string | undefined, userId: string) {
const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await client.query(
`UPDATE buchhaltung_freigaben SET status = 'genehmigt', kommentar = $2, freigegeben_von = $3, freigegeben_am = NOW() WHERE id = $1 RETURNING *`,
[id, kommentar || null, userId]
);
if (result.rows[0]) {
await client.query(
`UPDATE buchhaltung_transaktionen SET status = 'freigegeben' WHERE id = $1`,
[result.rows[0].transaktion_id]
);
await logAudit(result.rows[0].transaktion_id, 'freigegeben', { kommentar }, userId);
}
await client.query('COMMIT');
return result.rows[0] || null;
} catch (error) {
await client.query('ROLLBACK');
logger.error('BuchhaltungService.approveFreigabe failed', { error, id });
throw new Error('Freigabe konnte nicht genehmigt werden');
} finally {
client.release();
}
}
async function rejectFreigabe(id: number, kommentar: string | undefined, userId: string) {
try {
const result = await pool.query(
`UPDATE buchhaltung_freigaben SET status = 'abgelehnt', kommentar = $2, freigegeben_von = $3, freigegeben_am = NOW() WHERE id = $1 RETURNING *`,
[id, kommentar || null, userId]
);
if (result.rows[0]) await logAudit(result.rows[0].transaktion_id, 'freigabe_abgelehnt', { kommentar }, userId);
return result.rows[0] || null;
} catch (error) {
logger.error('BuchhaltungService.rejectFreigabe failed', { error, id });
throw new Error('Freigabe konnte nicht abgelehnt werden');
}
}
// ---------------------------------------------------------------------------
// Einstellungen (Settings)
// ---------------------------------------------------------------------------
async function getEinstellungen() {
try {
const result = await pool.query(`SELECT * FROM buchhaltung_einstellungen`);
const settings: Record<string, unknown> = {};
for (const row of result.rows) settings[row.key] = row.value;
return settings;
} catch (error) {
logger.error('BuchhaltungService.getEinstellungen failed', { error });
throw new Error('Einstellungen konnten nicht geladen werden');
}
}
async function setEinstellungen(data: Record<string, unknown>) {
try {
for (const [key, value] of Object.entries(data)) {
await pool.query(
`INSERT INTO buchhaltung_einstellungen (key, value) VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value = $2, aktualisiert_am = NOW()`,
[key, JSON.stringify(value)]
);
}
} catch (error) {
logger.error('BuchhaltungService.setEinstellungen failed', { error });
throw new Error('Einstellungen konnten nicht gespeichert werden');
}
}
// ---------------------------------------------------------------------------
// Audit
// ---------------------------------------------------------------------------
async function logAudit(transaktionId: number | null, aktion: string, details: unknown, userId: string) {
try {
await pool.query(
`INSERT INTO buchhaltung_audit (transaktion_id, aktion, details, erstellt_von) VALUES ($1, $2, $3, $4)`,
[transaktionId, aktion, JSON.stringify(details), userId]
);
} catch (err) {
logger.warn('BuchhaltungService.logAudit failed (non-fatal)', { err });
}
}
async function getAuditByTransaktion(transaktionId: number) {
try {
const result = await pool.query(
`SELECT * FROM buchhaltung_audit WHERE transaktion_id = $1 ORDER BY erstellt_am DESC`,
[transaktionId]
);
return result.rows;
} catch (error) {
logger.error('BuchhaltungService.getAuditByTransaktion failed', { error });
throw new Error('Audit-Einträge konnten nicht geladen werden');
}
}
// ---------------------------------------------------------------------------
// Stats / Overview
// ---------------------------------------------------------------------------
async function getOverview(haushaltsjahrId: number) {
try {
const totalsResult = await pool.query(
`SELECT
COALESCE(SUM(CASE WHEN typ = 'einnahme' AND status IN ('gebucht','freigegeben') THEN betrag ELSE 0 END), 0) as total_einnahmen,
COALESCE(SUM(CASE WHEN typ = 'ausgabe' AND status IN ('gebucht','freigegeben') THEN betrag ELSE 0 END), 0) as total_ausgaben
FROM buchhaltung_transaktionen
WHERE haushaltsjahr_id = $1`,
[haushaltsjahrId]
);
const kontenResult = await pool.query(
`SELECT k.*,
kt.bezeichnung as konto_typ_bezeichnung,
kt.art as konto_typ_art,
COALESCE(SUM(CASE WHEN t.typ = 'ausgabe' AND t.status IN ('gebucht','freigegeben') THEN t.betrag ELSE 0 END), 0) as gebucht_betrag,
COALESCE(SUM(CASE WHEN t.typ = 'ausgabe' AND t.status = 'entwurf' THEN t.betrag ELSE 0 END), 0) as ausstehend_betrag
FROM buchhaltung_konten k
LEFT JOIN buchhaltung_konto_typen kt ON k.konto_typ_id = kt.id
LEFT JOIN buchhaltung_transaktionen t ON t.konto_id = k.id AND t.haushaltsjahr_id = $1
WHERE k.haushaltsjahr_id = $1 AND k.aktiv = TRUE
GROUP BY k.id, kt.bezeichnung, kt.art
ORDER BY k.kontonummer`,
[haushaltsjahrId]
);
const totalEinnahmen = parseFloat(totalsResult.rows[0].total_einnahmen);
const totalAusgaben = parseFloat(totalsResult.rows[0].total_ausgaben);
return {
haushaltsjahr_id: haushaltsjahrId,
total_einnahmen: totalEinnahmen,
total_ausgaben: totalAusgaben,
saldo: totalEinnahmen - totalAusgaben,
konten_budget: kontenResult.rows.map(row => ({
...row,
gebucht_betrag: parseFloat(row.gebucht_betrag),
ausstehend_betrag: parseFloat(row.ausstehend_betrag),
verfuegbar_betrag: parseFloat(row.budget_betrag) - parseFloat(row.gebucht_betrag) - parseFloat(row.ausstehend_betrag),
auslastung_prozent: parseFloat(row.budget_betrag) > 0
? Math.round((parseFloat(row.gebucht_betrag) / parseFloat(row.budget_betrag)) * 100)
: 0,
})),
};
} catch (error) {
logger.error('BuchhaltungService.getOverview failed', { error, haushaltsjahrId });
throw new Error('Übersicht konnte nicht geladen werden');
}
}
// ---------------------------------------------------------------------------
// Export
// ---------------------------------------------------------------------------
const buchhaltungService = {
getAllHaushaltsjahre,
getHaushaltsjahrById,
getCurrentHaushaltsjahr,
createHaushaltsjahr,
updateHaushaltsjahr,
closeHaushaltsjahr,
getAllKontoTypen,
getAllBankkonten,
getBankkontoById,
createBankkonto,
updateBankkonto,
deactivateBankkonto,
getAllKonten,
getKontoById,
createKonto,
updateKonto,
deleteKonto,
getBudgetUtilisation,
listTransaktionen,
getTransaktionById,
createTransaktion,
updateTransaktion,
bookTransaktion,
stornoTransaktion,
deleteTransaktion,
getBelegeByTransaktion,
uploadBeleg,
deleteBeleg,
getFreigabenByTransaktion,
createFreigabe,
approveFreigabe,
rejectFreigabe,
getEinstellungen,
setEinstellungen,
logAudit,
getAuditByTransaktion,
getOverview,
};
export default buchhaltungService;

View File

@@ -41,6 +41,13 @@ const DEFAULT_PERMISSION_DEPS: Record<string, string[]> = {
'wissen:widget_recent': ['wissen:view'],
'wissen:widget_search': ['wissen:view'],
'admin:write': ['admin:view'],
'buchhaltung:create': ['buchhaltung:view'],
'buchhaltung:edit': ['buchhaltung:view'],
'buchhaltung:delete': ['buchhaltung:view', 'buchhaltung:create'],
'buchhaltung:manage_accounts': ['buchhaltung:view'],
'buchhaltung:manage_settings': ['buchhaltung:view'],
'buchhaltung:export': ['buchhaltung:view'],
'buchhaltung:widget': ['buchhaltung:view'],
};
export interface FeatureGroupRow {