v1.23: Nebenkostenabrechnung als PDF aus Kosten- und Zaehlermodul

This commit is contained in:
2026-10-04 14:38:51 +02:00
parent 7026019ced
commit 9707b6700d
9 changed files with 1258 additions and 16 deletions
+245
View File
@@ -0,0 +1,245 @@
/** 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)
const vorauszahlung = (Number(einheit.warm_rent) - Number(einheit.cold_rent)) * 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 }
}
/** Zeichnet das PDF und liefert den Dateinamen (im UPLOAD_DIR). */
async function bauePdf(daten) {
const { einheit, jahr, positionen, summe, vorauszahlung, differenz, zaehler } = 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: ${einheit.tenant || '–'}`, 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(Number(einheit.warm_rent) - Number(einheit.cold_rent))}/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 }