0f8475ce87
- /api/dashboard/* - Dashboard Übersicht, Cashflow, Steuer-Preview - /api/customers/* - Kunden CRUD API - /api/invoices/* - Rechnungen CRUD API + Zahlungen Server.js angepasst um neue Routen einzubinden.
1592 lines
52 KiB
JavaScript
1592 lines
52 KiB
JavaScript
const express = require('express');
|
|
const cors = require('cors');
|
|
const multer = require('multer');
|
|
const sharp = require('sharp');
|
|
const Tesseract = require('tesseract.js');
|
|
const { Pool } = require('pg');
|
|
const { v4: uuidv4 } = require('uuid');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const bcrypt = require('bcryptjs');
|
|
const jwt = require('jsonwebtoken');
|
|
|
|
// Auth imports
|
|
const authRoutes = require('./routes/auth');
|
|
const { authRequired, adminRequired } = require('./middleware/auth');
|
|
|
|
const app = express();
|
|
const PORT = process.env.PORT || 3001;
|
|
|
|
// Database connection - Docker-compatible defaults
|
|
const pool = new Pool({
|
|
host: process.env.DB_HOST || 'buchhaltung-db',
|
|
port: process.env.DB_PORT || 5432,
|
|
database: process.env.DB_NAME || 'buchhaltung',
|
|
user: process.env.DB_USER || 'postgres',
|
|
password: process.env.DB_PASSWORD || 'postgres',
|
|
});
|
|
|
|
// Middleware
|
|
app.use(cors());
|
|
app.use(express.json());
|
|
app.use('/uploads', express.static('uploads'));
|
|
|
|
// Auth Routes
|
|
app.use('/api/auth', authRoutes);
|
|
|
|
// Neue API Routen (für Frontend)
|
|
const dashboardRoutes = require('./routes/dashboard');
|
|
const customersRoutes = require('./routes/customers');
|
|
const invoicesRoutes = require('./routes/invoices');
|
|
|
|
app.use('/api/dashboard', dashboardRoutes);
|
|
app.use('/api/customers', customersRoutes);
|
|
app.use('/api/invoices', invoicesRoutes);
|
|
|
|
// Nebenkosten Routes laden
|
|
const nebenkostenRoutes = require('./routes/nebenkosten');
|
|
nebenkostenRoutes(app);
|
|
|
|
// Ensure uploads directory exists
|
|
const uploadsDir = path.join(__dirname, 'uploads');
|
|
if (!fs.existsSync(uploadsDir)) {
|
|
fs.mkdirSync(uploadsDir, { recursive: true });
|
|
}
|
|
|
|
// Multer configuration
|
|
const storage = multer.diskStorage({
|
|
destination: (req, file, cb) => {
|
|
cb(null, 'uploads/');
|
|
},
|
|
filename: (req, file, cb) => {
|
|
const uniqueName = `${Date.now()}-${uuidv4()}${path.extname(file.originalname)}`;
|
|
cb(null, uniqueName);
|
|
}
|
|
});
|
|
|
|
const upload = multer({
|
|
storage,
|
|
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB limit
|
|
fileFilter: (req, file, cb) => {
|
|
const allowedTypes = /jpeg|jpg|png|pdf|webp/;
|
|
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
|
|
const mimetype = allowedTypes.test(file.mimetype);
|
|
if (extname && mimetype) {
|
|
return cb(null, true);
|
|
}
|
|
cb(new Error('Nur Bilder und PDFs erlaubt'));
|
|
}
|
|
});
|
|
|
|
// Initialize database tables
|
|
async function initDatabase() {
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query(`
|
|
CREATE TABLE IF NOT EXISTS belege (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
date DATE NOT NULL,
|
|
haendler VARCHAR(255),
|
|
betrag DECIMAL(10,2) NOT NULL,
|
|
mwst_satz DECIMAL(4,2) DEFAULT 19.00,
|
|
kategorie VARCHAR(100),
|
|
status VARCHAR(50) DEFAULT 'neu',
|
|
file_path VARCHAR(500),
|
|
ocr_text TEXT,
|
|
created_at TIMESTAMP DEFAULT NOW(),
|
|
updated_at TIMESTAMP DEFAULT NOW()
|
|
)
|
|
`);
|
|
|
|
await client.query(`
|
|
CREATE TABLE IF NOT EXISTS rechnungen (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
rechnung_nr VARCHAR(50) UNIQUE NOT NULL,
|
|
kunde VARCHAR(255) NOT NULL,
|
|
kunde_email VARCHAR(255),
|
|
kunde_adresse TEXT,
|
|
leistung TEXT NOT NULL,
|
|
betrag DECIMAL(10,2) NOT NULL,
|
|
ust_satz DECIMAL(4,2) DEFAULT 19.00,
|
|
datum DATE NOT NULL,
|
|
faelligkeit DATE,
|
|
status VARCHAR(50) DEFAULT 'offen',
|
|
pdf_path VARCHAR(500),
|
|
created_at TIMESTAMP DEFAULT NOW()
|
|
)
|
|
`);
|
|
|
|
await client.query(`
|
|
CREATE TABLE IF NOT EXISTS nebenkosten (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
jahr INTEGER NOT NULL,
|
|
wohnung VARCHAR(100),
|
|
mieter VARCHAR(255),
|
|
kaltmiete DECIMAL(10,2),
|
|
nebenkosten DECIMAL(10,2),
|
|
heizkosten DECIMAL(10,2),
|
|
wasser DECIMAL(10,2),
|
|
muell DECIMAL(10,2),
|
|
versicherung DECIMAL(10,2),
|
|
sonstiges DECIMAL(10,2),
|
|
created_at TIMESTAMP DEFAULT NOW()
|
|
)
|
|
`);
|
|
|
|
// Kredite Tabelle
|
|
await client.query(`
|
|
CREATE TABLE IF NOT EXISTS kredite (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
name VARCHAR(255) NOT NULL,
|
|
kreditgeber VARCHAR(255),
|
|
person VARCHAR(100), -- 'Kerstin', 'Niki', etc.
|
|
richtung VARCHAR(50) DEFAULT 'ausgehend', -- 'eingehend' (Forderung) oder 'ausgehend' (Schulden)
|
|
ursprungsschuld DECIMAL(12,2) NOT NULL,
|
|
restschuld DECIMAL(12,2) NOT NULL,
|
|
monatsrate DECIMAL(10,2) NOT NULL,
|
|
zinssatz DECIMAL(5,2) DEFAULT 4.00,
|
|
start_datum DATE NOT NULL,
|
|
end_datum DATE,
|
|
laufzeit_monate INTEGER,
|
|
faelligkeit_tag INTEGER DEFAULT 1,
|
|
status VARCHAR(50) DEFAULT 'aktiv',
|
|
notizen TEXT,
|
|
created_at TIMESTAMP DEFAULT NOW(),
|
|
updated_at TIMESTAMP DEFAULT NOW()
|
|
)
|
|
`);
|
|
|
|
// Kredit-Buchungen (Tilgungsverlauf)
|
|
await client.query(`
|
|
CREATE TABLE IF NOT EXISTS kredit_buchungen (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
kredit_id UUID REFERENCES kredite(id) ON DELETE CASCADE,
|
|
datum DATE NOT NULL,
|
|
rate_betrag DECIMAL(10,2) NOT NULL,
|
|
zinsen DECIMAL(10,2) DEFAULT 0,
|
|
tilgung DECIMAL(10,2) NOT NULL,
|
|
restschuld_nach DECIMAL(12,2) NOT NULL,
|
|
created_at TIMESTAMP DEFAULT NOW()
|
|
)
|
|
`);
|
|
|
|
// Fixe Ausgaben (für Cashflow)
|
|
await client.query(`
|
|
CREATE TABLE IF NOT EXISTS fixe_ausgaben (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
name VARCHAR(255) NOT NULL,
|
|
kategorie VARCHAR(100),
|
|
betrag DECIMAL(10,2) NOT NULL,
|
|
intervall VARCHAR(50) DEFAULT 'monatlich',
|
|
faelligkeit_tag INTEGER,
|
|
is_active BOOLEAN DEFAULT true,
|
|
created_at TIMESTAMP DEFAULT NOW()
|
|
)
|
|
`);
|
|
|
|
// Stunden Tabelle
|
|
await client.query(`
|
|
CREATE TABLE IF NOT EXISTS stunden (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
datum DATE NOT NULL,
|
|
kunde VARCHAR(255) NOT NULL,
|
|
beschreibung TEXT,
|
|
stunden DECIMAL(10,2) NOT NULL,
|
|
stundensatz DECIMAL(10,2) NOT NULL,
|
|
betrag DECIMAL(10,2) NOT NULL,
|
|
status VARCHAR(50) DEFAULT 'offen',
|
|
created_at TIMESTAMP DEFAULT NOW()
|
|
)
|
|
`);
|
|
|
|
// Kostenplanung Tabelle
|
|
await client.query(`
|
|
CREATE TABLE IF NOT EXISTS kostenplanung (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
objekt VARCHAR(100) NOT NULL,
|
|
name VARCHAR(255) NOT NULL,
|
|
kategorie VARCHAR(100) NOT NULL,
|
|
monate DECIMAL(10,2)[] DEFAULT ARRAY[0,0,0,0,0,0,0,0,0,0,0,0],
|
|
jahr INTEGER DEFAULT EXTRACT(YEAR FROM CURRENT_DATE),
|
|
created_at TIMESTAMP DEFAULT NOW(),
|
|
updated_at TIMESTAMP DEFAULT NOW()
|
|
)
|
|
`);
|
|
|
|
// Benutzer (für Auth)
|
|
await client.query(`
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
username VARCHAR(100) UNIQUE NOT NULL,
|
|
password_hash VARCHAR(255) NOT NULL,
|
|
pin VARCHAR(10),
|
|
role VARCHAR(50) DEFAULT 'user',
|
|
is_active BOOLEAN DEFAULT true,
|
|
created_at TIMESTAMP DEFAULT NOW(),
|
|
last_login TIMESTAMP
|
|
)
|
|
`);
|
|
|
|
console.log('✅ Datenbank-Tabellen erstellt');
|
|
|
|
// Migration: Füge richtung-Spalte zu bestehenden kredite-Tabellen hinzu
|
|
await client.query(`
|
|
ALTER TABLE kredite
|
|
ADD COLUMN IF NOT EXISTS richtung VARCHAR(50) DEFAULT 'ausgehend'
|
|
`);
|
|
console.log('✅ Migration: richtung-Spalte geprüft/hinzugefügt');
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
// OCR Endpoint
|
|
app.post('/api/ocr', upload.single('image'), async (req, res) => {
|
|
try {
|
|
if (!req.file) {
|
|
return res.status(400).json({ error: 'Kein Bild hochgeladen' });
|
|
}
|
|
|
|
const filePath = req.file.path;
|
|
|
|
// Preprocess image with sharp
|
|
const processedPath = filePath + '-processed.png';
|
|
await sharp(filePath)
|
|
.resize(2000, null, { withoutEnlargement: true })
|
|
.greyscale()
|
|
.normalize()
|
|
.toFile(processedPath);
|
|
|
|
// Run OCR with German language
|
|
const result = await Tesseract.recognize(
|
|
processedPath,
|
|
'deu',
|
|
{ logger: m => console.log(m) }
|
|
);
|
|
|
|
const text = result.data.text;
|
|
|
|
// Extract data with regex patterns
|
|
const extractedData = extractReceiptData(text);
|
|
|
|
// Save to database
|
|
const query = `
|
|
INSERT INTO belege (date, haendler, betrag, mwst_satz, kategorie, status, file_path, ocr_text)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
RETURNING *
|
|
`;
|
|
const values = [
|
|
extractedData.date || new Date(),
|
|
extractedData.haendler || 'Unbekannt',
|
|
extractedData.betrag || 0,
|
|
extractedData.mwst || 19.00,
|
|
'unbekannt',
|
|
'neu',
|
|
req.file.filename,
|
|
text
|
|
];
|
|
|
|
const dbResult = await pool.query(query, values);
|
|
|
|
// Cleanup processed file
|
|
fs.unlinkSync(processedPath);
|
|
|
|
res.json({
|
|
success: true,
|
|
beleg: dbResult.rows[0],
|
|
extracted: extractedData,
|
|
rawText: text.substring(0, 500) // First 500 chars
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('OCR Error:', error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Extract receipt data using regex patterns
|
|
function extractReceiptData(text) {
|
|
const data = {
|
|
haendler: null,
|
|
betrag: null,
|
|
date: null,
|
|
mwst: 19.00
|
|
};
|
|
|
|
// Try to find total amount
|
|
const totalPatterns = [
|
|
/(?:gesamt|summe|total|betrag|zu zahlen)[\s:]*([\d.,]+)/i,
|
|
/(?:eur|€)[\s:]*([\d.,]+)/i,
|
|
/([\d.,]+)\s*(?:eur|€)/i
|
|
];
|
|
|
|
for (const pattern of totalPatterns) {
|
|
const match = text.match(pattern);
|
|
if (match) {
|
|
let amount = match[1].replace(/\./g, '').replace(',', '.');
|
|
data.betrag = parseFloat(amount);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Try to find date
|
|
const datePatterns = [
|
|
/(\d{2})[\/.-](\d{2})[\/.-](\d{4})/, // DD.MM.YYYY
|
|
/(\d{2})[\/.-](\d{2})[\/.-](\d{2})/, // DD.MM.YY
|
|
];
|
|
|
|
for (const pattern of datePatterns) {
|
|
const match = text.match(pattern);
|
|
if (match) {
|
|
const day = match[1];
|
|
const month = match[2];
|
|
const year = match[3].length === 2 ? '20' + match[3] : match[3];
|
|
data.date = `${year}-${month}-${day}`;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Try to find merchant name (first line often contains it)
|
|
const lines = text.split('\n').filter(l => l.trim());
|
|
if (lines.length > 0) {
|
|
// Skip common header words
|
|
const skipWords = ['bon', 'beleg', 'quittung', 'kasse', 'rechnung'];
|
|
for (const line of lines.slice(0, 5)) {
|
|
const cleanLine = line.trim();
|
|
if (cleanLine.length > 2 && !skipWords.some(w => cleanLine.toLowerCase().includes(w))) {
|
|
data.haendler = cleanLine;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
// Belege API
|
|
app.get('/api/belege', async (req, res) => {
|
|
try {
|
|
const { status, limit = 50 } = req.query;
|
|
let query = 'SELECT * FROM belege ORDER BY created_at DESC LIMIT $1';
|
|
const values = [limit];
|
|
|
|
if (status) {
|
|
query = 'SELECT * FROM belege WHERE status = $2 ORDER BY created_at DESC LIMIT $1';
|
|
values.push(status);
|
|
}
|
|
|
|
const result = await pool.query(query, values);
|
|
res.json(result.rows);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.get('/api/belege/:id', async (req, res) => {
|
|
try {
|
|
const result = await pool.query('SELECT * FROM belege WHERE id = $1', [req.params.id]);
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Beleg nicht gefunden' });
|
|
}
|
|
res.json(result.rows[0]);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.put('/api/belege/:id', async (req, res) => {
|
|
try {
|
|
const { haendler, betrag, kategorie, status } = req.body;
|
|
const query = `
|
|
UPDATE belege
|
|
SET haendler = $1, betrag = $2, kategorie = $3, status = $4, updated_at = NOW()
|
|
WHERE id = $5
|
|
RETURNING *
|
|
`;
|
|
const result = await pool.query(query, [haendler, betrag, kategorie, status, req.params.id]);
|
|
res.json(result.rows[0]);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/belege/:id', async (req, res) => {
|
|
try {
|
|
// Get file path first
|
|
const beleg = await pool.query('SELECT file_path FROM belege WHERE id = $1', [req.params.id]);
|
|
if (beleg.rows.length > 0 && beleg.rows[0].file_path) {
|
|
const filePath = path.join(uploadsDir, beleg.rows[0].file_path);
|
|
if (fs.existsSync(filePath)) {
|
|
fs.unlinkSync(filePath);
|
|
}
|
|
}
|
|
|
|
await pool.query('DELETE FROM belege WHERE id = $1', [req.params.id]);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Rechnungen API
|
|
app.get('/api/rechnungen', async (req, res) => {
|
|
try {
|
|
const result = await pool.query('SELECT * FROM rechnungen ORDER BY datum DESC');
|
|
res.json(result.rows);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.post('/api/rechnungen', async (req, res) => {
|
|
try {
|
|
const { kunde, kunde_email, kunde_adresse, leistung, betrag, ust_satz, datum, faelligkeit } = req.body;
|
|
const rechnungNr = `RE-${new Date().getFullYear()}-${String(await getNextRechnungNr()).padStart(3, '0')}`;
|
|
|
|
const query = `
|
|
INSERT INTO rechnungen (rechnung_nr, kunde, kunde_email, kunde_adresse, leistung, betrag, ust_satz, datum, faelligkeit)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
RETURNING *
|
|
`;
|
|
const result = await pool.query(query, [rechnungNr, kunde, kunde_email, kunde_adresse, leistung, betrag, ust_satz, datum, faelligkeit]);
|
|
res.json(result.rows[0]);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
async function getNextRechnungNr() {
|
|
const result = await pool.query("SELECT COUNT(*) FROM rechnungen WHERE rechnung_nr LIKE 'RE-' || EXTRACT(YEAR FROM CURRENT_DATE) || '-%'");
|
|
return parseInt(result.rows[0].count) + 1;
|
|
}
|
|
|
|
// EÜR API
|
|
app.get('/api/euer', async (req, res) => {
|
|
try {
|
|
const { year = new Date().getFullYear() } = req.query;
|
|
|
|
// Einnahmen aus Rechnungen
|
|
const einnahmen = await pool.query(`
|
|
SELECT COALESCE(SUM(betrag), 0) as total FROM rechnungen
|
|
WHERE status = 'bezahlt' AND EXTRACT(YEAR FROM datum) = $1
|
|
`, [year]);
|
|
|
|
// Ausgaben aus Belegen
|
|
const ausgaben = await pool.query(`
|
|
SELECT COALESCE(SUM(betrag), 0) as total FROM belege
|
|
WHERE status = 'fertig' AND EXTRACT(YEAR FROM date) = $1
|
|
`, [year]);
|
|
|
|
const buchungen = await pool.query(`
|
|
SELECT * FROM euer_buchungen
|
|
WHERE EXTRACT(YEAR FROM date) = $1
|
|
ORDER BY date DESC
|
|
`, [year]);
|
|
|
|
res.json({
|
|
jahr: year,
|
|
einnahmen: einnahmen.rows[0].total,
|
|
ausgaben: ausgaben.rows[0].total,
|
|
ergebnis: einnahmen.rows[0].total - ausgaben.rows[0].total,
|
|
buchungen: buchungen.rows
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Kunden API
|
|
app.get('/api/kunden', async (req, res) => {
|
|
try {
|
|
const result = await pool.query('SELECT * FROM kunden ORDER BY name');
|
|
res.json(result.rows);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.get('/api/kunden/:id', async (req, res) => {
|
|
try {
|
|
const result = await pool.query('SELECT * FROM kunden WHERE id = $1', [req.params.id]);
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Kunde nicht gefunden' });
|
|
}
|
|
res.json(result.rows[0]);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.post('/api/kunden', async (req, res) => {
|
|
try {
|
|
const { name, adresse, plz, ort, email, telefon, notizen } = req.body;
|
|
const query = `
|
|
INSERT INTO kunden (name, adresse, plz, ort, email, telefon, notizen)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
RETURNING *
|
|
`;
|
|
const result = await pool.query(query, [name, adresse, plz, ort, email, telefon, notizen]);
|
|
res.json(result.rows[0]);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.put('/api/kunden/:id', async (req, res) => {
|
|
try {
|
|
const { name, adresse, plz, ort, email, telefon, notizen } = req.body;
|
|
const query = `
|
|
UPDATE kunden
|
|
SET name = COALESCE($1, name), adresse = COALESCE($2, adresse), plz = COALESCE($3, plz),
|
|
ort = COALESCE($4, ort), email = COALESCE($5, email), telefon = COALESCE($6, telefon), notizen = COALESCE($7, notizen)
|
|
WHERE id = $8
|
|
RETURNING *
|
|
`;
|
|
const result = await pool.query(query, [name, adresse, plz, ort, email, telefon, notizen, req.params.id]);
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Kunde nicht gefunden' });
|
|
}
|
|
res.json(result.rows[0]);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/kunden/:id', async (req, res) => {
|
|
try {
|
|
const result = await pool.query('DELETE FROM kunden WHERE id = $1 RETURNING *', [req.params.id]);
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Kunde nicht gefunden' });
|
|
}
|
|
res.json({ success: true, deleted: result.rows[0] });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Auftragsnachweise API
|
|
app.get('/api/auftragsnachweise', async (req, res) => {
|
|
try {
|
|
const result = await pool.query(`
|
|
SELECT an.*, k.name as kunden_name
|
|
FROM auftragsnachweise an
|
|
JOIN kunden k ON an.kunde_id = k.id
|
|
ORDER BY an.datum DESC
|
|
`);
|
|
res.json(result.rows);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.get('/api/auftragsnachweise/:id', async (req, res) => {
|
|
try {
|
|
const result = await pool.query(`
|
|
SELECT an.*, k.name as kunden_name
|
|
FROM auftragsnachweise an
|
|
JOIN kunden k ON an.kunde_id = k.id
|
|
WHERE an.id = $1
|
|
`, [req.params.id]);
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Auftragsnachweis nicht gefunden' });
|
|
}
|
|
res.json(result.rows[0]);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.post('/api/auftragsnachweise', async (req, res) => {
|
|
try {
|
|
const { kunde_id, datum, beschreibung, stunden_ids, art_der_arbeit, anfahrt_km, pauschale } = req.body;
|
|
const query = `
|
|
INSERT INTO auftragsnachweise (kunde_id, datum, beschreibung, stunden_ids, art_der_arbeit, anfahrt_km, pauschale)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
RETURNING *
|
|
`;
|
|
const result = await pool.query(query, [
|
|
kunde_id, datum, beschreibung,
|
|
JSON.stringify(stunden_ids || []),
|
|
JSON.stringify(art_der_arbeit || []),
|
|
anfahrt_km || 0,
|
|
pauschale || 0
|
|
]);
|
|
res.json(result.rows[0]);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/auftragsnachweise/:id', async (req, res) => {
|
|
try {
|
|
const result = await pool.query('DELETE FROM auftragsnachweise WHERE id = $1 RETURNING *', [req.params.id]);
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Auftragsnachweis nicht gefunden' });
|
|
}
|
|
res.json({ success: true, deleted: result.rows[0] });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Nebenkosten API
|
|
app.get('/api/nebenkosten', async (req, res) => {
|
|
try {
|
|
const { jahr } = req.query;
|
|
let query = 'SELECT * FROM nebenkosten';
|
|
const values = [];
|
|
|
|
if (jahr) {
|
|
query += ' WHERE jahr = $1';
|
|
values.push(jahr);
|
|
}
|
|
query += ' ORDER BY jahr DESC';
|
|
|
|
const result = await pool.query(query, values);
|
|
res.json(result.rows);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.post('/api/nebenkosten', async (req, res) => {
|
|
try {
|
|
const { jahr, wohnung, mieter, kaltmiete, nebenkosten, heizkosten, wasser, muell, versicherung, sonstiges } = req.body;
|
|
|
|
const query = `
|
|
INSERT INTO nebenkosten (jahr, wohnung, mieter, kaltmiete, nebenkosten, heizkosten, wasser, muell, versicherung, sonstiges)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
|
RETURNING *
|
|
`;
|
|
const result = await pool.query(query, [jahr, wohnung, mieter, kaltmiete, nebenkosten, heizkosten, wasser, muell, versicherung, sonstiges]);
|
|
res.json(result.rows[0]);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Nebenkosten PUT (Update)
|
|
app.put('/api/nebenkosten/:id', async (req, res) => {
|
|
try {
|
|
const { jahr, wohnung, mieter, kaltmiete, nebenkosten, heizkosten, wasser, muell, versicherung, sonstiges } = req.body;
|
|
const query = `
|
|
UPDATE nebenkosten
|
|
SET jahr = $1, wohnung = $2, mieter = $3, kaltmiete = $4, nebenkosten = $5, heizkosten = $6, wasser = $7, muell = $8, versicherung = $9, sonstiges = $10
|
|
WHERE id = $11
|
|
RETURNING *
|
|
`;
|
|
const result = await pool.query(query, [jahr, wohnung, mieter, kaltmiete, nebenkosten, heizkosten, wasser, muell, versicherung, sonstiges, req.params.id]);
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Nebenkosten nicht gefunden' });
|
|
}
|
|
res.json(result.rows[0]);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Nebenkosten DELETE
|
|
app.delete('/api/nebenkosten/:id', async (req, res) => {
|
|
try {
|
|
const result = await pool.query('DELETE FROM nebenkosten WHERE id = $1 RETURNING *', [req.params.id]);
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Nebenkosten nicht gefunden' });
|
|
}
|
|
res.json({ success: true, deleted: result.rows[0] });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Kredite API
|
|
app.get('/api/kredite', async (req, res) => {
|
|
try {
|
|
const { person, status } = req.query;
|
|
let query = 'SELECT * FROM kredite';
|
|
const values = [];
|
|
const conditions = [];
|
|
|
|
if (person) {
|
|
conditions.push('person = $' + (values.length + 1));
|
|
values.push(person);
|
|
}
|
|
if (status) {
|
|
conditions.push('status = $' + (values.length + 1));
|
|
values.push(status);
|
|
}
|
|
|
|
if (conditions.length > 0) {
|
|
query += ' WHERE ' + conditions.join(' AND ');
|
|
}
|
|
|
|
query += ' ORDER BY created_at DESC';
|
|
|
|
const result = await pool.query(query, values);
|
|
res.json(result.rows);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.post('/api/kredite', async (req, res) => {
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
const { name, kreditgeber, person, ursprungsschuld, monatsrate, zinssatz, start_datum, laufzeit_monate, faelligkeit_tag, notizen, richtung } = req.body;
|
|
|
|
// Ensure richtung column exists
|
|
await client.query(`
|
|
ALTER TABLE kredite ADD COLUMN IF NOT EXISTS richtung VARCHAR(50) DEFAULT 'ausgehend'
|
|
`);
|
|
|
|
// 1. Kredit erstellen
|
|
const kreditQuery = `
|
|
INSERT INTO kredite (name, kreditgeber, person, ursprungsschuld, restschuld, monatsrate, zinssatz, start_datum, laufzeit_monate, faelligkeit_tag, notizen, richtung)
|
|
VALUES ($1, $2, $3, $4, $4, $5, $6, $7, $8, $9, $10, COALESCE($11, 'ausgehend'))
|
|
RETURNING *
|
|
`;
|
|
const kreditResult = await client.query(kreditQuery, [name, kreditgeber, person, ursprungsschuld, monatsrate, zinssatz, start_datum, laufzeit_monate, faelligkeit_tag, notizen, richtung]);
|
|
const kredit = kreditResult.rows[0];
|
|
|
|
// 2. Startbuchung erstellen
|
|
await client.query(
|
|
'INSERT INTO kredit_buchungen (kredit_id, datum, rate_betrag, zinsen, tilgung, restschuld_nach) VALUES ($1, $2, $3, $4, $5, $6)',
|
|
[kredit.id, start_datum || new Date().toISOString().split('T')[0], 0, 0, 0, ursprungsschuld]
|
|
);
|
|
|
|
// 3. Monatsbuchungen erstellen (Zinsen immer, Tilgung optional)
|
|
// Auch ohne Rate werden Zinsbuchungen erstellt wenn Zinssatz > 0
|
|
const hatZinsen = zinssatz && parseFloat(zinssatz) > 0;
|
|
const hatRate = monatsrate && parseFloat(monatsrate) > 0;
|
|
|
|
if (hatZinsen || hatRate) {
|
|
const startDate = new Date(start_datum || new Date());
|
|
const laufzeit = laufzeit_monate || (hatZinsen && !hatRate ? 12 : 60); // Bei reinen Zinskrediten: 1 Jahr Default
|
|
let restschuld = parseFloat(ursprungsschuld);
|
|
const rate = parseFloat(monatsrate || 0);
|
|
const zins = parseFloat(zinssatz || 0) / 100 / 12;
|
|
|
|
for (let i = 0; i < laufzeit; i++) {
|
|
const buchungDatum = new Date(startDate);
|
|
buchungDatum.setMonth(buchungDatum.getMonth() + i + 1); // +1 weil Startbuchung bereits erstellt
|
|
|
|
const zinsen = restschuld * zins;
|
|
let tilgung = 0;
|
|
|
|
if (hatRate) {
|
|
// Normale Annuitätentilgung
|
|
tilgung = rate - zinsen;
|
|
restschuld -= tilgung;
|
|
} else if (hatZinsen) {
|
|
// Rate = 0 aber Zinsen > 0: Zinsen werden zum Kapital addiert (Zinseszins)
|
|
restschuld += zinsen;
|
|
}
|
|
// Bei Rate = 0 und Zinsen = 0: Restschuld bleibt gleich
|
|
|
|
if (restschuld < 0) restschuld = 0;
|
|
|
|
await client.query(
|
|
'INSERT INTO kredit_buchungen (kredit_id, datum, rate_betrag, zinsen, tilgung, restschuld_nach) VALUES ($1, $2, $3, $4, $5, $6)',
|
|
[kredit.id, buchungDatum.toISOString().split('T')[0], rate, zinsen, tilgung, restschuld]
|
|
);
|
|
|
|
if (restschuld <= 0) break; // Kredit abbezahlt
|
|
}
|
|
|
|
// Restschuld aktualisieren
|
|
await client.query('UPDATE kredite SET restschuld = $1 WHERE id = $2', [restschuld, kredit.id]);
|
|
}
|
|
|
|
await client.query('COMMIT');
|
|
res.json(kredit);
|
|
} catch (error) {
|
|
await client.query('ROLLBACK');
|
|
res.status(500).json({ error: error.message });
|
|
} finally {
|
|
client.release();
|
|
}
|
|
});
|
|
|
|
app.get('/api/kredite/:id/buchungen', async (req, res) => {
|
|
try {
|
|
const result = await pool.query(
|
|
'SELECT * FROM kredit_buchungen WHERE kredit_id = $1 ORDER BY datum DESC',
|
|
[req.params.id]
|
|
);
|
|
res.json(result.rows);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Kredit PUT (Update) - Verwendet COALESCE für partielle Updates
|
|
app.put('/api/kredite/:id', async (req, res) => {
|
|
try {
|
|
const { name, kreditgeber, person, richtung, ursprungsschuld, restschuld, monatsrate, zinssatz, start_datum, end_datum, laufzeit_monate, faelligkeit_tag, status, notizen } = req.body;
|
|
const query = `
|
|
UPDATE kredite
|
|
SET
|
|
name = COALESCE($1, name),
|
|
kreditgeber = COALESCE($2, kreditgeber),
|
|
person = COALESCE($3, person),
|
|
richtung = COALESCE($4, richtung),
|
|
ursprungsschuld = COALESCE($5, ursprungsschuld),
|
|
restschuld = COALESCE($6, restschuld),
|
|
monatsrate = COALESCE($7, monatsrate),
|
|
zinssatz = COALESCE($8, zinssatz),
|
|
start_datum = COALESCE($9, start_datum),
|
|
end_datum = COALESCE($10, end_datum),
|
|
laufzeit_monate = COALESCE($11, laufzeit_monate),
|
|
faelligkeit_tag = COALESCE($12, faelligkeit_tag),
|
|
status = COALESCE($13, status),
|
|
notizen = COALESCE($14, notizen),
|
|
updated_at = NOW()
|
|
WHERE id = $15
|
|
RETURNING *
|
|
`;
|
|
const result = await pool.query(query, [name, kreditgeber, person, richtung, ursprungsschuld, restschuld, monatsrate, zinssatz, start_datum, end_datum, laufzeit_monate, faelligkeit_tag, status, notizen, req.params.id]);
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Kredit nicht gefunden' });
|
|
}
|
|
res.json(result.rows[0]);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Kredit PATCH (partielles Update) - für z.B. Restschuld-Updates
|
|
app.patch('/api/kredite/:id', async (req, res) => {
|
|
try {
|
|
const { restschuld, status, notizen } = req.body;
|
|
const updates = [];
|
|
const values = [];
|
|
|
|
if (restschuld !== undefined) {
|
|
updates.push(`restschuld = $${values.length + 1}`);
|
|
values.push(restschuld);
|
|
}
|
|
if (status !== undefined) {
|
|
updates.push(`status = $${values.length + 1}`);
|
|
values.push(status);
|
|
}
|
|
if (notizen !== undefined) {
|
|
updates.push(`notizen = $${values.length + 1}`);
|
|
values.push(notizen);
|
|
}
|
|
|
|
if (updates.length === 0) {
|
|
return res.status(400).json({ error: 'Keine Felder zum Aktualisieren angegeben' });
|
|
}
|
|
|
|
updates.push(`updated_at = NOW()`);
|
|
values.push(req.params.id);
|
|
|
|
const query = `
|
|
UPDATE kredite
|
|
SET ${updates.join(', ')}
|
|
WHERE id = $${values.length}
|
|
RETURNING *
|
|
`;
|
|
|
|
const result = await pool.query(query, values);
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Kredit nicht gefunden' });
|
|
}
|
|
res.json(result.rows[0]);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Kredit DELETE
|
|
app.delete('/api/kredite/:id', async (req, res) => {
|
|
try {
|
|
// Lösche zuerst alle Buchungen
|
|
await pool.query('DELETE FROM kredit_buchungen WHERE kredit_id = $1', [req.params.id]);
|
|
// Dann den Kredit
|
|
const result = await pool.query('DELETE FROM kredite WHERE id = $1 RETURNING *', [req.params.id]);
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Kredit nicht gefunden' });
|
|
}
|
|
res.json({ success: true, deleted: result.rows[0] });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Kredit Buchung hinzufügen
|
|
app.post('/api/kredite/:id/buchungen', async (req, res) => {
|
|
try {
|
|
const { datum, rate_betrag, zinsen, tilgung, restschuld_nach } = req.body;
|
|
const query = `
|
|
INSERT INTO kredit_buchungen (kredit_id, datum, rate_betrag, zinsen, tilgung, restschuld_nach)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
RETURNING *
|
|
`;
|
|
const result = await pool.query(query, [req.params.id, datum, rate_betrag, zinsen, tilgung, restschuld_nach]);
|
|
|
|
// Update Restschuld im Kredit
|
|
await pool.query('UPDATE kredite SET restschuld = $1, updated_at = NOW() WHERE id = $2', [restschuld_nach, req.params.id]);
|
|
|
|
res.json(result.rows[0]);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Kredit Buchung löschen
|
|
app.delete('/api/kredite/:id/buchungen/:buchungId', async (req, res) => {
|
|
try {
|
|
await pool.query('DELETE FROM kredit_buchungen WHERE id = $1 AND kredit_id = $2', [req.params.buchungId, req.params.id]);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Kredit Buchungen NEU BERECHNEN (für bestehende Kredite ohne oder mit fehlerhaften Buchungen)
|
|
app.post('/api/kredite/:id/buchungen/neuberechnen', async (req, res) => {
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
// 1. Kredit-Daten holen
|
|
const kreditResult = await client.query(
|
|
'SELECT * FROM kredite WHERE id = $1',
|
|
[req.params.id]
|
|
);
|
|
|
|
if (kreditResult.rows.length === 0) {
|
|
await client.query('ROLLBACK');
|
|
return res.status(404).json({ error: 'Kredit nicht gefunden' });
|
|
}
|
|
|
|
const kredit = kreditResult.rows[0];
|
|
const { start_datum, ursprungsschuld, zinssatz, monatsrate, richtung } = kredit;
|
|
|
|
// 2. Alte Buchungen löschen
|
|
await client.query('DELETE FROM kredit_buchungen WHERE kredit_id = $1', [req.params.id]);
|
|
|
|
// 3. Startbuchung erstellen
|
|
await client.query(
|
|
'INSERT INTO kredit_buchungen (kredit_id, datum, rate_betrag, zinsen, tilgung, restschuld_nach) VALUES ($1, $2, $3, $4, $5, $6)',
|
|
[req.params.id, start_datum, 0, 0, 0, ursprungsschuld]
|
|
);
|
|
|
|
// 4. ALLE Zahlungen holen und chronologisch verarbeiten
|
|
const zahlungenResult = await client.query(
|
|
'SELECT * FROM kredit_zahlungen WHERE kredit_id = $1 ORDER BY datum',
|
|
[req.params.id]
|
|
);
|
|
const zahlungen = zahlungenResult.rows;
|
|
|
|
const zinsProMonat = parseFloat(zinssatz || 0) / 100 / 12;
|
|
let restschuld = parseFloat(ursprungsschuld);
|
|
const istForderung = richtung === 'eingehend';
|
|
let letzteBuchungDatum = new Date(start_datum);
|
|
|
|
// Für jeden Monat von Start bis heute: Buchung erstellen
|
|
const startDate = new Date(start_datum);
|
|
const endDate = new Date();
|
|
let aktuellesDatum = new Date(startDate);
|
|
aktuellesDatum.setMonth(aktuellesDatum.getMonth() + 1); // Erster Monat nach Start
|
|
|
|
while (aktuellesDatum <= endDate) {
|
|
const jahr = aktuellesDatum.getFullYear();
|
|
const monat = aktuellesDatum.getMonth() + 1;
|
|
const monatStart = new Date(jahr, aktuellesDatum.getMonth(), 1);
|
|
const monatEnde = new Date(jahr, aktuellesDatum.getMonth() + 1, 0);
|
|
|
|
// Zahlungen in diesem Monat finden
|
|
const zahlungenImMonat = zahlungen.filter(z => {
|
|
const zDatum = new Date(z.datum);
|
|
return zDatum >= monatStart && zDatum <= monatEnde;
|
|
});
|
|
|
|
// Zinsen für den Monat berechnen
|
|
const zinsen = restschuld * zinsProMonat;
|
|
|
|
if (zahlungenImMonat.length > 0) {
|
|
// Es gab Zahlungen in diesem Monat
|
|
let monatsTilgung = 0;
|
|
let monatsRate = 0;
|
|
|
|
for (const z of zahlungenImMonat) {
|
|
const betrag = parseFloat(z.betrag);
|
|
if (z.typ === 'auslage') {
|
|
// Auslage erhöht die Schuld
|
|
restschuld += betrag;
|
|
monatsTilgung -= betrag; // Negative Tilgung = Erhöhung
|
|
} else {
|
|
// Ratenzahlung
|
|
const rateZinsen = restschuld * zinsProMonat;
|
|
const rateTilgung = betrag - rateZinsen;
|
|
restschuld -= rateTilgung;
|
|
monatsTilgung += rateTilgung;
|
|
monatsRate += betrag;
|
|
}
|
|
}
|
|
|
|
await client.query(
|
|
'INSERT INTO kredit_buchungen (kredit_id, datum, rate_betrag, zinsen, tilgung, restschuld_nach) VALUES ($1, $2, $3, $4, $5, $6)',
|
|
[req.params.id, monatEnde.toISOString().split('T')[0], monatsRate, zinsen.toFixed(2), monatsTilgung.toFixed(2), restschuld]
|
|
);
|
|
} else {
|
|
// Keine Zahlung in diesem Monat
|
|
if (istForderung) {
|
|
// Bei Forderung: Zinsen erhöhen die Restschuld
|
|
restschuld += zinsen;
|
|
}
|
|
// Bei Schuld: Zinsen werden nicht zum Kapitalisiert (nur Zinszahlung fällig)
|
|
|
|
await client.query(
|
|
'INSERT INTO kredit_buchungen (kredit_id, datum, rate_betrag, zinsen, tilgung, restschuld_nach) VALUES ($1, $2, $3, $4, $5, $6)',
|
|
[req.params.id, monatEnde.toISOString().split('T')[0], 0, zinsen.toFixed(2), 0, restschuld]
|
|
);
|
|
}
|
|
|
|
// Nächster Monat
|
|
aktuellesDatum.setMonth(aktuellesDatum.getMonth() + 1);
|
|
}
|
|
|
|
// 5. Restschuld aktualisieren
|
|
await client.query('UPDATE kredite SET restschuld = $1 WHERE id = $2', [restschuld, req.params.id]);
|
|
|
|
await client.query('COMMIT');
|
|
res.json({
|
|
success: true,
|
|
message: 'Buchungen neu berechnet',
|
|
kreditId: req.params.id,
|
|
neueRestschuld: restschuld
|
|
});
|
|
} catch (error) {
|
|
await client.query('ROLLBACK');
|
|
res.status(500).json({ error: error.message });
|
|
} finally {
|
|
client.release();
|
|
}
|
|
});
|
|
|
|
// Kredit Zahlungen Tabelle erstellen
|
|
async function initZahlungenTable() {
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query(`
|
|
CREATE TABLE IF NOT EXISTS kredit_zahlungen (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
kredit_id UUID REFERENCES kredite(id) ON DELETE CASCADE,
|
|
betrag DECIMAL(10,2) NOT NULL,
|
|
datum DATE NOT NULL,
|
|
typ VARCHAR(50) NOT NULL DEFAULT 'monatsrate',
|
|
notiz TEXT,
|
|
created_at TIMESTAMP DEFAULT NOW()
|
|
)
|
|
`);
|
|
console.log('✅ Kredit Zahlungen Tabelle erstellt');
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
// Zahlungen Endpunkte (Alias für Frontend)
|
|
app.get('/api/kredite/:id/zahlungen', async (req, res) => {
|
|
try {
|
|
// Erstelle Tabelle falls nicht vorhanden
|
|
await initZahlungenTable();
|
|
const result = await pool.query(
|
|
'SELECT * FROM kredit_zahlungen WHERE kredit_id = $1 ORDER BY datum DESC',
|
|
[req.params.id]
|
|
);
|
|
res.json(result.rows);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.post('/api/kredite/:id/zahlungen', async (req, res) => {
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
await initZahlungenTable();
|
|
|
|
const { betrag, datum, typ, notiz } = req.body;
|
|
|
|
// 1. Zahlung speichern
|
|
const zahlungResult = await client.query(
|
|
'INSERT INTO kredit_zahlungen (kredit_id, betrag, datum, typ, notiz) VALUES ($1, $2, $3, $4, $5) RETURNING *',
|
|
[req.params.id, betrag, datum, typ || 'monatsrate', notiz]
|
|
);
|
|
|
|
// 2. Kredit-Daten holen (inkl. Zinssatz)
|
|
const kreditResult = await client.query('SELECT restschuld, zinssatz FROM kredite WHERE id = $1', [req.params.id]);
|
|
let restschuld = parseFloat(kreditResult.rows[0].restschuld);
|
|
const zinssatz = parseFloat(kreditResult.rows[0].zinssatz) || 0;
|
|
|
|
let zinsen = 0;
|
|
let tilgung = 0;
|
|
|
|
// 3. Zinsen berechnen für Ratenzahlungen (nicht für Auslagen)
|
|
if (typ !== 'auslage') {
|
|
// Monatlicher Zinssatz (jährlich / 12 / 100)
|
|
const monatlicherZinssatz = zinssatz / 12 / 100;
|
|
zinsen = restschuld * monatlicherZinssatz;
|
|
tilgung = parseFloat(betrag) - zinsen;
|
|
restschuld -= tilgung;
|
|
} else {
|
|
// Auslage: erhöht die Restschuld
|
|
tilgung = parseFloat(betrag);
|
|
restschuld += tilgung;
|
|
}
|
|
|
|
// 4. Kredit aktualisieren
|
|
await client.query('UPDATE kredite SET restschuld = $1 WHERE id = $2', [restschuld, req.params.id]);
|
|
|
|
// 5. Buchung erstellen
|
|
await client.query(
|
|
'INSERT INTO kredit_buchungen (kredit_id, datum, rate_betrag, zinsen, tilgung, restschuld_nach) VALUES ($1, $2, $3, $4, $5, $6)',
|
|
[req.params.id, datum, typ === 'auslage' ? 0 : betrag, zinsen.toFixed(2), tilgung.toFixed(2), restschuld]
|
|
);
|
|
|
|
await client.query('COMMIT');
|
|
res.json(zahlungResult.rows[0]);
|
|
} catch (error) {
|
|
await client.query('ROLLBACK');
|
|
res.status(500).json({ error: error.message });
|
|
} finally {
|
|
client.release();
|
|
}
|
|
});
|
|
|
|
app.delete('/api/kredite/:id/zahlungen/:zahlungId', async (req, res) => {
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
// 1. Zahlung holen
|
|
const zahlungResult = await client.query('SELECT * FROM kredit_zahlungen WHERE id = $1 AND kredit_id = $2', [req.params.zahlungId, req.params.id]);
|
|
if (zahlungResult.rows.length === 0) {
|
|
await client.query('ROLLBACK');
|
|
return res.status(404).json({ error: 'Zahlung nicht gefunden' });
|
|
}
|
|
const zahlung = zahlungResult.rows[0];
|
|
|
|
// 2. Zahlung löschen
|
|
await client.query('DELETE FROM kredit_zahlungen WHERE id = $1 AND kredit_id = $2', [req.params.zahlungId, req.params.id]);
|
|
|
|
// 3. Aktuelle Restschuld holen
|
|
const kreditResult = await client.query('SELECT restschuld FROM kredite WHERE id = $1', [req.params.id]);
|
|
let restschuld = parseFloat(kreditResult.rows[0].restschuld);
|
|
|
|
// 4. Restschuld zurücksetzen
|
|
if (zahlung.typ === 'auslage') {
|
|
restschuld -= parseFloat(zahlung.betrag);
|
|
} else {
|
|
restschuld += parseFloat(zahlung.betrag);
|
|
}
|
|
|
|
// 5. Kredit aktualisieren
|
|
await client.query('UPDATE kredite SET restschuld = $1 WHERE id = $2', [restschuld, req.params.id]);
|
|
|
|
// 6. Buchungen neu berechnen (einfacher: alles löschen und neu erstellen)
|
|
await client.query('DELETE FROM kredit_buchungen WHERE kredit_id = $1', [req.params.id]);
|
|
|
|
// Startbuchung
|
|
const kreditInfo = await client.query('SELECT ursprungsschuld, start_datum FROM kredite WHERE id = $1', [req.params.id]);
|
|
let r = parseFloat(kreditInfo.rows[0].ursprungsschuld);
|
|
await client.query(
|
|
'INSERT INTO kredit_buchungen (kredit_id, datum, rate_betrag, zinsen, tilgung, restschuld_nach) VALUES ($1, $2, $3, $4, $5, $6)',
|
|
[req.params.id, kreditInfo.rows[0].start_datum, 0, 0, 0, r]
|
|
);
|
|
|
|
// Alle verbleibenden Zahlungen als Buchungen erstellen
|
|
const remainingZahlungen = await client.query('SELECT * FROM kredit_zahlungen WHERE kredit_id = $1 ORDER BY datum', [req.params.id]);
|
|
for (const z of remainingZahlungen.rows) {
|
|
if (z.typ === 'auslage') {
|
|
r += parseFloat(z.betrag);
|
|
await client.query(
|
|
'INSERT INTO kredit_buchungen (kredit_id, datum, rate_betrag, zinsen, tilgung, restschuld_nach) VALUES ($1, $2, $3, $4, $5, $6)',
|
|
[req.params.id, z.datum, 0, 0, z.betrag, r]
|
|
);
|
|
} else {
|
|
r -= parseFloat(z.betrag);
|
|
await client.query(
|
|
'INSERT INTO kredit_buchungen (kredit_id, datum, rate_betrag, zinsen, tilgung, restschuld_nach) VALUES ($1, $2, $3, $4, $5, $6)',
|
|
[req.params.id, z.datum, z.betrag, 0, z.betrag, r]
|
|
);
|
|
}
|
|
}
|
|
|
|
await client.query('COMMIT');
|
|
res.json({ success: true, restschuld: r });
|
|
} catch (error) {
|
|
await client.query('ROLLBACK');
|
|
res.status(500).json({ error: error.message });
|
|
} finally {
|
|
client.release();
|
|
}
|
|
});
|
|
|
|
// Kredit-Tilgung simulieren/berechnen
|
|
app.post('/api/kredite/:id/simulieren', async (req, res) => {
|
|
try {
|
|
const kredit = await pool.query('SELECT * FROM kredite WHERE id = $1', [req.params.id]);
|
|
if (kredit.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Kredit nicht gefunden' });
|
|
}
|
|
|
|
const k = kredit.rows[0];
|
|
let restschuld = parseFloat(k.restschuld);
|
|
const monatsrate = parseFloat(k.monatsrate);
|
|
const zinssatz = parseFloat(k.zinssatz) / 100 / 12; // Monatlicher Zinssatz
|
|
|
|
const plan = [];
|
|
let monat = 0;
|
|
|
|
while (restschuld > 0 && monat < 600) { // Max 50 Jahre
|
|
monat++;
|
|
const zinsen = restschuld * zinssatz;
|
|
let tilgung = monatsrate - zinsen;
|
|
|
|
if (tilgung > restschuld) {
|
|
tilgung = restschuld;
|
|
}
|
|
|
|
restschuld -= tilgung;
|
|
|
|
const datum = new Date(k.start_datum);
|
|
datum.setMonth(datum.getMonth() + monat);
|
|
|
|
plan.push({
|
|
monat,
|
|
datum: datum.toISOString().split('T')[0],
|
|
rate: monatsrate,
|
|
zinsen: zinsen.toFixed(2),
|
|
tilgung: tilgung.toFixed(2),
|
|
restschuld: restschuld.toFixed(2)
|
|
});
|
|
|
|
if (restschuld <= 0) break;
|
|
}
|
|
|
|
res.json({
|
|
kredit: k,
|
|
tilgungsplan: plan,
|
|
gesamt_monate: monat,
|
|
end_datum: plan.length > 0 ? plan[plan.length - 1].datum : null
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Fixe Ausgaben API
|
|
app.get('/api/fixe-ausgaben', async (req, res) => {
|
|
try {
|
|
const result = await pool.query('SELECT * FROM fixe_ausgaben WHERE is_active = true ORDER BY kategorie, name');
|
|
res.json(result.rows);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.post('/api/fixe-ausgaben', async (req, res) => {
|
|
try {
|
|
const { name, kategorie, betrag, intervall, faelligkeit_tag } = req.body;
|
|
const query = `
|
|
INSERT INTO fixe_ausgaben (name, kategorie, betrag, intervall, faelligkeit_tag)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
RETURNING *
|
|
`;
|
|
const result = await pool.query(query, [name, kategorie, betrag, intervall, faelligkeit_tag]);
|
|
res.json(result.rows[0]);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Cashflow Übersicht
|
|
app.get('/api/cashflow', async (req, res) => {
|
|
try {
|
|
const { monat, jahr = new Date().getFullYear() } = req.query;
|
|
|
|
// Einnahmen aus Rechnungen
|
|
const einnahmenResult = await pool.query(`
|
|
SELECT COALESCE(SUM(betrag), 0) as total FROM rechnungen
|
|
WHERE status = 'bezahlt' AND EXTRACT(MONTH FROM datum) = $1 AND EXTRACT(YEAR FROM datum) = $2
|
|
`, [monat, jahr]);
|
|
|
|
// Variable Ausgaben aus Belegen
|
|
const ausgabenResult = await pool.query(`
|
|
SELECT COALESCE(SUM(betrag), 0) as total FROM belege
|
|
WHERE status = 'fertig' AND EXTRACT(MONTH FROM date) = $1 AND EXTRACT(YEAR FROM date) = $2
|
|
`, [monat, jahr]);
|
|
|
|
// Fixe Ausgaben (monatlich)
|
|
const fixeResult = await pool.query(`
|
|
SELECT COALESCE(SUM(betrag), 0) as total FROM fixe_ausgaben
|
|
WHERE is_active = true AND intervall = 'monatlich'
|
|
`);
|
|
|
|
// Kreditraten (monatlich)
|
|
const krediteResult = await pool.query(`
|
|
SELECT COALESCE(SUM(monatsrate), 0) as total FROM kredite
|
|
WHERE status = 'aktiv'
|
|
`);
|
|
|
|
const einnahmen = parseFloat(einnahmenResult.rows[0].total);
|
|
const ausgaben = parseFloat(ausgabenResult.rows[0].total);
|
|
const fixe = parseFloat(fixeResult.rows[0].total);
|
|
const raten = parseFloat(krediteResult.rows[0].total);
|
|
|
|
res.json({
|
|
monat,
|
|
jahr,
|
|
einnahmen,
|
|
variable_ausgaben: ausgaben,
|
|
fixe_ausgaben: fixe,
|
|
kreditraten: raten,
|
|
gesamt_ausgaben: ausgaben + fixe + raten,
|
|
verfuegbar: einnahmen - ausgaben - fixe - raten
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Stunden API
|
|
app.get('/api/stunden', async (req, res) => {
|
|
try {
|
|
const { status, jahr } = req.query;
|
|
let query = 'SELECT * FROM stunden ORDER BY datum DESC';
|
|
const values = [];
|
|
|
|
if (status) {
|
|
query = 'SELECT * FROM stunden WHERE status = $1 ORDER BY datum DESC';
|
|
values.push(status);
|
|
} else if (jahr) {
|
|
query = 'SELECT * FROM stunden WHERE EXTRACT(YEAR FROM datum) = $1 ORDER BY datum DESC';
|
|
values.push(jahr);
|
|
}
|
|
|
|
const result = await pool.query(query, values);
|
|
res.json(result.rows);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.post('/api/stunden', async (req, res) => {
|
|
try {
|
|
const { datum, kunde, beschreibung, stunden, stundensatz } = req.body;
|
|
const betrag = stunden * stundensatz;
|
|
|
|
const query = `
|
|
INSERT INTO stunden (datum, kunde, beschreibung, stunden, stundensatz, betrag)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
RETURNING *
|
|
`;
|
|
const result = await pool.query(query, [datum, kunde, beschreibung, stunden, stundensatz, betrag]);
|
|
res.json(result.rows[0]);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.put('/api/stunden/:id', async (req, res) => {
|
|
try {
|
|
const { datum, kunde, beschreibung, stunden, stundensatz, status } = req.body;
|
|
const betrag = stunden * stundensatz;
|
|
|
|
const query = `
|
|
UPDATE stunden
|
|
SET datum = $1, kunde = $2, beschreibung = $3, stunden = $4, stundensatz = $5, betrag = $6, status = $7
|
|
WHERE id = $8
|
|
RETURNING *
|
|
`;
|
|
const result = await pool.query(query, [datum, kunde, beschreibung, stunden, stundensatz, betrag, status, req.params.id]);
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Stunden nicht gefunden' });
|
|
}
|
|
res.json(result.rows[0]);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/stunden/:id', async (req, res) => {
|
|
try {
|
|
const result = await pool.query('DELETE FROM stunden WHERE id = $1 RETURNING *', [req.params.id]);
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Stunden nicht gefunden' });
|
|
}
|
|
res.json({ success: true, deleted: result.rows[0] });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Kostenplanung API
|
|
app.get('/api/kostenplanung', async (req, res) => {
|
|
try {
|
|
const { objekt, jahr } = req.query;
|
|
let query = 'SELECT * FROM kostenplanung';
|
|
const values = [];
|
|
const conditions = [];
|
|
|
|
if (objekt) {
|
|
conditions.push('objekt = $' + (values.length + 1));
|
|
values.push(objekt);
|
|
}
|
|
if (jahr) {
|
|
conditions.push('jahr = $' + (values.length + 1));
|
|
values.push(jahr);
|
|
}
|
|
|
|
if (conditions.length > 0) {
|
|
query += ' WHERE ' + conditions.join(' AND ');
|
|
}
|
|
query += ' ORDER BY kategorie, name';
|
|
|
|
const result = await pool.query(query, values);
|
|
res.json(result.rows);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.post('/api/kostenplanung', async (req, res) => {
|
|
try {
|
|
const { objekt, name, kategorie, monate, jahr } = req.body;
|
|
|
|
const query = `
|
|
INSERT INTO kostenplanung (objekt, name, kategorie, monate, jahr)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
RETURNING *
|
|
`;
|
|
const result = await pool.query(query, [objekt, name, kategorie, monate, jahr || new Date().getFullYear()]);
|
|
res.json(result.rows[0]);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.put('/api/kostenplanung/:id', async (req, res) => {
|
|
try {
|
|
const { objekt, name, kategorie, monate, jahr, ist_einnahme } = req.body;
|
|
|
|
const query = `
|
|
UPDATE kostenplanung
|
|
SET objekt = $1, name = $2, kategorie = $3, monate = $4, jahr = $5, ist_einnahme = $6, updated_at = NOW()
|
|
WHERE id = $7
|
|
RETURNING *
|
|
`;
|
|
const result = await pool.query(query, [objekt, name, kategorie, monate, jahr, ist_einnahme, req.params.id]);
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Kostenplanung nicht gefunden' });
|
|
}
|
|
res.json(result.rows[0]);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/kostenplanung/:id', async (req, res) => {
|
|
try {
|
|
const result = await pool.query('DELETE FROM kostenplanung WHERE id = $1 RETURNING *', [req.params.id]);
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Kostenplanung nicht gefunden' });
|
|
}
|
|
res.json({ success: true, deleted: result.rows[0] });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Geschäftsplanung monatliche Summen API (für Dashboard)
|
|
app.get('/api/geschaeftsplanung/monatlich', async (req, res) => {
|
|
try {
|
|
const { jahr } = req.query;
|
|
const year = jahr || new Date().getFullYear();
|
|
|
|
// Hole alle Kostenplanung-Einträge für das Jahr
|
|
const result = await pool.query(
|
|
'SELECT * FROM kostenplanung WHERE jahr = $1',
|
|
[year]
|
|
);
|
|
|
|
// Berechne Summen pro Kategorie
|
|
const summen = {
|
|
einnahmen: 0,
|
|
betriebskosten: 0,
|
|
betriebsergebnis: 0
|
|
};
|
|
|
|
const monatlich = Array(12).fill(0).map(() => ({
|
|
einnahmen: 0,
|
|
betriebskosten: 0,
|
|
betriebsergebnis: 0
|
|
}));
|
|
|
|
result.rows.forEach(row => {
|
|
const monate = row.monate || Array(12).fill(0);
|
|
const kategorie = row.kategorie?.toLowerCase() || '';
|
|
|
|
if (kategorie === 'einnahmen') {
|
|
monate.forEach((betrag, idx) => {
|
|
const val = parseFloat(betrag) || 0;
|
|
monatlich[idx].einnahmen += val;
|
|
summen.einnahmen += val;
|
|
});
|
|
} else if (kategorie === 'betriebskosten') {
|
|
monate.forEach((betrag, idx) => {
|
|
const val = parseFloat(betrag) || 0;
|
|
monatlich[idx].betriebskosten += val;
|
|
summen.betriebskosten += val;
|
|
});
|
|
}
|
|
});
|
|
|
|
summen.betriebsergebnis = summen.einnahmen - summen.betriebskosten;
|
|
monatlich.forEach(m => {
|
|
m.betriebsergebnis = m.einnahmen - m.betriebskosten;
|
|
});
|
|
|
|
res.json({
|
|
jahr: year,
|
|
summen,
|
|
monatlich
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Error handler
|
|
app.use((err, req, res, next) => {
|
|
console.error(err.stack);
|
|
res.status(500).json({ error: err.message });
|
|
});
|
|
|
|
// Health check endpoint
|
|
app.get('/api/health', (req, res) => {
|
|
res.json({ status: 'ok', service: 'buchhaltung-backend', timestamp: new Date().toISOString() });
|
|
});
|
|
|
|
// Start server
|
|
initDatabase().then(() => {
|
|
app.listen(PORT, '0.0.0.0', () => {
|
|
console.log(`🚀 Backend läuft auf Port ${PORT}`);
|
|
console.log(`📁 Uploads: ${uploadsDir}`);
|
|
});
|
|
}).catch(err => {
|
|
console.error('Datenbank-Fehler:', err);
|
|
process.exit(1);
|
|
});
|
|
|
|
// Graceful shutdown
|
|
process.on('SIGTERM', async () => {
|
|
console.log('SIGTERM empfangen, schließe Verbindungen...');
|
|
await pool.end();
|
|
process.exit(0);
|
|
}); |