Files
buchhaltung/backend/routes/nebenkosten_container.js
2026-04-26 07:51:39 +02:00

963 lines
37 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// API Routes für Nebenkostenabrechnung (Objekte, Mieter, Mietverträge, Kosten, Vorauszahlungen, Abrechnungen)
const { Pool } = require('pg');
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',
});
module.exports = (app) => {
// ========== OBJEKTE ==========
// Alle Objekte
app.get('/api/objekte', async (req, res) => {
try {
const result = await pool.query('SELECT * FROM objekte ORDER BY name');
res.json(result.rows);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Einzelnes Objekt
app.get('/api/objekte/:id', async (req, res) => {
try {
const result = await pool.query('SELECT * FROM objekte WHERE id = $1', [req.params.id]);
if (result.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' });
res.json(result.rows[0]);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Objekt erstellen
app.post('/api/objekte', async (req, res) => {
try {
const { name, adresse, plz, ort, wohnflaeche_qm, bemerkung } = req.body;
const result = await pool.query(
'INSERT INTO objekte (name, adresse, plz, ort, wohnflaeche_qm, bemerkung) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *',
[name, adresse, plz, ort, wohnflaeche_qm || 0, bemerkung]
);
res.json(result.rows[0]);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Objekt aktualisieren
app.put('/api/objekte/:id', async (req, res) => {
try {
const { name, adresse, plz, ort, wohnflaeche_qm, bemerkung } = req.body;
const result = await pool.query(
'UPDATE objekte SET name = $1, adresse = $2, plz = $3, ort = $4, wohnflaeche_qm = $5, bemerkung = $6 WHERE id = $7 RETURNING *',
[name, adresse, plz, ort, wohnflaeche_qm, bemerkung, req.params.id]
);
if (result.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' });
res.json(result.rows[0]);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Objekt löschen
app.delete('/api/objekte/:id', async (req, res) => {
try {
const result = await pool.query('DELETE FROM objekte WHERE id = $1 RETURNING *', [req.params.id]);
if (result.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' });
res.json({ success: true, deleted: result.rows[0] });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// ========== OBJEKTKOSTEN ==========
// Kosten für ein Objekt laden (optional gefiltert nach Jahr)
app.get('/api/objekte/:id/kosten', async (req, res) => {
try {
const { jahr } = req.query;
let query = 'SELECT * FROM objektkosten WHERE objekt_id = $1';
const params = [req.params.id];
if (jahr) {
query += ' AND jahr = $2';
params.push(jahr);
}
query += ' ORDER BY jahr DESC, kategorie ASC';
const result = await pool.query(query, params);
res.json(result.rows);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Kosten hinzufügen
app.post('/api/objekte/:id/kosten', async (req, res) => {
try {
const { kategorie, betrag, jahr } = req.body;
const result = await pool.query(
'INSERT INTO objektkosten (objekt_id, kategorie, betrag, jahr) VALUES ($1, $2, $3, $4) RETURNING *',
[req.params.id, kategorie, betrag, jahr]
);
res.json(result.rows[0]);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Kosten löschen
app.delete('/api/objekte/:id/kosten/:kostenId', async (req, res) => {
try {
await pool.query('DELETE FROM objektkosten WHERE id = $1 AND objekt_id = $2', [req.params.kostenId, req.params.id]);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Mieter eines Objekts
app.get('/api/objekte/:id/mieter', async (req, res) => {
try {
const result = await pool.query(
`SELECT m.*, mv.id as mietvertrag_id, mv.wohnflaeche_qm, mv.kaltmiete,
mv.nebenkosten_vorauszahlung, mv.vertragsbeginn, mv.vertragsende, mv.ist_aktuell
FROM mieter m
JOIN mietvertraege mv ON mv.mieter_id = m.id
WHERE mv.objekt_id = $1
ORDER BY m.name`,
[req.params.id]
);
res.json(result.rows);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// ========== MIETER ==========
app.get('/api/mieter', async (req, res) => {
try {
const result = await pool.query('SELECT * FROM mieter ORDER BY name');
res.json(result.rows);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/api/mieter/:id', async (req, res) => {
try {
const result = await pool.query('SELECT * FROM mieter WHERE id = $1', [req.params.id]);
if (result.rows.length === 0) return res.status(404).json({ error: 'Mieter nicht gefunden' });
res.json(result.rows[0]);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.post('/api/mieter', async (req, res) => {
try {
const { name, email, telefon, adresse } = req.body;
const result = await pool.query(
'INSERT INTO mieter (name, email, telefon, adresse) VALUES ($1, $2, $3, $4) RETURNING *',
[name, email, telefon, adresse]
);
res.json(result.rows[0]);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.put('/api/mieter/:id', async (req, res) => {
try {
const { name, email, telefon, adresse } = req.body;
const result = await pool.query(
'UPDATE mieter SET name = $1, email = $2, telefon = $3, adresse = $4 WHERE id = $5 RETURNING *',
[name, email, telefon, adresse, req.params.id]
);
if (result.rows.length === 0) return res.status(404).json({ error: 'Mieter nicht gefunden' });
res.json(result.rows[0]);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.delete('/api/mieter/:id', async (req, res) => {
try {
const result = await pool.query('DELETE FROM mieter WHERE id = $1 RETURNING *', [req.params.id]);
if (result.rows.length === 0) return res.status(404).json({ error: 'Mieter nicht gefunden' });
res.json({ success: true, deleted: result.rows[0] });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Mietverträge eines Mieters
app.get('/api/mieter/:id/mietvertraege', async (req, res) => {
try {
const result = await pool.query(
`SELECT mv.*, o.name as objekt_name
FROM mietvertraege mv
JOIN objekte o ON o.id = mv.objekt_id
WHERE mv.mieter_id = $1
ORDER BY mv.vertragsbeginn DESC`,
[req.params.id]
);
res.json(result.rows);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// ========== MIETVERTRÄGE ==========
app.get('/api/mietvertraege', async (req, res) => {
try {
const result = await pool.query(
`SELECT mv.*, m.name as mieter_name, o.name as objekt_name
FROM mietvertraege mv
JOIN mieter m ON m.id = mv.mieter_id
JOIN objekte o ON o.id = mv.objekt_id
ORDER BY mv.vertragsbeginn DESC`
);
res.json(result.rows);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// ========== MIETVERTRÄGE ==========
app.get('/api/mietvertraege', async (req, res) => {
try {
const { objekt_id, ist_aktuell } = req.query;
let query = `SELECT mv.*, o.name as objekt_name, o.adresse, o.plz, o.ort,
m.name as mieter_name, m.email as mieter_email
FROM mietvertraege mv
JOIN objekte o ON o.id = mv.objekt_id
JOIN mieter m ON m.id = mv.mieter_id`;
const values = [];
const conditions = [];
if (objekt_id) {
conditions.push(`mv.objekt_id = $${values.length + 1}`);
values.push(objekt_id);
}
if (ist_aktuell !== undefined) {
conditions.push(`mv.ist_aktuell = $${values.length + 1}`);
values.push(ist_aktuell === 'true');
}
if (conditions.length > 0) {
query += ' WHERE ' + conditions.join(' AND ');
}
query += ' ORDER BY mv.vertragsbeginn DESC';
const result = await pool.query(query, values);
res.json(result.rows);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.post('/api/mietvertraege', async (req, res) => {
try {
const { objekt_id, mieter_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsbeginn } = req.body;
const result = await pool.query(
`INSERT INTO mietvertraege (objekt_id, mieter_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsbeginn)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
[objekt_id, mieter_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung || 0, vertragsbeginn]
);
res.json(result.rows[0]);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.put('/api/mietvertraege/:id', async (req, res) => {
try {
const { wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsende, ist_aktuell } = req.body;
const result = await pool.query(
`UPDATE mietvertraege SET wohnflaeche_qm = $1, kaltmiete = $2, nebenkosten_vorauszahlung = $3,
vertragsende = $4, ist_aktuell = $5 WHERE id = $6 RETURNING *`,
[wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsende, ist_aktuell !== undefined ? ist_aktuell : true, req.params.id]
);
if (result.rows.length === 0) return res.status(404).json({ error: 'Mietvertrag nicht gefunden' });
res.json(result.rows[0]);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.post('/api/mietvertraege/:id/beenden', async (req, res) => {
try {
const { vertragsende } = req.body;
const result = await pool.query(
`UPDATE mietvertraege SET vertragsende = $1, ist_aktuell = false WHERE id = $2 RETURNING *`,
[vertragsende, req.params.id]
);
if (result.rows.length === 0) return res.status(404).json({ error: 'Mietvertrag nicht gefunden' });
res.json(result.rows[0]);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.delete('/api/mietvertraege/:id', async (req, res) => {
try {
const result = await pool.query('DELETE FROM mietvertraege WHERE id = $1 RETURNING *', [req.params.id]);
if (result.rows.length === 0) return res.status(404).json({ error: 'Mietvertrag nicht gefunden' });
res.json({ success: true, deleted: result.rows[0] });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// ========== OBJEKTKOSTEN ==========
app.get('/api/objektkosten', async (req, res) => {
try {
const { objekt_id, jahr } = req.query;
let query = 'SELECT ok.*, o.name as objekt_name FROM objektkosten ok JOIN objekte o ON o.id = ok.objekt_id';
const values = [];
const conditions = [];
if (objekt_id) {
conditions.push(`ok.objekt_id = $${values.length + 1}`);
values.push(objekt_id);
}
if (jahr) {
conditions.push(`ok.jahr = $${values.length + 1}`);
values.push(parseInt(jahr));
}
if (conditions.length > 0) {
query += ' WHERE ' + conditions.join(' AND ');
}
query += ' ORDER BY ok.jahr DESC, ok.kategorie, ok.datum';
const result = await pool.query(query, values);
res.json(result.rows);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.post('/api/objektkosten', async (req, res) => {
try {
const { objekt_id, kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung } = req.body;
const result = await pool.query(
`INSERT INTO objektkosten (objekt_id, kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
[objekt_id, kategorie, bezeichnung, betrag, datum, jahr || new Date().getFullYear(), verteilung || 'qm', bemerkung]
);
res.json(result.rows[0]);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.put('/api/objektkosten/:id', async (req, res) => {
try {
const { kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung } = req.body;
const result = await pool.query(
`UPDATE objektkosten SET kategorie = $1, bezeichnung = $2, betrag = $3, datum = $4, jahr = $5, verteilung = $6, bemerkung = $7
WHERE id = $8 RETURNING *`,
[kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung, req.params.id]
);
if (result.rows.length === 0) return res.status(404).json({ error: 'Kosten nicht gefunden' });
res.json(result.rows[0]);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.delete('/api/objektkosten/:id', async (req, res) => {
try {
const result = await pool.query('DELETE FROM objektkosten WHERE id = $1 RETURNING *', [req.params.id]);
if (result.rows.length === 0) return res.status(404).json({ error: 'Kosten nicht gefunden' });
res.json({ success: true, deleted: result.rows[0] });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// ========== VORAUSZAHLUNGEN ==========
app.get('/api/vorauszahlungen', async (req, res) => {
try {
const { objekt_id, mieter_id, jahr } = req.query;
let query = `SELECT v.*, o.name as objekt_name, m.name as mieter_name
FROM vorauszahlungen v
JOIN objekte o ON o.id = v.objekt_id
JOIN mieter m ON m.id = v.mieter_id`;
const values = [];
const conditions = [];
if (objekt_id) {
conditions.push(`v.objekt_id = $${values.length + 1}`);
values.push(objekt_id);
}
if (mieter_id) {
conditions.push(`v.mieter_id = $${values.length + 1}`);
values.push(mieter_id);
}
if (jahr) {
conditions.push(`v.jahr = $${values.length + 1}`);
values.push(parseInt(jahr));
}
if (conditions.length > 0) {
query += ' WHERE ' + conditions.join(' AND ');
}
query += ' ORDER BY v.jahr DESC, v.monat';
const result = await pool.query(query, values);
res.json(result.rows);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.post('/api/vorauszahlungen', async (req, res) => {
try {
const { objekt_id, mieter_id, jahr, monat, betrag, bezahlt_am, bemerkung } = req.body;
const result = await pool.query(
`INSERT INTO vorauszahlungen (objekt_id, mieter_id, jahr, monat, betrag, bezahlt_am, bemerkung)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
[objekt_id, mieter_id, jahr, monat, betrag, bezahlt_am, bemerkung]
);
res.json(result.rows[0]);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Bulk: Vorauszahlungen für Jahr erstellen
app.post('/api/vorauszahlungen/bulk', async (req, res) => {
const client = await pool.connect();
try {
await client.query('BEGIN');
const { objektId, mieterId, jahr, monatlicherBetrag } = req.body;
const results = [];
for (let monat = 1; monat <= 12; monat++) {
const result = await client.query(
`INSERT INTO vorauszahlungen (objekt_id, mieter_id, jahr, monat, betrag)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (objekt_id, mieter_id, jahr, monat)
DO UPDATE SET betrag = $5
RETURNING *`,
[objektId, mieterId, jahr, monat, monatlicherBetrag]
);
results.push(result.rows[0]);
}
await client.query('COMMIT');
res.json({ success: true, created: results.length, vorauszahlungen: results });
} catch (error) {
await client.query('ROLLBACK');
res.status(500).json({ error: error.message });
} finally {
client.release();
}
});
app.put('/api/vorauszahlungen/:id', async (req, res) => {
try {
const { betrag, bezahlt_am, bemerkung } = req.body;
const result = await pool.query(
`UPDATE vorauszahlungen SET betrag = $1, bezahlt_am = $2, bemerkung = $3 WHERE id = $4 RETURNING *`,
[betrag, bezahlt_am, bemerkung, req.params.id]
);
if (result.rows.length === 0) return res.status(404).json({ error: 'Vorauszahlung nicht gefunden' });
res.json(result.rows[0]);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.delete('/api/vorauszahlungen/:id', async (req, res) => {
try {
const result = await pool.query('DELETE FROM vorauszahlungen WHERE id = $1 RETURNING *', [req.params.id]);
if (result.rows.length === 0) return res.status(404).json({ error: 'Vorauszahlung nicht gefunden' });
res.json({ success: true, deleted: result.rows[0] });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// ========== NEBENKOSTENABRECHNUNG ==========
// Übersicht
app.get('/api/nebenkostenabrechnung/uebersicht', async (req, res) => {
try {
const { jahr = new Date().getFullYear() } = req.query;
// Alle Objekte mit Summen
const result = await pool.query(`
SELECT o.*,
COALESCE(k.gesamt_kosten, 0) as gesamt_kosten,
COALESCE(v.gesamt_vorauszahlungen, 0) as gesamt_vorauszahlungen,
COALESCE(m.anzahl_mieter, 0) as anzahl_mieter
FROM objekte o
LEFT JOIN (
SELECT objekt_id, SUM(betrag) as gesamt_kosten
FROM objektkosten WHERE jahr = $1 GROUP BY objekt_id
) k ON k.objekt_id = o.id
LEFT JOIN (
SELECT objekt_id, SUM(betrag) as gesamt_vorauszahlungen
FROM vorauszahlungen WHERE jahr = $1 GROUP BY objekt_id
) v ON v.objekt_id = o.id
LEFT JOIN (
SELECT objekt_id, COUNT(*) as anzahl_mieter
FROM mietvertraege WHERE ist_aktuell = true GROUP BY objekt_id
) m ON m.objekt_id = o.id
ORDER BY o.name
`, [jahr]);
res.json({ jahr: parseInt(jahr), objekte: result.rows });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Abrechnung Vorschau (Berechnung)
app.post('/api/nebenkostenabrechnung/vorschau', async (req, res) => {
try {
const { objektId, jahr, zeitraumVon, zeitraumBis } = req.body;
// Objekt-Daten
const objektResult = await pool.query('SELECT * FROM objekte WHERE id = $1', [objektId]);
if (objektResult.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' });
const objekt = objektResult.rows[0];
// Alle Kosten des Objekts im Jahr
const kostenResult = await pool.query(
'SELECT * FROM objektkosten WHERE objekt_id = $1 AND jahr = $2 ORDER BY kategorie',
[objektId, jahr]
);
// Aktuelle Mieter mit Mietverträgen
const mieterResult = await pool.query(`
SELECT mv.*, m.name as mieter_name, m.email as mieter_email
FROM mietvertraege mv
JOIN mieter m ON m.id = mv.mieter_id
WHERE mv.objekt_id = $1 AND mv.ist_aktuell = true
`, [objektId]);
// Berechnung
const gesamtKosten = kostenResult.rows.reduce((sum, k) => sum + parseFloat(k.betrag), 0);
const gesamtFlaeche = mieterResult.rows.reduce((sum, m) => sum + parseFloat(m.wohnflaeche_qm), 0);
// Tage im Abrechnungszeitraum
const vonDatum = new Date(zeitraumVon);
const bisDatum = new Date(zeitraumBis);
const tageGesamt = Math.ceil((bisDatum - vonDatum) / (1000 * 60 * 60 * 24)) + 1;
// Pro Mieter berechnen
const berechnungen = [];
for (const mietvertrag of mieterResult.rows) {
// Vorauszahlungen dieses Mieters
const vorauszahlungenResult = await pool.query(
'SELECT SUM(betrag) as summe FROM vorauszahlungen WHERE objekt_id = $1 AND mieter_id = $2 AND jahr = $3',
[objektId, mietvertrag.mieter_id, jahr]
);
const summeVorauszahlungen = parseFloat(vorauszahlungenResult.rows[0]?.summe || 0);
// Anteil berechnen (pro-rata bei Mieterwechsel möglich)
const anteilQm = parseFloat(mietvertrag.wohnflaeche_qm);
const anteilTage = tageGesamt; // Hier könnte differenzierte Logik für Ein-/Auszug stehen
const anteilKosten = gesamtKosten * (anteilQm / gesamtFlaeche);
const ergebnis = anteilKosten - summeVorauszahlungen;
berechnungen.push({
mietvertrag_id: mietvertrag.id,
mieter_id: mietvertrag.mieter_id,
mieter_name: mietvertrag.mieter_name,
anteil_qm: anteilQm,
anteil_tage: anteilTage,
anteil_kosten: Math.round(anteilKosten * 100) / 100,
summe_vorauszahlungen: summeVorauszahlungen,
ergebnis: Math.round(ergebnis * 100) / 100,
ist_nachzahlung: ergebnis > 0,
betrag_nachzahlung: ergebnis > 0 ? ergebnis : 0,
betrag_gutschrift: ergebnis < 0 ? Math.abs(ergebnis) : 0
});
}
res.json({
objekt,
jahr,
zeitraum_von: zeitraumVon,
zeitraum_bis: zeitraumBis,
tage_gesamt: tageGesamt,
gesamt_kosten: gesamtKosten,
gesamt_flaeche: gesamtFlaeche,
kosten: kostenResult.rows,
mieter: mieterResult.rows,
berechnungen
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Abrechnung speichern
app.post('/api/nebenkostenabrechnung', async (req, res) => {
const client = await pool.connect();
try {
await client.query('BEGIN');
const { objekt_id, jahr, zeitraum_von, zeitraum_bis, ist_entwurf, berechnungen } = req.body;
// Bestehende Abrechnung prüfen/löschen
await client.query(
'DELETE FROM nebenkostenabrechnungen WHERE objekt_id = $1 AND jahr = $2',
[objekt_id, jahr]
);
// Neue Abrechnung erstellen
const abrechnungResult = await client.query(
`INSERT INTO nebenkostenabrechnungen (objekt_id, jahr, zeitraum_von, zeitraum_bis, ist_entwurf)
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
[objekt_id, jahr, zeitraum_von, zeitraum_bis, ist_entwurf !== false]
);
const abrechnung = abrechnungResult.rows[0];
// Positionen speichern
for (const pos of berechnungen) {
await client.query(
`INSERT INTO abrechnungspositionen
(abrechnung_id, mieter_id, mietvertrag_id, anteil_qm, anteil_tage, anteil_kosten, summe_vorauszahlungen, ergebnis)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[abrechnung.id, pos.mieter_id, pos.mietvertrag_id, pos.anteil_qm, pos.anteil_tage,
pos.anteil_kosten, pos.summe_vorauszahlungen, pos.ergebnis]
);
}
await client.query('COMMIT');
res.json({ success: true, abrechnung: abrechnungResult.rows[0] });
} catch (error) {
await client.query('ROLLBACK');
res.status(500).json({ error: error.message });
} finally {
client.release();
}
});
// Alle Abrechnungen
app.get('/api/nebenkostenabrechnung', async (req, res) => {
try {
const result = await pool.query(`
SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort
FROM nebenkostenabrechnungen na
JOIN objekte o ON o.id = na.objekt_id
ORDER BY na.jahr DESC, o.name
`);
res.json(result.rows);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Alias für Frontend (Plural)
app.get('/api/nebenkostenabrechnungen', async (req, res) => {
try {
const result = await pool.query(`
SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort
FROM nebenkostenabrechnungen na
JOIN objekte o ON o.id = na.objekt_id
ORDER BY na.jahr DESC, o.name
`);
res.json(result.rows);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Einzelne Abrechnung mit Positionen
app.get('/api/nebenkostenabrechnung/:id', async (req, res) => {
try {
const abrechnungResult = await pool.query(`
SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort
FROM nebenkostenabrechnungen na
JOIN objekte o ON o.id = na.objekt_id
WHERE na.id = $1
`, [req.params.id]);
if (abrechnungResult.rows.length === 0) {
return res.status(404).json({ error: 'Abrechnung nicht gefunden' });
}
const positionenResult = await pool.query(`
SELECT ap.*, m.name as mieter_name, m.email as mieter_email
FROM abrechnungspositionen ap
JOIN mieter m ON m.id = ap.mieter_id
WHERE ap.abrechnung_id = $1
`, [req.params.id]);
const kostenResult = await pool.query(`
SELECT * FROM objektkosten
WHERE objekt_id = $1 AND jahr = $2
`, [abrechnungResult.rows[0].objekt_id, abrechnungResult.rows[0].jahr]);
res.json({
abrechnung: abrechnungResult.rows[0],
positionen: positionenResult.rows,
kosten: kostenResult.rows
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Status aktualisieren (Entwurf/Final)
app.put('/api/nebenkostenabrechnung/:id/status', async (req, res) => {
try {
const { ist_entwurf } = req.body;
const result = await pool.query(
'UPDATE nebenkostenabrechnungen SET ist_entwurf = $1 WHERE id = $2 RETURNING *',
[ist_entwurf, req.params.id]
);
if (result.rows.length === 0) return res.status(404).json({ error: 'Abrechnung nicht gefunden' });
res.json(result.rows[0]);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// PDF Export - Privat (René Täger) - EINE PDF pro Mieter
app.post('/api/nebenkostenabrechnung/:id/pdf', async (req, res) => {
try {
const { PDFDocument, rgb, StandardFonts } = require('pdf-lib');
const abrechnungResult = await pool.query(`
SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort, o.wohnflaeche_qm as objekt_flaeche
FROM nebenkostenabrechnungen na
JOIN objekte o ON o.id = na.objekt_id
WHERE na.id = $1
`, [req.params.id]);
if (abrechnungResult.rows.length === 0) {
return res.status(404).json({ error: 'Abrechnung nicht gefunden' });
}
const abrechnung = abrechnungResult.rows[0];
// ALLE Positionen laden
const positionenResult = await pool.query(`
SELECT ap.*, m.name as mieter_name, m.email as mieter_email, m.adresse as mieter_adresse
FROM abrechnungspositionen ap
JOIN mieter m ON m.id = ap.mieter_id
WHERE ap.abrechnung_id = $1
`, [req.params.id]);
const kostenResult = await pool.query(`
SELECT * FROM objektkosten
WHERE objekt_id = $1 AND jahr = $2 ORDER BY kategorie
`, [abrechnung.objekt_id, abrechnung.jahr]);
// Berechnungsgrundlagen
const gesamtKosten = kostenResult.rows.reduce((sum, k) => sum + parseFloat(k.betrag), 0);
const gesamtFlaeche = parseFloat(abrechnung.objekt_flaeche || 95);
const kostenProQm = gesamtKosten / gesamtFlaeche;
// PDF erstellen
const pdfDoc = await PDFDocument.create();
const width = 595.28;
const height = 841.89;
const margin = 50;
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
const istEntwurf = req.query.entwurf === 'true';
const heute = new Date().toLocaleDateString('de-DE');
// Hilfsfunktion für Trennlinie
function drawLine(page, yPos) {
page.drawLine({
start: { x: margin, y: yPos },
end: { x: width - margin, y: yPos },
thickness: 0.5,
color: rgb(0.8, 0.8, 0.8)
});
}
// ========== FÜR JEDEN MIETER EINE SEITE ==========
for (const pos of positionenResult.rows) {
const mieterName = pos.mieter_name;
const mieterAdresse = pos.mieter_adresse || '';
const mieterFlaeche = parseFloat(pos.anteil_qm);
const anteilKosten = parseFloat(pos.anteil_kosten);
const vorauszahlungen = parseFloat(pos.summe_vorauszahlungen);
const ergebnis = parseFloat(pos.ergebnis);
// Neue Seite für diesen Mieter
const page = pdfDoc.addPage([width, height]);
let y = height - 50;
// ========== ABSENDER (oben links) ==========
page.drawText('René Täger', { x: margin, y, size: 10, font: fontBold });
y -= 14;
page.drawText('Zingelstraße 7', { x: margin, y, size: 9, font });
y -= 14;
page.drawText('25554 Wilster', { x: margin, y, size: 9, font });
y -= 30;
// Trennlinie
drawLine(page, y);
y -= 30;
// ========== EMPFÄNGER (nur dieser Mieter) ==========
page.drawText(mieterName, { x: margin, y, size: 11, font: fontBold });
y -= 16;
if (mieterAdresse) {
const adressZeilen = mieterAdresse.split('\n');
for (const zeile of adressZeilen) {
page.drawText(zeile, { x: margin, y, size: 10, font });
y -= 14;
}
}
y -= 40;
// ========== DATUM + BETREFF ==========
page.drawText(`Wilster, den ${heute}`, { x: width - margin - 150, y: height - 120, size: 9, font });
page.drawText('Nebenkostenabrechnung', { x: margin, y, size: 16, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
y -= 25;
if (istEntwurf) {
page.drawText('ENTWURF', { x: width - 140, y: y + 15, size: 20, font: fontBold, color: rgb(0.8, 0.3, 0.3) });
}
page.drawText(`für das Jahr ${abrechnung.jahr}`, { x: margin, y, size: 12, font });
y -= 20;
page.drawText(`Objekt: ${abrechnung.objekt_name}`, { x: margin, y, size: 11, font: fontBold });
y -= 16;
page.drawText(`${abrechnung.adresse}, ${abrechnung.plz} ${abrechnung.ort}`, { x: margin, y, size: 10, font });
y -= 16;
page.drawText(`Abrechnungszeitraum: ${new Date(abrechnung.zeitraum_von).toLocaleDateString('de-DE')} - ${new Date(abrechnung.zeitraum_bis).toLocaleDateString('de-DE')}`, { x: margin, y, size: 10, font });
y -= 35;
// ========== BEGLEITTEXT ==========
page.drawText('Sehr geehrte(r) Mieter(in),', { x: margin, y, size: 10, font: fontBold });
y -= 18;
page.drawText('im Folgenden erhalten Sie Ihre persönliche Nebenkostenabrechnung.', { x: margin, y, size: 9, font });
y -= 14;
page.drawText('Die Kosten wurden nach Ihrem im Mietvertrag vereinbarten Anteil umgelegt.', { x: margin, y, size: 9, font });
y -= 30;
// ========== 1. KOSTENÜBERSICHT (alle Kosten des Objekts) ==========
page.drawText('1. Kostenübersicht des Objekts:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
y -= 20;
// Tabellenkopf
page.drawText('Kategorie', { x: margin, y, size: 9, font: fontBold });
page.drawText('Bezeichnung', { x: margin + 100, y, size: 9, font: fontBold });
page.drawText('Betrag', { x: margin + 350, y, size: 9, font: fontBold });
y -= 12;
drawLine(page, y + 8);
y -= 5;
for (const k of kostenResult.rows) {
const betrag = parseFloat(k.betrag);
page.drawText(k.kategorie, { x: margin, y, size: 9, font });
page.drawText(k.bezeichnung, { x: margin + 100, y, size: 9, font });
page.drawText(`${betrag.toFixed(2)}`, { x: margin + 350, y, size: 9, font });
y -= 14;
}
y -= 5;
drawLine(page, y + 8);
y -= 5;
page.drawText('Gesamtkosten:', { x: margin + 100, y, size: 10, font: fontBold });
page.drawText(`${gesamtKosten.toFixed(2)}`, { x: margin + 350, y, size: 10, font: fontBold });
y -= 30;
// ========== 2. BERECHNUNGSGRUNDLAGE ==========
page.drawText('2. Berechnungsgrundlage:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
y -= 20;
page.drawText(`Gesamtkosten:`, { x: margin + 20, y, size: 9, font });
page.drawText(`${gesamtKosten.toFixed(2)}`, { x: margin + 250, y, size: 9, font });
y -= 14;
page.drawText(`Gesamtwohnfläche:`, { x: margin + 20, y, size: 9, font });
page.drawText(`${gesamtFlaeche.toFixed(2)} qm`, { x: margin + 250, y, size: 9, font });
y -= 14;
page.drawText(`Kosten pro qm:`, { x: margin + 20, y, size: 9, font: fontBold });
page.drawText(`${gesamtKosten.toFixed(2)} € / ${gesamtFlaeche.toFixed(2)} qm = ${kostenProQm.toFixed(2)} €/qm`, { x: margin + 250, y, size: 9, font: fontBold });
y -= 30;
// ========== 3. IHRE PERSÖNLICHE BERECHNUNG ==========
page.drawText('3. Ihre persönliche Berechnung:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
y -= 20;
page.drawText(`Ihr Name:`, { x: margin + 20, y, size: 9, font });
page.drawText(mieterName, { x: margin + 250, y, size: 9, font: fontBold });
y -= 14;
page.drawText(`Ihre Wohnfläche:`, { x: margin + 20, y, size: 9, font });
page.drawText(`${mieterFlaeche.toFixed(2)} qm`, { x: margin + 250, y, size: 9, font });
y -= 14;
page.drawText(`Ihr Anteil an den Kosten:`, { x: margin + 20, y, size: 9, font });
page.drawText(`${kostenProQm.toFixed(2)} €/qm × ${mieterFlaeche.toFixed(2)} qm = ${anteilKosten.toFixed(2)}`, { x: margin + 250, y, size: 9, font });
y -= 14;
page.drawText(`Geleistete Vorauszahlungen:`, { x: margin + 20, y, size: 9, font });
page.drawText(`${vorauszahlungen.toFixed(2)}`, { x: margin + 250, y, size: 9, font });
y -= 25;
// Ergebnis hervorgehoben
drawLine(page, y + 8);
y -= 5;
if (ergebnis > 0) {
page.drawText(`Nachzahlung:`, { x: margin + 20, y, size: 11, font: fontBold });
page.drawText(`${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € = ${ergebnis.toFixed(2)}`, { x: margin + 250, y, size: 11, font: fontBold });
y -= 20;
page.drawText(`Bitte überweisen Sie den Betrag innerhalb von 14 Tagen.`, { x: margin + 20, y, size: 9, font, color: rgb(0.8, 0.2, 0.2) });
} else {
page.drawText(`Gutschrift:`, { x: margin + 20, y, size: 11, font: fontBold });
page.drawText(`${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € = ${Math.abs(ergebnis).toFixed(2)}`, { x: margin + 250, y, size: 11, font: fontBold });
y -= 20;
page.drawText(`Der Betrag wird mit der nächsten Miete verrechnet.`, { x: margin + 20, y, size: 9, font, color: rgb(0.2, 0.6, 0.2) });
}
y -= 25;
// ========== FUSSZEILE ==========
y -= 20;
drawLine(page, y);
y -= 20;
page.drawText('Diese Abrechnung wurde maschinell erstellt und ist ohne Unterschrift gültig.', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) });
y -= 12;
page.drawText('Bei Rückfragen: René Täger | Tel: +49 15563 717612 | E-Mail: info@taeger-it.de', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) });
}
// PDF speichern
const pdfBytes = await pdfDoc.save();
const pdfBuffer = Buffer.from(pdfBytes.buffer || pdfBytes);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Length', pdfBuffer.length);
res.setHeader('Content-Disposition', `attachment; filename="nebenkostenabrechnung-${abrechnung.jahr}-${abrechnung.objekt_name.replace(/\s+/g, '_')}.pdf"`);
res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition');
res.end(pdfBuffer);
} catch (error) {
console.error('PDF Error:', error);
res.status(500).json({ error: error.message });
}
});
};