Files
Mieterportal/server/src/abrechnung.js
T

254 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 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 }