// 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 }); } }); };