/** Nebenkostenabrechnung als PDF (v1.23). * * Erzeugt pro Wohnung und Abrechnungsjahr eine PDF-Abrechnung direkt aus * dem Kosten- und Zählerstandsmodul: * - umlagefähige, hausweite Kosten → Verteilung nach Wohnflächenanteil * - umlagefähige, wohnungsbezogene Kosten → voll der Wohnung * - Abzug der Nebenkosten-Vorauszahlung (Warmmiete − Kaltmiete) × 12 * - Anlage: Zählerstände der Wohnung (Stand + Datum) * Das PDF landet im Upload-Verzeichnis und wird als Dokument * („Nebenkostenabrechnung") der Wohnung zugeordnet – erneutes Erzeugen * für dieselbe Wohnung + Jahr ersetzt Datei und Dokument. */ const fs = require('fs') const path = require('path') const PDFDocument = require('pdfkit') const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads') const FARBE_DUNKEL = '#22333b' const FARBE_AKZENT = '#f59e0b' const FARBE_SOFT = '#64748b' const eur = (n) => Number(n || 0).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €' const zahl = (n) => Number(n || 0).toLocaleString('de-DE', { maximumFractionDigits: 2 }) const dDatum = () => new Date().toLocaleDateString('de-DE') /** Sammelt alle Positionen und Zahlen für Wohnung + Jahr. */ async function abrechnungsDaten(pool, unitId, jahr) { const { rows: einheiten } = await pool.query('SELECT * FROM units WHERE id = $1', [unitId]) if (!einheiten.length) throw Object.assign(new Error('Wohnung nicht gefunden.'), { status: 404 }) const einheit = einheiten[0] const { rows: hausEinheiten } = await pool.query( 'SELECT * FROM units WHERE building = $1', [einheit.building], ) const hausFlaeche = hausEinheiten.reduce((s, u) => s + Number(u.size || 0), 0) const anteil = hausFlaeche > 0 ? Number(einheit.size || 0) / hausFlaeche : 0 const { rows: kosten } = await pool.query( `SELECT * FROM costs WHERE building = $1 AND jahr = $2 AND umlegbar = TRUE AND (unit_id = $3 OR unit_id IS NULL) ORDER BY kostenart`, [einheit.building, jahr, einheit.id], ) const positionen = [] for (const k of kosten) { const wohnungsAnteil = k.unit_id ? Number(k.betrag) // wohnungsbezogen: voller Betrag : Number(k.betrag) * anteil // hausweit: nach Wohnflächenanteil positionen.push({ kostenart: k.kostenart, notiz: k.notiz, hausGesamt: k.unit_id ? null : Number(k.betrag), verteilung: k.unit_id ? 'wohnungsbezogen' : `${zahl(einheit.size)} von ${zahl(hausFlaeche)} m²`, betrag: wohnungsAnteil, }) } // Zusammenfassen nach Kostenart const jeArt = new Map() for (const p of positionen) { const alt = jeArt.get(p.kostenart) || { ...p, betrag: 0, hausGesamt: p.hausGesamt } alt.betrag += p.betrag if (p.hausGesamt !== null) alt.hausGesamt = (alt.hausGesamt || 0) + p.hausGesamt jeArt.set(p.kostenart, alt) } const summe = [...jeArt.values()].reduce((s, p) => s + p.betrag, 0) // Vorauszahlung aus dem Mieterobjekt (seit v1.36, vorher am Wohnungsobjekt) const { rows: mieterRows } = await pool.query( 'SELECT name, vorname, kaltmiete, warmmiete FROM mieter WHERE unit_id = $1', [einheit.id], ) const nkVorauszahlung = mieterRows.length ? Number(mieterRows[0].warmmiete) - Number(mieterRows[0].kaltmiete) : 0 const mieterName = mieterRows.length ? `${mieterRows[0].vorname} ${mieterRows[0].name}`.trim() : '' const vorauszahlung = nkVorauszahlung * 12 const differenz = summe - vorauszahlung const { rows: zaehler } = await pool.query( `SELECT nummer, art, stand, stand_am FROM meters WHERE unit_id = $1 ORDER BY art, nummer`, [einheit.id], ) return { einheit, jahr, positionen: [...jeArt.values()], summe, vorauszahlung, differenz, zaehler, nkVorauszahlung, mieterName } } /** Zeichnet das PDF und liefert den Dateinamen (im UPLOAD_DIR). */ async function bauePdf(daten) { const { einheit, jahr, positionen, summe, vorauszahlung, differenz, zaehler, nkVorauszahlung, mieterName } = daten const dateiName = `ab-${einheit.id}-${jahr}.pdf` const vollerPfad = path.join(UPLOAD_DIR, dateiName) await new Promise((resolve, reject) => { const doc = new PDFDocument({ size: 'A4', margin: 50, info: { Title: `Nebenkostenabrechnung ${jahr} – ${einheit.building}, Whg. ${einheit.number}`, Author: 'Hinterstein Immobilien', } }) const strom = fs.createWriteStream(vollerPfad) strom.on('finish', resolve) strom.on('error', reject) doc.pipe(strom) const breite = doc.page.width - 100 // nutzbare Breite let y = doc.y // ── Kopf ── doc.font('Helvetica-Bold').fontSize(16).fillColor(FARBE_DUNKEL) doc.text('Hinterstein Immobilien', 50, y) doc.font('Helvetica').fontSize(9).fillColor(FARBE_SOFT) doc.text('Nebenkostenabrechnung', 50, y + 20, { align: 'right', width: breite }) doc.moveTo(50, y + 36).lineTo(50 + breite, y + 36).lineWidth(1.5).strokeColor(FARBE_AKZENT).stroke() y += 50 doc.font('Helvetica-Bold').fontSize(13).fillColor(FARBE_DUNKEL) doc.text(`Nebenkostenabrechnung ${jahr}`, 50, y) y += 22 doc.font('Helvetica').fontSize(10).fillColor('#0f172a') doc.text(`Abrechnungszeitraum: 01.01.${jahr} – 31.12.${jahr}`, 50, y) y += 16 doc.text(`Objekt: ${einheit.building} · Wohnung ${einheit.number} · ${zahl(einheit.size)} m²`, 50, y) y += 16 doc.text(`Mieter: ${mieterName || '–'}`, 50, y) y += 16 doc.text(`Erstellt am ${dDatum()}`, 50, y) y += 26 // ── Kostenpositionen ── doc.font('Helvetica-Bold').fontSize(11).fillColor(FARBE_DUNKEL) doc.text('Umlagefähige Betriebskosten', 50, y) y += 18 const spalten = { art: 50, gesamt: 300, schluessel: 390, betrag: 480 } doc.font('Helvetica-Bold').fontSize(8.5).fillColor(FARBE_SOFT) doc.text('KOSTENART', spalten.art, y) doc.text('HAUS GESAMT', spalten.gesamt, y, { width: 85, align: 'right' }) doc.text('VERTEILUNG', spalten.schluessel, y) doc.text('IHRE KOSTEN', spalten.betrag, y, { width: 65, align: 'right' }) y += 6 doc.moveTo(50, y).lineTo(50 + breite, y).lineWidth(0.5).strokeColor('#cbd5e1').stroke() y += 12 doc.font('Helvetica').fontSize(9.5).fillColor('#0f172a') for (const p of positionen) { if (y > 730) { doc.addPage(); y = 60 } doc.text(p.kostenart, spalten.art, y, { width: 240 }) doc.text(p.hausGesamt !== null ? eur(p.hausGesamt) : '–', spalten.gesamt, y, { width: 85, align: 'right' }) doc.fillColor(FARBE_SOFT).text(p.verteilung, spalten.schluessel, y, { width: 85 }) doc.fillColor('#0f172a').text(eur(p.betrag), spalten.betrag, y, { width: 65, align: 'right' }) y += 18 } y += 2 doc.moveTo(50, y).lineTo(50 + breite, y).lineWidth(0.5).strokeColor('#cbd5e1').stroke() y += 14 // ── Summenblock ── const zeile = (label, wert, fett, farbe) => { doc.font(fett ? 'Helvetica-Bold' : 'Helvetica').fontSize(10).fillColor(farbe || '#0f172a') doc.text(label, 50, y, { width: 400 }) doc.text(wert, 430, y, { width: 115, align: 'right' }) y += 17 } zeile('Summe umlagefähige Kosten', eur(summe), true) zeile(`Nebenkostenvorauszahlung (${eur(nkVorauszahlung)}/Monat × 12)`, '- ' + eur(vorauszahlung)) y += 4 doc.moveTo(320, y - 4).lineTo(545, y - 4).lineWidth(0.5).strokeColor('#cbd5e1').stroke() const nachzahlung = differenz > 0 zeile( nachzahlung ? 'Nachzahlung' : 'Erstattung', (nachzahlung ? '+ ' : '- ') + eur(Math.abs(differenz)), true, nachzahlung ? '#b91c1c' : '#047857', ) y += 14 // ── Anlage Zählerstände ── if (zaehler.length) { if (y > 640) { doc.addPage(); y = 60 } doc.font('Helvetica-Bold').fontSize(11).fillColor(FARBE_DUNKEL) doc.text('Anlage: Zählerstände der Wohnung', 50, y) y += 16 doc.font('Helvetica-Bold').fontSize(8.5).fillColor(FARBE_SOFT) doc.text('ZÄHLERART', 50, y) doc.text('NUMMER', 160, y) doc.text('STAND', 330, y, { width: 90, align: 'right' }) doc.text('STAND VOM', 440, y, { width: 105, align: 'right' }) y += 6 doc.moveTo(50, y).lineTo(50 + breite, y).lineWidth(0.5).strokeColor('#cbd5e1').stroke() y += 12 doc.font('Helvetica').fontSize(9.5).fillColor('#0f172a') for (const z of zaehler) { if (y > 730) { doc.addPage(); y = 60 } doc.text(z.art, 50, y) doc.text(z.nummer, 160, y, { width: 150 }) doc.text(zahl(z.stand), 330, y, { width: 90, align: 'right' }) doc.text(z.stand_am || '–', 440, y, { width: 105, align: 'right' }) y += 18 } y += 10 doc.font('Helvetica').fontSize(8).fillColor(FARBE_SOFT) doc.text('Hinweis: Verbrauchskosten (z. B. Wasser) sind im Abrechnungszeitraum in den o. g. Kostenarten enthalten.', 50, y, { width: breite }) y += 12 } // ── Fußzeile ── doc.font('Helvetica').fontSize(8).fillColor(FARBE_SOFT) doc.text( 'Hinterstein Immobilien · Diese Abrechnung wurde automatisch aus dem Mieterportal erzeugt (Kosten- und Zählerstandsmodul).', 50, Math.max(y + 8, 770), { width: breite }, ) doc.end() }) return dateiName } /** Erzeugt die Abrechnung: PDF + Dokumenten-Eintrag (ersetzt vorhandene). */ async function erzeugeAbrechnung(pool, unitId, jahr) { const daten = await abrechnungsDaten(pool, unitId, jahr) const { einheit } = daten const dateiName = await bauePdf(daten) const groesseBytes = fs.statSync(path.join(UPLOAD_DIR, dateiName)).size const groesse = groesseBytes > 1024 ? `${zahl(groesseBytes / 1024)} KB` : `${groesseBytes} B` const titel = `Nebenkostenabrechnung ${jahr} – ${einheit.building}, Whg. ${einheit.number}` const { rows: vorhanden } = await pool.query( 'SELECT * FROM documents WHERE title = $1 AND unit_id = $2', [titel, einheit.id], ) let dokument if (vorhanden.length) { const alt = vorhanden[0].file_path if (alt && alt !== dateiName) { await fs.promises.unlink(path.join(UPLOAD_DIR, alt)).catch(() => {}) } const { rows } = await pool.query( `UPDATE documents SET file_name = $1, file_path = $2, size = $3, date = $4, category = 'Nebenkostenabrechnung' WHERE id = $5 RETURNING *`, [titel, dateiName, groesse, dDatum(), vorhanden[0].id], ) dokument = rows[0] } else { const { rows } = await pool.query( `INSERT INTO documents (id, unit_id, title, category, size, date, file_name, file_path) VALUES ($1,$2,$3,'Nebenkostenabrechnung',$4,$5,$6,$7) RETURNING *`, [`d-ab-${einheit.id}-${jahr}`, einheit.id, titel, groesse, dDatum(), titel, dateiName], ) dokument = rows[0] } return { dokument, daten } } module.exports = { erzeugeAbrechnung }