v1.35: Mietvertragsmodul (Entwurf-PDF, Versand, Unterschrift, Mieteranlage)

This commit is contained in:
2026-10-04 19:39:22 +02:00
parent c82e399a29
commit 9e74813e22
12 changed files with 1334 additions and 2 deletions
+29
View File
@@ -227,6 +227,35 @@ ALTER TABLE belege ADD COLUMN IF NOT EXISTS unit_id TEXT REFERENCES units(id);
ALTER TABLE belege ADD COLUMN IF NOT EXISTS gebaude TEXT;
ALTER TABLE belege ADD COLUMN IF NOT EXISTS zuordnung TEXT; -- zaehlernummer | adresse | manuell
ALTER TABLE belege ADD COLUMN IF NOT EXISTS rohtext TEXT; -- Volltext des Events für die Zuordnung
-- Portal-/Vermieterdaten für Mietvertrags-PDFs (v1.35, Schlüssel-Wert)
CREATE TABLE IF NOT EXISTS einstellungen (
schluessel TEXT PRIMARY KEY,
wert TEXT NOT NULL DEFAULT ''
);
-- Mietverträge (v1.35): Entwurf → versendet → unterschrieben → aktiv.
-- Dokument- und Benutzerbezüge bewusst ohne harten FK, damit das Löschen
-- von Dokumenten/Benutzern nicht an der Vertragshistorie scheitert.
CREATE TABLE IF NOT EXISTS mietvertraege (
id TEXT PRIMARY KEY,
unit_id TEXT NOT NULL REFERENCES units(id),
mieter_name TEXT NOT NULL,
mieter_email TEXT NOT NULL DEFAULT '',
mieter_telefon TEXT NOT NULL DEFAULT '',
beginn_am TEXT NOT NULL,
ende_am TEXT NOT NULL DEFAULT '', -- '' = unbefristet
kaltmiete NUMERIC(10,2) NOT NULL,
nebenkosten NUMERIC(10,2) NOT NULL DEFAULT 0,
kaution NUMERIC(10,2) NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'entwurf'
CHECK (status IN ('entwurf','versendet','unterschrieben','aktiv')),
dokument_id TEXT NOT NULL DEFAULT '', -- Entwurf als PDF (Dokumentenmodul)
unterschriften_dokument_id TEXT NOT NULL DEFAULT '', -- unterschriebener Vertrag (Scan)
benutzer_id TEXT NOT NULL DEFAULT '', -- angelegtes Mieterkonto
erstellt_am TEXT NOT NULL,
geaendert_am TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_mietvertraege_unit ON mietvertraege(unit_id);
`
async function init() {
+294 -1
View File
@@ -15,6 +15,7 @@ const { pool, init, sessionsAufraeumen } = require('./db')
const { seed, seedeZaehler, seedeHaeuser, seedeKosten } = require('./seed')
const mail = require('./mail')
const { erzeugeAbrechnung } = require('./abrechnung')
const { erzeugeVertragsPdf, vermieterDaten } = require('./vertrag')
const zaehlerVerlauf = require('./zaehlerVerlauf')
const PORT = Number(process.env.PORT || 3000)
@@ -172,6 +173,14 @@ const mapDocument = (d) => ({
fileName: d.file_name ?? null, hasFile: Boolean(d.file_path),
})
const mapMessage = (m) => ({ id: m.id, from: m.from_role, author: m.author, text: m.text, at: m.at })
const mapMietvertrag = (v) => ({
id: v.id, unitId: v.unit_id, mieterName: v.mieter_name, mieterEmail: v.mieter_email,
mieterTelefon: v.mieter_telefon, beginnAm: v.beginn_am, endeAm: v.ende_am,
kaltmiete: Number(v.kaltmiete), nebenkosten: Number(v.nebenkosten), kaution: Number(v.kaution),
status: v.status, dokumentId: v.dokument_id || null,
unterschriftenDokumentId: v.unterschriften_dokument_id || null,
benutzerId: v.benutzer_id || null, erstelltAm: v.erstellt_am, geaendertAm: v.geaendert_am,
})
const mapThread = (t) => ({
id: t.id, unitId: t.unit_id, tenant: t.tenant, subject: t.subject,
unreadByAdmin: t.unread_by_admin, unreadByTenant: t.unread_by_tenant,
@@ -342,7 +351,7 @@ app.get('/api/bootstrap', auth, async (req, res, next) => {
try {
const istAdmin = req.user.rolle === 'verwaltung'
const einheit = req.user.einheit_id
const [units, transactions, documents, tickets, threads, messages, announcements, users, codes, meters, buildings, costs, belege] =
const [units, transactions, documents, tickets, threads, messages, announcements, users, codes, meters, buildings, costs, belege, mietvertraege] =
await Promise.all([
pool.query('SELECT * FROM units ORDER BY id'),
istAdmin
@@ -379,6 +388,9 @@ app.get('/api/bootstrap', auth, async (req, res, next) => {
istAdmin
? pool.query('SELECT * FROM belege ORDER BY erfasst_am DESC')
: { rows: [] },
istAdmin
? pool.query('SELECT * FROM mietvertraege ORDER BY erstellt_am DESC')
: { rows: [] },
])
// Threads mit ihren Nachrichten zusammenführen
const nachrichtenProThread = {}
@@ -402,6 +414,8 @@ app.get('/api/bootstrap', auth, async (req, res, next) => {
buildings: buildings.rows.map(mapBuilding),
kosten: costs.rows.map(mapKosten),
belege: belege.rows.map(mapBeleg),
mietvertraege: mietvertraege.rows.map(mapMietvertrag),
einstellungen: await vermieterDaten(pool),
})
} catch (e) { next(e) }
})
@@ -802,6 +816,12 @@ app.delete('/api/documents/:id', auth, adminOnly, async (req, res, next) => {
try {
const { rows } = await pool.query('DELETE FROM documents WHERE id = $1 RETURNING file_path', [req.params.id])
if (!rows.length) return res.status(404).json({ fehler: 'Dokument nicht gefunden.' })
// Verweise aus dem Mietvertragsmodul lösen (Entwurf / Unterschrifts-PDF)
await pool.query(
`UPDATE mietvertraege SET dokument_id = '' WHERE dokument_id = $1;
UPDATE mietvertraege SET unterschriften_dokument_id = '' WHERE unterschriften_dokument_id = $1`,
[req.params.id],
)
if (rows[0].file_path) {
// Datei best-effort mitlöschen (Fehler hier dürfen den Request nicht killen)
await fs.promises.unlink(path.join(UPLOAD_DIR, rows[0].file_path)).catch(() => {})
@@ -1386,6 +1406,7 @@ app.delete('/api/units/:id', auth, adminOnly, async (req, res, next) => {
['threads', 'unit_id', 'Unterhaltungen'],
['registration_codes', 'einheit_id', 'Registrierungscodes'],
['belege', 'unit_id', 'Belege'],
['mietvertraege', 'unit_id', 'Mietverträge'],
]) {
const { rows: r } = await pool.query(`SELECT COUNT(*)::int AS n FROM ${tabelle} WHERE ${spalte} = $1`, [req.params.id])
if (r[0].n > 0) blocker.push(`${r[0].n} ${label}`)
@@ -1705,6 +1726,278 @@ app.post('/api/abrechnungen', auth, adminOnly, async (req, res, next) => {
}
})
// ── Mietverträge (v1.35) ─────────────────────────────────────
const SHA256_0815 = 'c57a5df4f97ed6914116e5fd1348406064834f22503d890eb571af442ac4b4c3' // sha256("0815")
/** Pflichtfelren prüfen; liefert normierte Werte oder null (+Response bereits gesendet). */
async function mietvertragGueltig(b, res) {
const mieterName = String(b.mieterName || '').trim()
if (!mieterName) { res.status(400).json({ fehler: 'Name des zukünftigen Mieters fehlt.' }); return null }
const unitId = String(b.unitId || '').trim()
const { rows: u } = await pool.query('SELECT * FROM units WHERE id = $1', [unitId])
if (!u.length) { res.status(400).json({ fehler: 'Wohnung unbekannt.' }); return null }
const einheit = u[0]
const kaltmiete = b.kaltmiete !== undefined && b.kaltmiete !== '' ? Number(b.kaltmiete) : Number(einheit.cold_rent)
const nebenkosten = b.nebenkosten !== undefined && b.nebenkosten !== '' ? Number(b.nebenkosten) : Number(einheit.warm_rent) - Number(einheit.cold_rent)
if (!Number.isFinite(kaltmiete) || kaltmiete < 0) { res.status(400).json({ fehler: 'Kaltmiete ungültig.' }); return null }
if (!Number.isFinite(nebenkosten) || nebenkosten < 0) { res.status(400).json({ fehler: 'Nebenkosten ungültig.' }); return null }
const kaution = Number(b.kaution ?? 0)
if (!Number.isFinite(kaution) || kaution < 0) { res.status(400).json({ fehler: 'Kaution ungültig.' }); return null }
const beginnAm = datumDeutsch(b.beginnAm) || String(b.beginnAm || '').trim()
if (!beginnAm) { res.status(400).json({ fehler: 'Mietbeginn fehlt.' }); return null }
const endeAm = datumDeutsch(b.endeAm) || String(b.endeAm || '').trim()
const mieterEmail = String(b.mieterEmail || '').trim()
const mieterTelefon = String(b.mieterTelefon || '').trim()
return { unitId, einheit, mieterName, mieterEmail, mieterTelefon, beginnAm, endeAm, kaltmiete, nebenkosten, kaution }
}
async function mietvertragLaden(id, res) {
const { rows } = await pool.query('SELECT * FROM mietvertraege WHERE id = $1', [id])
if (!rows.length) { res.status(404).json({ fehler: 'Mietvertrag nicht gefunden.' }); return null }
return rows[0]
}
app.post('/api/mietvertraege', auth, adminOnly, async (req, res, next) => {
try {
const g = await mietvertragGueltig(req.body || {}, res)
if (!g) return
const id = `mv-${Date.now()}`
const { rows } = await pool.query(
`INSERT INTO mietvertraege (id, unit_id, mieter_name, mieter_email, mieter_telefon,
beginn_am, ende_am, kaltmiete, nebenkosten, kaution, status, erstellt_am, geaendert_am)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'entwurf',$11,$11) RETURNING *`,
[id, g.unitId, g.mieterName, g.mieterEmail, g.mieterTelefon, g.beginnAm, g.endeAm,
g.kaltmiete, g.nebenkosten, g.kaution, deutschesDatum()],
)
res.json({ mietvertrag: mapMietvertrag(rows[0]) })
} catch (e) { next(e) }
})
app.patch('/api/mietvertraege/:id', auth, adminOnly, async (req, res, next) => {
try {
const alt = await mietvertragLaden(req.params.id, res)
if (!alt) return
if (alt.status === 'aktiv') return res.status(400).json({ fehler: 'Aktive Verträge können nicht mehr bearbeitet werden.' })
const b = { ...mapMietvertrag(alt), ...(req.body || {}) }
const g = await mietvertragGueltig(b, res)
if (!g) return
const { rows } = await pool.query(
`UPDATE mietvertraege SET unit_id=$1, mieter_name=$2, mieter_email=$3, mieter_telefon=$4,
beginn_am=$5, ende_am=$6, kaltmiete=$7, nebenkosten=$8, kaution=$9, geaendert_am=$10
WHERE id=$11 RETURNING *`,
[g.unitId, g.mieterName, g.mieterEmail, g.mieterTelefon, g.beginnAm, g.endeAm,
g.kaltmiete, g.nebenkosten, g.kaution, deutschesDatum(), req.params.id],
)
res.json({ mietvertrag: mapMietvertrag(rows[0]) })
} catch (e) { next(e) }
})
app.delete('/api/mietvertraege/:id', auth, adminOnly, async (req, res, next) => {
try {
const alt = await mietvertragLaden(req.params.id, res)
if (!alt) return
if (alt.status === 'aktiv') return res.status(400).json({ fehler: 'Aktive Verträge können nicht gelöscht werden.' })
await pool.query('DELETE FROM mietvertraege WHERE id = $1', [req.params.id])
res.json({ ok: true })
} catch (e) { next(e) }
})
// Entwurf als PDF erzeugen/aktualisieren (Dokumentenmodul)
app.post('/api/mietvertraege/:id/entwurf', auth, adminOnly, async (req, res, next) => {
try {
const vertrag = await mietvertragLaden(req.params.id, res)
if (!vertrag) return
if (vertrag.status === 'aktiv') return res.status(400).json({ fehler: 'Aktive Verträge können nicht mehr bearbeitet werden.' })
const dokument = await erzeugeVertragsPdf(pool, vertrag)
res.json({ dokument: mapDocument(dokument) })
} catch (e) {
if (e.status) return res.status(e.status).json({ fehler: e.message })
next(e)
}
})
// Entwurf per E-Mail an den zukünftigen Mieter versenden (CC: Verwaltung)
app.post('/api/mietvertraege/:id/versenden', auth, adminOnly, async (req, res, next) => {
try {
let vertrag = await mietvertragLaden(req.params.id, res)
if (!vertrag) return
if (vertrag.status === 'aktiv') return res.status(400).json({ fehler: 'Aktive Verträge können nicht erneut versendet werden.' })
if (!vertrag.mieter_email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(vertrag.mieter_email))
return res.status(400).json({ fehler: 'E-Mail-Adresse des zukünftigen Mieters fehlt oder ist ungültig.' })
// PDF auf aktuellem Datenstand neu erzeugen (ersetzt ggf. altes Entwurfs-PDF)
await erzeugeVertragsPdf(pool, vertrag)
vertrag = (await pool.query('SELECT * FROM mietvertraege WHERE id = $1', [req.params.id])).rows[0]
const { rows: einheiten } = await pool.query('SELECT * FROM units WHERE id = $1', [vertrag.unit_id])
const einheit = einheiten[0]
const betreff = `Ihr Mietvertragsentwurf – ${einheit.building}, Wohnung ${einheit.number}`
const portalUrl = process.env.PORTAL_URL || 'https://mieterportal.hocks.eu'
const text = `Guten Tag ${vertrag.mieter_name},\n\nanbei erhalten Sie den Entwurf Ihres Mietvertrags für die Wohnung ${einheit.number} in ${einheit.building}. `
+ 'Bitte prüfen Sie den Vertrag; um ihn zu bestätigen, unterschreiben Sie ihn und reichen Sie ihn bei der Verwaltung ein.\n\n'
+ `Mit freundlichen Grüßen\nIhre Verwaltung`
const html = mail.baueHtmlMail({
titel: 'Mietvertragsentwurf',
untertitel: `${einheit.building}, Wohnung ${einheit.number}`,
einleitung: `Guten Tag ${vertrag.mieter_name},`,
zeilen: [
['Objekt', `${einheit.building}, Whg. ${einheit.number}`],
['Mietbeginn', vertrag.beginn_am],
['Kaltmiete', `${Number(vertrag.kaltmiete).toLocaleString('de-DE', { minimumFractionDigits: 2 })} €`],
['Kaution', `${Number(vertrag.kaution).toLocaleString('de-DE', { minimumFractionDigits: 2 })} €`],
],
statusBox: { text: 'Der Vertragsentwurf (PDF) ist an diese E-Mail angehängt.', hintergrund: '#ecfdf5', farbe: '#047857' },
fussnote: 'Bei Rückfragen antworten Sie bitte direkt auf diese E-Mail.',
})
await mail.sendeMail(vertrag.mieter_email, betreff, text, html, await mail.verwaltungEmails(), {
dateiname: `Mietvertrag-${einheit.building.replace(/\s+/g, '_')}-Whg${einheit.number}.pdf`,
inhaltB64: fs.readFileSync(path.join(UPLOAD_DIR, path.basename(`mv-${vertrag.id}.pdf`))).toString('base64'),
mime: 'application/pdf',
})
const { rows } = await pool.query(
`UPDATE mietvertraege SET status = 'versendet', geaendert_am = $1 WHERE id = $2 RETURNING *`,
[deutschesDatum(), req.params.id],
)
console.log(`[vertrag] Entwurf ${vertrag.id} an ${vertrag.mieter_email} versendet`)
res.json({ mietvertrag: mapMietvertrag(rows[0]) })
} catch (e) { next(e) }
})
// Unterschriebenen Vertrag (Scan/PDF) hochladen → Status „unterschrieben"
app.post('/api/mietvertraege/:id/unterschrift', auth, adminOnly, async (req, res, next) => {
try {
const vertrag = await mietvertragLaden(req.params.id, res)
if (!vertrag) return
if (vertrag.status === 'aktiv') return res.status(400).json({ fehler: 'Aktive Verträge können nicht mehr bearbeitet werden.' })
upload.single('file')(req, res, async (fehler) => {
try {
if (fehler) return res.status(400).json({ fehler: 'Upload fehlgeschlagen – nur PDF-Dateien bis 25 MB erlaubt.' })
if (!req.file) return res.status(400).json({ fehler: 'Keine Datei übertragen.' })
const groesseBytes = req.file.size
const groesse = groesseBytes > 1024 ? `${(groesseBytes / 1024).toLocaleString('de-DE', { maximumFractionDigits: 1 })} KB` : `${groesseBytes} B`
const { rows: einheiten } = await pool.query('SELECT * FROM units WHERE id = $1', [vertrag.unit_id])
const einheit = einheiten[0]
const dokumentTitel = `Mietvertrag (unterschrieben) – ${einheit.building}, Whg. ${einheit.number} – ${vertrag.mieter_name}`
let dokumentId = vertrag.unterschriften_dokument_id
const { rows: vorhanden } = await pool.query('SELECT id FROM documents WHERE id = $1', [dokumentId || ''])
if (vorhanden.length) {
await pool.query(
`UPDATE documents SET title = $1, file_name = $1, size = $2, date = $3, unit_id = $4 WHERE id = $5`,
[dokumentTitel, groesse, deutschesDatum(), einheit.id, dokumentId],
)
} else {
dokumentId = `d-mvu-${vertrag.id}`
await pool.query(
`INSERT INTO documents (id, unit_id, title, category, size, date, file_name, file_path)
VALUES ($1,$2,$3,'Mietvertrag',$4,$5,$3,$6)`,
[dokumentId, einheit.id, dokumentTitel, groesse, deutschesDatum(), `${vertrag.id}.pdf`],
)
}
const { rows } = await pool.query(
`UPDATE mietvertraege SET status = 'unterschrieben', unterschriften_dokument_id = $1, geaendert_am = $2
WHERE id = $3 RETURNING *`,
[dokumentId, deutschesDatum(), req.params.id],
)
res.json({ mietvertrag: mapMietvertrag(rows[0]), dokument: mapDocument((await pool.query('SELECT * FROM documents WHERE id = $1', [dokumentId])).rows[0]) })
} catch (e) { next(e) }
})
} catch (e) { next(e) }
})
// Mieterkonto anlegen + Wohnung zuweisen (manuell ausgelöst) → Status „aktiv"
app.post('/api/mietvertraege/:id/aktivieren', auth, adminOnly, async (req, res, next) => {
try {
const vertrag = await mietvertragLaden(req.params.id, res)
if (!vertrag) return
if (vertrag.status === 'aktiv') return res.status(400).json({ fehler: 'Der Vertrag ist bereits aktiv.' })
if (vertrag.status !== 'unterschrieben')
return res.status(400).json({ fehler: 'Erst den unterschriebenen Vertrag hochladen, dann aktivieren.' })
const { rows: einheiten } = await pool.query('SELECT * FROM units WHERE id = $1', [vertrag.unit_id])
if (!einheiten.length) return res.status(400).json({ fehler: 'Wohnung unbekannt.' })
const einheit = einheiten[0]
// Benutzername aus Name ableiten (vorname.nachname), ggf. numerisch ergänzen
const teile = vertrag.mieter_name.trim().split(/\s+/)
const norm = (s) => s.toLowerCase().replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss').replace(/[^a-z0-9.\-]/g, '')
const basis = teile.length > 1 ? `${norm(teile[0])}.${norm(teile[teile.length - 1])}` : norm(teile[0])
let benutzername = basis || `mieter.${Date.now()}`
let versuch = 0
while (true) {
const { rows: belegt } = await pool.query('SELECT 1 FROM users WHERE lower(benutzername) = lower($1)', [benutzername])
if (!belegt.length) break
versuch += 1
benutzername = `${basis}${versuch + 1}`
if (versuch > 20) return res.status(400).json({ fehler: 'Kein freier Benutzername ableitbar.' })
}
const benutzerId = `b-${Date.now()}`
await pool.query(
`INSERT INTO users (id, benutzername, name, passwort_hash, rolle, email, einheit_id, aktiv, erstellt_am, telefon)
VALUES ($1,$2,$3,$4,'mieter',$5,$6,TRUE,$7,$8)`,
[benutzerId, benutzername, vertrag.mieter_name, SHA256_0815,
vertrag.mieter_email, einheit.id, deutschesDatum(), vertrag.mieter_telefon || null],
)
// Wohnung als vermietet führen
await pool.query(
`UPDATE units SET tenant = $1, status = 'vermietet' WHERE id = $2`,
[vertrag.mieter_name, einheit.id],
)
const { rows } = await pool.query(
`UPDATE mietvertraege SET status = 'aktiv', benutzer_id = $1, geaendert_am = $2 WHERE id = $3 RETURNING *`,
[benutzerId, deutschesDatum(), req.params.id],
)
// Zugangsdaten an den Mieter mailen (wenn E-Mail hinterlegt)
if (vertrag.mieter_email && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(vertrag.mieter_email)) {
const portalUrl = process.env.PORTAL_URL || 'https://mieterportal.hocks.eu'
const text = `Guten Tag ${vertrag.mieter_name},\n\nIhr Mietvertrag ist aktiv – willkommen bei Hinterstein Immobilien! `
+ `Ihr Zugang zum Mieterportal wurde eingerichtet:\n\nBenutzername: ${benutzername}\nPasswort: 0815\n\n`
+ `Bitte melden Sie sich an und ändern Sie Ihr Passwort im Profil.\n${portalUrl}\n\nMit freundlichen Grüßen\nIhre Verwaltung`
const html = mail.baueHtmlMail({
titel: 'Ihr Mieterportal-Zugang ist bereit',
untertitel: `${einheit.building}, Wohnung ${einheit.number}`,
einleitung: `Guten Tag ${vertrag.mieter_name},`,
zeilen: [
['Objekt', `${einheit.building}, Whg. ${einheit.number}`],
['Benutzername', benutzername],
['Passwort', '0815 (bitte nach dem ersten Login ändern)'],
],
statusBox: { text: 'Der unterschriebene Mietvertrag ist im Portal hinterlegt.', hintergrund: '#ecfdf5', farbe: '#047857' },
aktionenHtml: mail.buttonHtml('Zum Mieterportal', portalUrl),
fussnote: 'Bei Fragen wenden Sie sich bitte an die Verwaltung.',
})
await mail.sendeMail(vertrag.mieter_email, 'Ihr Mieterportal-Zugang – Hinterstein Immobilien', text, html, await mail.verwaltungEmails())
}
console.log(`[vertrag] ${vertrag.id} aktiviert – Mieterkonto ${benutzername} (${benutzerId}) angelegt`)
res.json({ mietvertrag: mapMietvertrag(rows[0]), benutzername })
} catch (e) { next(e) }
})
// Vermieterdaten (Schlüssel-Wert) lesen/pflegen – Basis der Vertrags-PDFs
app.get('/api/einstellungen', auth, adminOnly, async (req, res, next) => {
try {
const daten = await vermieterDaten(pool)
res.json({ einstellungen: daten })
} catch (e) { next(e) }
})
const EINSTELLUNGEN_KEYS = [
'vermieter_name', 'vermieter_strasse', 'vermieter_hausnummer', 'vermieter_plz', 'vermieter_ort',
'vermieter_telefon', 'vermieter_email', 'vermieter_bankname', 'vermieter_iban',
]
app.put('/api/einstellungen', auth, adminOnly, async (req, res, next) => {
try {
const b = req.body || {}
for (const key of EINSTELLUNGEN_KEYS) {
if (b[key] !== undefined) {
await pool.query(
`INSERT INTO einstellungen (schluessel, wert) VALUES ($1, $2)
ON CONFLICT (schluessel) DO UPDATE SET wert = EXCLUDED.wert`,
[key, String(b[key] ?? '').trim()],
)
}
}
res.json({ einstellungen: await vermieterDaten(pool) })
} catch (e) { next(e) }
})
// ── Fehlerbehandlung ─────────────────────────────────────────
app.use((err, _req, res, _next) => {
console.error('[api]', err)
+241
View File
@@ -0,0 +1,241 @@
/** Mietvertrag als PDF (v1.35).
*
* Erzeugt einen rechtskonformen Mietvertrags-Entwurf (Wohnraummietvertrag,
* Mustervorlage) aus den hinterlegten Vermieterdaten (Tabelle einstellungen),
* den Mieterdaten des Vertrags und den Bestandsdaten (Haus/Wohnung).
* Das PDF landet im Upload-Verzeichnis und wird als Dokument
* („Mietvertrag") der Wohnung zugeordnet – erneutes Erzeugen ersetzt
* Datei und Dokument.
*
* Hinweis: Es handelt sich um eine automatisch erzeugte Mustervorlage;
* die Verwaltung prüft den Entwurf vor Versand/Unterschrift.
*/
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')
/** Liest die Vermieterdaten (Schlüssel-Wert) als Objekt. */
async function vermieterDaten(pool) {
const { rows } = await pool.query('SELECT schluessel, wert FROM einstellungen')
const d = {}
for (const r of rows) d[r.schluessel] = r.wert
return {
name: d.vermieter_name || '',
strasse: d.vermieter_strasse || '',
hausnummer: d.vermieter_hausnummer || '',
plz: d.vermieter_plz || '',
ort: d.vermieter_ort || '',
telefon: d.vermieter_telefon || '',
email: d.vermieter_email || '',
bankname: d.vermieter_bankname || '',
iban: d.vermieter_iban || '',
}
}
const vermieterAdresse = (v) =>
[v.strasse && `${v.strasse} ${v.hausnummer}`, v.plz && `${v.plz} ${v.ort}`].filter(Boolean).join(', ')
/** Zeichnet den Vertrag und liefert den Dateinamen (im UPLOAD_DIR). */
async function bauePdf(vertrag, einheit, haus, vermieter) {
const dateiName = `mv-${vertrag.id}.pdf`
const vollerPfad = path.join(UPLOAD_DIR, dateiName)
const anschrift = `${haus.strasse} ${haus.hausnummer}, ${haus.plz} ${haus.ort}`
const warmmiete = Number(vertrag.kaltmiete) + Number(vertrag.nebenkosten)
await new Promise((resolve, reject) => {
const doc = new PDFDocument({ size: 'A4', margin: 50, info: {
Title: `Mietvertrag Entwurf – ${einheit.building}, Whg. ${einheit.number}`,
Author: vermieter.name || 'Hinterstein Immobilien',
} })
const strom = fs.createWriteStream(vollerPfad)
strom.on('finish', resolve)
strom.on('error', reject)
doc.pipe(strom)
const breite = doc.page.width - 100
let y = doc.y
// ── Briefkopf: Vermieter links, Mieter rechts ──
doc.font('Helvetica-Bold').fontSize(12).fillColor(FARBE_DUNKEL)
doc.text(vermieter.name || 'Vermieter', 50, y)
doc.font('Helvetica').fontSize(9).fillColor(FARBE_SOFT)
const vermieterZeilen = [
vermieterAdresse(vermieter),
vermieter.telefon, vermieter.email,
].filter(Boolean)
doc.text(vermieterZeilen.join('\n') || 'Vermieterdaten im Portal hinterlegen', 50, y + 16, { width: 240 })
doc.font('Helvetica-Bold').fontSize(12).fillColor(FARBE_DUNKEL)
doc.text(vertrag.mieter_name, 310, y, { width: 235 })
doc.font('Helvetica').fontSize(9).fillColor(FARBE_SOFT)
doc.text('Mieter/in', 310, y + 16, { width: 235 })
doc.moveTo(50, y + 70).lineTo(50 + breite, y + 70).lineWidth(1.5).strokeColor(FARBE_AKZENT).stroke()
y += 86
// ── Titel ──
doc.font('Helvetica-Bold').fontSize(15).fillColor(FARBE_DUNKEL)
doc.text('Mietvertrag über Wohnraum', 50, y, { width: breite, align: 'center' })
y += 20
doc.font('Helvetica').fontSize(9).fillColor(FARBE_SOFT)
doc.text(`Entwurf vom ${dDatum()} – erzeugt aus dem Mieterportal`, 50, y, { width: breite, align: 'center' })
y += 26
const absatz = (titel, zeilen) => {
if (y > 700) { doc.addPage(); y = 60 }
doc.font('Helvetica-Bold').fontSize(10.5).fillColor(FARBE_DUNKEL)
doc.text(titel, 50, y)
y += 16
doc.font('Helvetica').fontSize(9.5).fillColor('#0f172a')
for (const zeile of zeilen) {
const h = doc.heightOfString(zeile, { width: breite })
if (y + h > 780) { doc.addPage(); y = 60 }
doc.text(zeile, 50, y, { width: breite, lineGap: 2 })
y += h + 7
}
y += 6
}
// ── Vertragsparteien & Mietobjekt ──
absatz('§ 1 Vertragsparteien und Mietobjekt', [
`Zwischen dem Vermieter, ${vermieter.name || '…'} (${vermieterAdresse(vermieter) || 'Adresse siehe Briefkopf'}), `
+ `und dem/der Mieter/in, ${vertrag.mieter_name}, wird folgender Mietvertrag geschlossen.`,
`Dem Mieter wird die Wohnung Nr. ${einheit.number} im Haus ${anschrift} `
+ `${haus.lage ? `(${haus.lage}) ` : ''}zum Wohnen überlassen. `
+ `Die Wohnung liegt in Etage ${einheit.floor}, die Wohnfläche beträgt ca. ${zahl(einheit.size)} m².`
+ (haus.heizungsart ? ` Die Wohnung wird mit ${haus.heizungsart} beheizt.` : ''),
`Zum Mietobjekt gehören die in der Wohnung vorhandenen fest installierten Einrichtungen.`
])
// ── Mietzeit ──
absatz('§ 2 Mietzeit', [
`Das Mietverhältnis beginnt am ${vertrag.beginn_am}. `
+ (vertrag.ende_am
? `Es wird auf den ${vertrag.ende_am} befristet geschlossen.`
: 'Es wird auf unbestimmte Zeit geschlossen (unbefristetes Mietverhältnis).'),
`Die Kündigung richtet sich nach den gesetzlichen Vorschriften (§§ 573, 573c BGB).`
])
// ── Miete ──
absatz('§ 3 Miete und Nebenkosten', [
`Die monatliche Kaltmiete beträgt ${eur(vertrag.kaltmiete)}.`,
`Als Vorauszahlung auf die Betriebskosten werden monatlich zusätzlich ${eur(vertrag.nebenkosten)} (Nebenkostenvorauszahlung) gezahlt. `
+ `Die jährliche Abrechnung der Betriebskosten erfolgt nach tatsächlichem Verbrauch bzw. nach der in der Betriebskostenverordnung geregelten Verteilung.`,
`Die Warmmiete (Kaltmiete zzgl. Nebenkostenvorauszahlung) beträgt somit ${eur(warmmiete)} monatlich. `
+ `Sie ist jeweils bis zum dritten Werktag des Monats im Voraus zu entrichten.`,
`Die Miete ist auf das Konto ${vermieter.bankname || 'des Vermieters'} (IBAN: ${vermieter.iban || '–'}) zu überweisen. `
+ 'Ein Zurückbehaltungsrecht des Mieters ist ausgeschlossen.'
])
// ── Kaution ──
absatz('§ 4 Kaution', [
`Der Mieter leistet bei Vertragsschluss eine Kaution in Höhe von ${eur(vertrag.kaution)} `
+ '(maximal drei Nettokaltmieten, § 551 BGB).',
'Die Kaution kann in drei gleichen monatlichen Raten geleistet werden. Sie wird auf einem Mietkonto mit Zinssatz '
+ 'für Spareinlagen mit dreimonatiger Kündigungsfrist angelegt und nach Beendigung des Mietverhältnisses '
+ 'zuzüglich der Zinsen zurückgegeben.'
])
// ── Pflichten ──
absatz('§ 5 Gebrauch, Instandhaltung und Schönheitsreparaturen', [
'Die Wohnung ist nur zum Wohnen bestimmt. Die Überlassung an Dritte (Untervermietung) bedarf der vorherigen schriftlichen Zustimmung des Vermieters.',
'Der Mieter hat die Wohnung ordnungsgemäß zu pflegen, kleine Instandhaltungen (z. B. Batterien in Rauchwarnmeldern) selbst zu übernehmen und Schäden unverzüglich dem Vermieter zu melden.',
'Schönheitsreparaturen obliegen dem Mieter im üblichen Umfang (§ 28 Abs. 4 BBauG, § 21 BGB); abweichende Vereinbarungen bleiben vorbehalten.',
'Rauchwarnmelder werden vom Vermieter bereitgestellt; der Mieter übernimmt die regelmäßige Prüfung (§§ 17, 18 BaulVO NRW).'
])
// ── Schluss ──
absatz('§ 6 Schlussbestimmungen', [
'Änderungen und Ergänzungen dieses Vertrags bedürfen zu ihrer Wirksamkeit der Schriftform; dies gilt auch für die Aufhebung dieser Schriftformklausel.',
'Sollte eine Bestimmung dieses Vertrags unwirksam sein oder werden, bleibt die Wirksamkeit der übrigen Bestimmungen unberührt.',
'Es gilt das Recht der Bundesrepublik Deutschland. Gerichtsstand ist der Sitz des Vermieters, soweit dem nicht zwingend etwas entgegensteht.'
])
// ── Unterschriften ──
if (y > 620) { doc.addPage(); y = 60 }
y += 10
doc.font('Helvetica-Bold').fontSize(10.5).fillColor(FARBE_DUNKEL)
doc.text('Unterschriften', 50, y)
y += 18
doc.font('Helvetica').fontSize(9.5).fillColor('#0f172a')
doc.text(`Ort, Datum`, 50, y)
doc.text(`Ort, Datum`, 310, y)
y += 34
doc.moveTo(50, y).lineTo(270, y).lineWidth(0.75).strokeColor('#94a3b8').stroke()
doc.moveTo(310, y).lineTo(530, y).lineWidth(0.75).strokeColor('#94a3b8').stroke()
y += 6
doc.font('Helvetica').fontSize(8.5).fillColor(FARBE_SOFT)
doc.text(vermieter.name || 'Vermieter', 50, y, { width: 220 })
doc.text(vertrag.mieter_name, 310, y, { width: 220 })
y += 22
// ── Fußzeile ──
doc.font('Helvetica').fontSize(8).fillColor(FARBE_SOFT)
doc.text(
'Mustervorlage, automatisch aus dem Mieterportal erzeugt. Dieser Entwurf ersetzt keine Rechtsberatung – '
+ 'die Verwaltung prüft den Vertrag vor Versand und Unterschrift.',
50, Math.max(y + 8, 770), { width: breite },
)
doc.end()
})
return dateiName
}
/** Erzeugt den Vertrags-Entwurf: PDF + Dokumenten-Eintrag (ersetzt vorhandene). */
async function erzeugeVertragsPdf(pool, vertrag) {
const { rows: einheiten } = await pool.query('SELECT * FROM units WHERE id = $1', [vertrag.unit_id])
if (!einheiten.length) throw Object.assign(new Error('Wohnung nicht gefunden.'), { status: 404 })
const einheit = einheiten[0]
const { rows: haeuser } = await pool.query('SELECT * FROM buildings WHERE name = $1', [einheit.building])
if (!haeuser.length) throw Object.assign(new Error('Haus nicht gefunden.'), { status: 404 })
const haus = haeuser[0]
const vermieter = await vermieterDaten(pool)
const dateiName = await bauePdf(vertrag, einheit, haus, vermieter)
const groesseBytes = fs.statSync(path.join(UPLOAD_DIR, dateiName)).size
const groesse = groesseBytes > 1024
? `${zahl(groesseBytes / 1024)} KB`
: `${groesseBytes} B`
const titel = `Mietvertrag Entwurf – ${einheit.building}, Whg. ${einheit.number} – ${vertrag.mieter_name}`
const { rows: vorhanden } = await pool.query(
'SELECT * FROM documents WHERE id = $1', [vertrag.dokument_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 title = $1, file_name = $2, file_path = $3, size = $4, date = $5, category = 'Mietvertrag', unit_id = $6
WHERE id = $7 RETURNING *`,
[titel, titel, dateiName, groesse, dDatum(), einheit.id, vorhanden[0].id],
)
dokument = rows[0]
} else {
const dokumentId = `d-mv-${vertrag.id}`
const { rows } = await pool.query(
`INSERT INTO documents (id, unit_id, title, category, size, date, file_name, file_path)
VALUES ($1,$2,$3,'Mietvertrag',$4,$5,$6,$7) RETURNING *`,
[dokumentId, einheit.id, titel, groesse, dDatum(), titel, dateiName],
)
dokument = rows[0]
}
await pool.query('UPDATE mietvertraege SET dokument_id = $1, geaendert_am = $2 WHERE id = $3',
[dokument.id, dDatum(), vertrag.id])
return dokument
}
module.exports = { erzeugeVertragsPdf, vermieterDaten, vermieterAdresse }