// 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; // Baue dynamisches UPDATE - nur Felder die gesendet wurden const updates = []; const values = []; let paramIndex = 1; if (name !== undefined) { updates.push(`name = $${paramIndex++}`); values.push(name); } if (adresse !== undefined) { updates.push(`adresse = $${paramIndex++}`); values.push(adresse); } if (plz !== undefined) { updates.push(`plz = $${paramIndex++}`); values.push(plz); } if (ort !== undefined) { updates.push(`ort = $${paramIndex++}`); values.push(ort); } if (wohnflaeche_qm !== undefined) { updates.push(`wohnflaeche_qm = $${paramIndex++}`); values.push(wohnflaeche_qm); } if (bemerkung !== undefined) { updates.push(`bemerkung = $${paramIndex++}`); values.push(bemerkung); } if (updates.length === 0) { return res.status(400).json({ error: 'Keine Felder zum Aktualisieren' }); } values.push(req.params.id); const query = `UPDATE objekte SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING *`; const result = await pool.query(query, values); 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 }); } }); // Mieter mit Mietvertrag erstellen (Combined) app.post('/api/mieter-mit-vertrag', async (req, res) => { const client = await pool.connect(); try { await client.query('BEGIN'); const { name, email, telefon, adresse, objekt_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsbeginn, vertragsende } = req.body; // Mieter erstellen const mieterResult = await client.query( 'INSERT INTO mieter (name, email, telefon, adresse) VALUES ($1, $2, $3, $4) RETURNING *', [name, email, telefon, adresse] ); const mieter = mieterResult.rows[0]; // Mietvertrag erstellen const vertragResult = await client.query( `INSERT INTO mietvertraege (objekt_id, mieter_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsbeginn, vertragsende, ist_aktuell) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, [objekt_id, mieter.id, wohnflaeche_qm || 0, kaltmiete || 0, nebenkosten_vorauszahlung || 0, vertragsbeginn, vertragsende || null, !vertragsende] ); await client.query('COMMIT'); res.json({ mieter, mietvertrag: vertragResult.rows[0] }); } catch (error) { await client.query('ROLLBACK'); res.status(500).json({ error: error.message }); } finally { client.release(); } }); // Mieter mit Mietvertrag aktualisieren (Combined) app.put('/api/mieter/:id/mit-vertrag', async (req, res) => { const client = await pool.connect(); try { await client.query('BEGIN'); const { name, email, telefon, adresse, objekt_id, kaltmiete, vorauszahlung, vertragsbeginn, vertragsende } = req.body; // ist_aktuell automatisch berechnen: true wenn kein vertragsende oder vertragsende in Zukunft const istAktuell = !vertragsende || new Date(vertragsende) >= new Date(); // Mieter aktualisieren const mieterResult = await client.query( 'UPDATE mieter SET name = $1, email = $2, telefon = $3, adresse = $4 WHERE id = $5 RETURNING *', [name, email, telefon, adresse, req.params.id] ); if (mieterResult.rows.length === 0) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'Mieter nicht gefunden' }); } // Aktiven Mietvertrag finden und aktualisieren const vertragResult = await client.query( `UPDATE mietvertraege SET objekt_id = $1, kaltmiete = $2, nebenkosten_vorauszahlung = $3, vertragsbeginn = $4, vertragsende = $5, ist_aktuell = $6 WHERE mieter_id = $7 AND (ist_aktuell = true OR id = ( SELECT id FROM mietvertraege WHERE mieter_id = $7 ORDER BY vertragsbeginn DESC LIMIT 1 )) RETURNING *`, [objekt_id, kaltmiete, vorauszahlung, vertragsbeginn, vertragsende || null, istAktuell, req.params.id] ); await client.query('COMMIT'); res.json({ mieter: mieterResult.rows[0], mietvertrag: vertragResult.rows[0] || null }); } catch (error) { await client.query('ROLLBACK'); res.status(500).json({ error: error.message }); } finally { client.release(); } }); // 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 }); } }); // ========== VERMIETUNGSBILANZ ========== // Hilfsfunktion: Berechne überlappende Monate zwischen zwei Zeiträumen function calculateOverlappingMonths(startA, endA, startB, endB) { const start = new Date(Math.max(startA.getTime(), startB.getTime())); const end = new Date(Math.min(endA.getTime(), endB.getTime())); if (start > end) return 0; // Monate berechnen (inklusive Start- und Endmonat) const months = (end.getFullYear() - start.getFullYear()) * 12 + (end.getMonth() - start.getMonth()) + 1; return Math.max(0, months); } // Einnahmen und Ausgaben pro Objekt für Vermietungs-Bilanz app.get('/api/vermietungsbilanz', async (req, res) => { try { const { jahr = new Date().getFullYear() } = req.query; const yearStart = new Date(jahr, 0, 1); // 1. Januar const yearEnd = new Date(jahr, 11, 31); // 31. Dezember // Alle Objekte laden const objekteResult = await pool.query('SELECT id, name FROM objekte ORDER BY name'); const bilanzDaten = []; let gesamtEinnahmen = 0; let gesamtAusgaben = 0; for (const objekt of objekteResult.rows) { // === EINNAHMEN: Alle Mietverträge mit Zeitraumberechnung === // Mietverträge laden die im Jahr aktiv waren const vertraegeResult = await pool.query(` SELECT mv.*, m.name as mieter_name FROM mietvertraege mv JOIN mieter m ON m.id = mv.mieter_id WHERE mv.objekt_id = $1 AND mv.vertragsbeginn <= $2 AND (mv.vertragsende IS NULL OR mv.vertragsende >= $3) `, [objekt.id, yearEnd, yearStart]); // Einnahmen pro Vertrag berechnen let objektEinnahmen = 0; const einnahmenDetails = []; for (const vertrag of vertraegeResult.rows) { const vertragsBeginn = new Date(vertrag.vertragsbeginn); const vertragsEnde = vertrag.vertragsende ? new Date(vertrag.vertragsende) : null; // Überlappende Monate berechnen const effectiveStart = vertragsBeginn > yearStart ? vertragsBeginn : yearStart; const effectiveEnd = (vertragsEnde && vertragsEnde < yearEnd) ? vertragsEnde : yearEnd; const months = calculateOverlappingMonths( vertragsBeginn, vertragsEnde || new Date(2099, 11, 31), yearStart, yearEnd ); const kaltmiete = parseFloat(vertrag.kaltmiete) || 0; const vertragEinnahmen = kaltmiete * months; objektEinnahmen += vertragEinnahmen; einnahmenDetails.push({ mieter_id: vertrag.mieter_id, mieter_name: vertrag.mieter_name, vertragsbeginn: vertrag.vertragsbeginn, vertragsende: vertrag.vertragsende, kaltmiete_monat: kaltmiete, monate: months, summe: Math.round(vertragEinnahmen * 100) / 100 }); } // === AUSGABEN: Nebenkosten für das Jahr === const ausgabenResult = await pool.query(` SELECT COALESCE(SUM(betrag), 0) as nebenkosten FROM objektkosten WHERE objekt_id = $1 AND jahr = $2 `, [objekt.id, jahr]); const nebenkosten = parseFloat(ausgabenResult.rows[0].nebenkosten); // Detaillierte Ausgaben nach Kategorie const kategorienResult = await pool.query(` SELECT kategorie, SUM(betrag) as betrag FROM objektkosten WHERE objekt_id = $1 AND jahr = $2 GROUP BY kategorie ORDER BY SUM(betrag) DESC `, [objekt.id, jahr]); // Detaillierte Ausgaben für Modal const ausgabenDetailsResult = await pool.query(` SELECT id, kategorie, bezeichnung, betrag, datum FROM objektkosten WHERE objekt_id = $1 AND jahr = $2 ORDER BY betrag DESC `, [objekt.id, jahr]); const bilanz = objektEinnahmen - nebenkosten; gesamtEinnahmen += objektEinnahmen; gesamtAusgaben += nebenkosten; // Anzahl aktiver Mieter const aktiveMieter = einnahmenDetails.length; bilanzDaten.push({ objekt_id: objekt.id, objekt_name: objekt.name, mieteinnahmen: Math.round(objektEinnahmen * 100) / 100, nebenkosten: Math.round(nebenkosten * 100) / 100, bilanz: Math.round(bilanz * 100) / 100, anzahl_mieter: aktiveMieter, kategorien: kategorienResult.rows, // Details für Drill-down einnahmen_details: einnahmenDetails, ausgaben_details: ausgabenDetailsResult.rows }); } res.json({ jahr: parseInt(jahr), objekte: bilanzDaten, gesamt_einnahmen: Math.round(gesamtEinnahmen * 100) / 100, gesamt_ausgaben: Math.round(gesamtAusgaben * 100) / 100, gesamt_bilanz: Math.round((gesamtEinnahmen - gesamtAusgaben) * 100) / 100 }); } catch (error) { console.error('Vermietungsbilanz Error:', error); res.status(500).json({ error: error.message }); } }); // Alias für Frontend-Kompatibilität mit DETAILS app.get('/api/vermietung/bilanz', async (req, res) => { try { const { jahr = new Date().getFullYear() } = req.query; const yearStart = new Date(jahr, 0, 1); const yearEnd = new Date(jahr, 11, 31); // Alle Objekte laden const objekteResult = await pool.query('SELECT id, name, adresse FROM objekte ORDER BY name'); const bilanzDaten = []; let gesamtEinnahmen = 0; let gesamtAusgaben = 0; for (const objekt of objekteResult.rows) { // === EINNAHMEN: Detaillierte Abfrage === const vertraegeResult = await pool.query(` SELECT mv.*, m.name as mieter_name FROM mietvertraege mv JOIN mieter m ON m.id = mv.mieter_id WHERE mv.objekt_id = $1 AND mv.vertragsbeginn <= $2 AND (mv.vertragsende IS NULL OR mv.vertragsende >= $3) ORDER BY m.name `, [objekt.id, yearEnd, yearStart]); // Einnahmen berechnen mit Detail-Tracking let objektEinnahmen = 0; const einnahmenDetails = []; for (const vertrag of vertraegeResult.rows) { const vertragsBeginn = new Date(vertrag.vertragsbeginn); const vertragsEnde = vertrag.vertragsende ? new Date(vertrag.vertragsende) : null; // Überlappende Monate berechnen const effectiveStart = vertragsBeginn > yearStart ? vertragsBeginn : yearStart; const effectiveEnd = (vertragsEnde && vertragsEnde < yearEnd) ? vertragsEnde : yearEnd; let monate = 0; if (effectiveStart <= effectiveEnd) { monate = (effectiveEnd.getFullYear() - effectiveStart.getFullYear()) * 12 + (effectiveEnd.getMonth() - effectiveStart.getMonth()) + 1; } const kaltmiete = parseFloat(vertrag.kaltmiete) || 0; const vertragEinnahmen = kaltmiete * monate; objektEinnahmen += vertragEinnahmen; // Formatierter Zeitraum const zeitraumVon = effectiveStart.toLocaleDateString('de-DE', { month: 'short' }); const zeitraumBis = effectiveEnd.toLocaleDateString('de-DE', { month: 'short' }); const zeitraum = monate === 12 ? 'Jan-Dez' : `${zeitraumVon}-${zeitraumBis}`; einnahmenDetails.push({ mieter: vertrag.mieter_name, zeitraum: zeitraum, monate: monate, kaltmiete: kaltmiete, summe: Math.round(vertragEinnahmen * 100) / 100 }); } // === AUSGABEN: Detaillierte Abfrage === const kostenResult = await pool.query(` SELECT kategorie, bezeichnung, betrag, datum FROM objektkosten WHERE objekt_id = $1 AND jahr = $2 ORDER BY kategorie, bezeichnung `, [objekt.id, jahr]); const ausgabenDetails = kostenResult.rows.map(k => ({ kategorie: k.kategorie, bezeichnung: k.bezeichnung, betrag: parseFloat(k.betrag) || 0, datum: k.datum })); const objektAusgaben = ausgabenDetails.reduce((sum, k) => sum + k.betrag, 0); const bilanz = objektEinnahmen - objektAusgaben; const rendite = objektEinnahmen > 0 ? ((bilanz / objektEinnahmen) * 100) : 0; gesamtEinnahmen += objektEinnahmen; gesamtAusgaben += objektAusgaben; bilanzDaten.push({ id: objekt.id, name: objekt.name, adresse: objekt.adresse, einnahmen: Math.round(objektEinnahmen * 100) / 100, ausgaben: Math.round(objektAusgaben * 100) / 100, bilanz: Math.round(bilanz * 100) / 100, rendite: Math.round(rendite * 10) / 10, anzahl_mieter: einnahmenDetails.length, details: { einnahmen: einnahmenDetails, ausgaben: ausgabenDetails } }); } res.json({ jahr: parseInt(jahr), objekte: bilanzDaten, gesamt: { einnahmen: Math.round(gesamtEinnahmen * 100) / 100, ausgaben: Math.round(gesamtAusgaben * 100) / 100, bilanz: Math.round((gesamtEinnahmen - gesamtAusgaben) * 100) / 100, rendite: gesamtEinnahmen > 0 ? Math.round(((gesamtEinnahmen - gesamtAusgaben) / gesamtEinnahmen) * 100 * 10) / 10 : 0 } }); } catch (error) { console.error('Vermietung Bilanz Error:', 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 }); } }); // ========== VERMIETUNGSBILANZ ========== app.get('/api/vermietung/bilanz', async (req, res) => { try { const { jahr = new Date().getFullYear() } = req.query; const yearStart = new Date(jahr, 0, 1); const yearEnd = new Date(jahr, 11, 31); // Alle Objekte laden const objekteResult = await pool.query('SELECT id, name, adresse FROM objekte ORDER BY name'); const bilanzDaten = []; let gesamtEinnahmen = 0; let gesamtAusgaben = 0; for (const objekt of objekteResult.rows) { // === EINNAHMEN: Detaillierte Abfrage === const vertraegeResult = await pool.query(` SELECT mv.*, m.name as mieter_name FROM mietvertraege mv JOIN mieter m ON m.id = mv.mieter_id WHERE mv.objekt_id = $1 AND mv.vertragsbeginn <= $2 AND (mv.vertragsende IS NULL OR mv.vertragsende >= $3) ORDER BY m.name `, [objekt.id, yearEnd, yearStart]); // Einnahmen berechnen mit Detail-Tracking let objektEinnahmen = 0; const einnahmenDetails = []; for (const vertrag of vertraegeResult.rows) { const vertragsBeginn = new Date(vertrag.vertragsbeginn); const vertragsEnde = vertrag.vertragsende ? new Date(vertrag.vertragsende) : null; // Überlappende Monate berechnen const effectiveStart = vertragsBeginn > yearStart ? vertragsBeginn : yearStart; const effectiveEnd = (vertragsEnde && vertragsEnde < yearEnd) ? vertragsEnde : yearEnd; let monate = 0; if (effectiveStart <= effectiveEnd) { monate = (effectiveEnd.getFullYear() - effectiveStart.getFullYear()) * 12 + (effectiveEnd.getMonth() - effectiveStart.getMonth()) + 1; } const kaltmiete = parseFloat(vertrag.kaltmiete) || 0; const vertragEinnahmen = kaltmiete * monate; objektEinnahmen += vertragEinnahmen; // Formatierter Zeitraum const zeitraumVon = effectiveStart.toLocaleDateString('de-DE', { month: 'short' }); const zeitraumBis = effectiveEnd.toLocaleDateString('de-DE', { month: 'short' }); const zeitraum = monate === 12 ? 'Jan-Dez' : `${zeitraumVon}-${zeitraumBis}`; einnahmenDetails.push({ mieter_name: vertrag.mieter_name, zeitraum: zeitraum, monate: monate, kaltmiete_monat: kaltmiete, summe: vertragEinnahmen }); } // === AUSGABEN: Detaillierte Abfrage === const kostenResult = await pool.query(` SELECT kategorie, bezeichnung, betrag, datum FROM objektkosten WHERE objekt_id = $1 AND jahr = $2 ORDER BY kategorie, bezeichnung `, [objekt.id, jahr]); const ausgabenDetails = kostenResult.rows.map(k => ({ kategorie: k.kategorie, bezeichnung: k.bezeichnung, betrag: parseFloat(k.betrag) || 0, datum: k.datum })); const objektAusgaben = ausgabenDetails.reduce((sum, k) => sum + k.betrag, 0); const bilanz = objektEinnahmen - objektAusgaben; const rendite = objektEinnahmen > 0 ? ((bilanz / objektEinnahmen) * 100) : 0; gesamtEinnahmen += objektEinnahmen; gesamtAusgaben += objektAusgaben; bilanzDaten.push({ id: objekt.id, name: objekt.name, adresse: objekt.adresse, einnahmen: Math.round(objektEinnahmen * 100) / 100, ausgaben: Math.round(objektAusgaben * 100) / 100, bilanz: Math.round(bilanz * 100) / 100, rendite: Math.round(rendite * 10) / 10, anzahl_mieter: einnahmenDetails.length, details: { einnahmen: einnahmenDetails, ausgaben: ausgabenDetails } }); } res.json({ jahr: parseInt(jahr), objekte: bilanzDaten, gesamt: { einnahmen: Math.round(gesamtEinnahmen * 100) / 100, ausgaben: Math.round(gesamtAusgaben * 100) / 100, bilanz: Math.round((gesamtEinnahmen - gesamtAusgaben) * 100) / 100, rendite: gesamtEinnahmen > 0 ? Math.round(((gesamtEinnahmen - gesamtAusgaben) / gesamtEinnahmen) * 100 * 10) / 10 : 0 } }); } catch (error) { console.error('Vermietung Bilanz Error:', error); res.status(500).json({ error: error.message }); } }); };