diff --git a/backend/src/routes/contracts.ts b/backend/src/routes/contracts.ts index 28ee612..2e44a54 100644 --- a/backend/src/routes/contracts.ts +++ b/backend/src/routes/contracts.ts @@ -98,6 +98,9 @@ const CONTRACT_DOCUMENTS_SELECT = { wifiIncluded: true, furnished: true, utilityBillingModel: true, + landlordName: true, + landlordAddress: true, + propertyAddress: true, signedAt: true, user: { select: { id: true, fullName: true } }, room: { select: { id: true, roomNumber: true, sizeSqm: true } }, @@ -193,6 +196,9 @@ contractsRouter.post( depositAmount, rentAdjustmentType, rentAdjustmentClause, + landlordName, + landlordAddress, + propertyAddress, } = req.body || {}; if (utilityBillingModel !== undefined && !UTILITY_MODELS.includes(utilityBillingModel)) { @@ -212,6 +218,9 @@ contractsRouter.post( depositAmount: Number.isFinite(Number(depositAmount)) ? Number(depositAmount) : undefined, rentAdjustmentType: rentAdjustmentType || undefined, rentAdjustmentClause: typeof rentAdjustmentClause === 'string' ? rentAdjustmentClause.trim() || null : undefined, + landlordName: typeof landlordName === 'string' && landlordName.trim() ? landlordName.trim() : undefined, + landlordAddress: typeof landlordAddress === 'string' ? landlordAddress.trim() || null : undefined, + propertyAddress: typeof propertyAddress === 'string' ? propertyAddress.trim() || null : undefined, }, include: { room: true, user: true }, }).catch(() => null); diff --git a/backend/src/services/contractDocumentGenerator.ts b/backend/src/services/contractDocumentGenerator.ts index 6a43e52..19608e1 100644 --- a/backend/src/services/contractDocumentGenerator.ts +++ b/backend/src/services/contractDocumentGenerator.ts @@ -3,15 +3,24 @@ import { Contract, Room, User, UtilityBillingModel, RentAdjustmentType } from '@ type ContractForPdf = Contract & { room: Room; user: User }; -const ADJUSTMENT_LABEL: Record = { - NONE: 'Keine Mietanpassungsklausel.', - INDEX_MIETE: 'Indexmietklausel: Die Miete kann sich gemäß der Entwicklung des Verbraucherpreisindexes ändern.', - STAFFEL_MIETE: 'Staffelmietklausel: Die Miete steigt zu vorab vereinbarten Zeitpunkten in vereinbarten Schritten.', +const UTILITY_LABEL: Record = { + PAUSCHALE: + 'Die Nebenkosten werden als Pauschale erhoben und sind in der in § 4 genannten Warmmiete enthalten. ' + + 'Eine gesonderte Abrechnung findet nicht statt, auch nicht bei Nachzahlung oder Guthaben.', + ABRECHNUNG: + 'Die Nebenkosten werden als Vorauszahlung erhoben und jährlich auf Basis der tatsächlich angefallenen ' + + 'Kosten anteilig nach Zimmergröße (§ 556a BGB) abgerechnet. Nachzahlungen und Guthaben werden im ' + + 'Rahmen der jeweiligen Abrechnung ausgeglichen.', }; -const UTILITY_LABEL: Record = { - PAUSCHALE: 'Die Nebenkosten sind als Pauschale in der Warmmiete enthalten und werden nicht separat abgerechnet.', - ABRECHNUNG: 'Die Nebenkosten werden jährlich auf Basis der tatsächlichen Kosten anteilig nach Zimmergröße abgerechnet.', +const ADJUSTMENT_LABEL: Record = { + NONE: 'Es ist keine Mietanpassungsklausel vereinbart. Mieterhöhungen richten sich nach den gesetzlichen Vorschriften (§§ 557 ff. BGB).', + INDEX_MIETE: + 'Es wird eine Indexmiete gemäß § 557b BGB vereinbart. Die Miete verändert sich entsprechend der ' + + 'Entwicklung des vom Statistischen Bundesamt ermittelten Verbraucherpreisindexes für Deutschland.', + STAFFEL_MIETE: + 'Es wird eine Staffelmiete gemäß § 557a BGB vereinbart. Die Miete erhöht sich zu den nachfolgend ' + + 'genannten Zeitpunkten um die jeweils vereinbarten Beträge.', }; function formatEuro(value: unknown): string { @@ -19,82 +28,199 @@ function formatEuro(value: unknown): string { } function formatDate(date: Date | null | undefined): string { - return date ? date.toLocaleDateString('de-DE') : 'unbefristet'; + return date ? date.toLocaleDateString('de-DE') : null as unknown as string; +} + +function addressOrPlaceholder(value: string | null | undefined): string { + return value && value.trim() ? value.trim() : '[Anschrift bitte im Vertragsformular ergänzen]'; } /** - * Erzeugt aus den Vertrags-Baukasten-Feldern (siehe Contract-Model: - * wifiIncluded, furnished, utilityBillingModel, noticePeriodMonths, - * rentAdjustmentType, depositAmount, ...) ein PDF-Dokument als Buffer. + * Erzeugt aus den Vertrags-Baukasten-Feldern ein vollständig strukturiertes + * PDF im Format eines gewöhnlichen Wohnraum-/WG-Zimmer-Mietvertrags + * (Vertragsparteien, Mieträume, Mietzeit, Miete/Nebenkosten, Kaution, + * Instandhaltung, Hausordnung, Tierhaltung, Untervermietung, Kündigung, + * Mietanpassung, salvatorische Klausel, Unterschriften). * - * Ausdrücklich eine Vorlage/Dokumentation auf Basis der App-Daten, keine - * rechtsverbindliche oder anwaltlich geprüfte Vertragsvorlage. + * Ausdrücklich eine aus den App-Daten generierte Vorlage, keine + * anwaltlich geprüfte oder rechtsverbindlich garantierte Vertragsurkunde + * — siehe Hinweis am Dokumentende. */ export function generateContractPdf(contract: ContractForPdf): Promise { return new Promise((resolve, reject) => { - const doc = new PDFDocument({ margin: 56 }); + const doc = new PDFDocument({ margin: 56, bufferPages: true, size: 'A4' }); const chunks: Buffer[] = []; doc.on('data', (chunk) => chunks.push(chunk)); doc.on('end', () => resolve(Buffer.concat(chunks))); doc.on('error', reject); - doc.fontSize(18).text('Mietvertrag über Wohnraum (Vorlage)', { underline: true }); - doc.moveDown(0.3); - doc.fontSize(9).fillColor('#666666').text( - 'Automatisch erstellte Vorlage auf Basis der Angaben in der WG-Verwaltungs-App. ' + - 'Keine Rechtsberatung, keine Gewähr auf rechtliche Vollständigkeit.', + const H1 = () => doc.font('Helvetica-Bold').fontSize(11); + const BODY = () => doc.font('Helvetica').fontSize(10.5); + const section = (title: string, bodyFn: () => void) => { + doc.moveDown(0.9); + H1().text(title); + doc.moveDown(0.25); + BODY(); + bodyFn(); + }; + + // --- Titel --- + doc.font('Helvetica-Bold').fontSize(17).text('Mietvertrag über Wohnraum', { align: 'center' }); + doc.moveDown(0.15); + doc.font('Helvetica').fontSize(10).fillColor('#555555').text( + `Zimmer ${contract.room.roomNumber} · ${addressOrPlaceholder(contract.propertyAddress)}`, + { align: 'center' }, ); doc.fillColor('#000000'); - doc.moveDown(1); + doc.moveDown(1.2); - doc.fontSize(13).text('§ 1 Mietparteien und Mietobjekt', { underline: true }); - doc.fontSize(11).moveDown(0.3); - doc.text(`Vermieter: Familie Lombardo, WG Nackenheim`); - doc.text(`Mieter: ${contract.user.fullName}`); - doc.text(`Mietobjekt: ${contract.room.roomNumber}, ${Number(contract.room.sizeSqm)} qm, WG Nackenheim`); - doc.moveDown(1); + // --- § 1 Vertragsparteien --- + section('§ 1 Vertragsparteien', () => { + doc.text(`Vermieter: ${contract.landlordName}`); + doc.text(`Anschrift des Vermieters: ${addressOrPlaceholder(contract.landlordAddress)}`); + doc.moveDown(0.4); + doc.text(`Mieter: ${contract.user.fullName}`); + doc.text(`E-Mail: ${contract.user.email}`); + doc.moveDown(0.4); + doc.text( + 'Vermieter und Mieter schließen den nachfolgenden Mietvertrag über den in § 2 bezeichneten Wohnraum ' + + 'innerhalb einer gemeinschaftlich bewohnten Wohnung (Wohngemeinschaft).', + ); + }); - doc.fontSize(13).text('§ 2 Mietzeit', { underline: true }); - doc.fontSize(11).moveDown(0.3); - doc.text(`Mietbeginn: ${formatDate(contract.startDate)}`); - doc.text(`Mietende: ${formatDate(contract.endDate)}`); - doc.text(`Kündigungsfrist: ${contract.noticePeriodMonths} Monate zum Monatsende.`); - doc.moveDown(1); + // --- § 2 Mieträume --- + section('§ 2 Mieträume', () => { + doc.text(`Anschrift der Wohnung: ${addressOrPlaceholder(contract.propertyAddress)}`); + doc.text(`Vermietetes Zimmer: ${contract.room.roomNumber}, ca. ${Number(contract.room.sizeSqm)} m².`); + doc.text( + 'Mitbenutzung: Küche, Bad/WC sowie ggf. weitere Gemeinschaftsflächen der Wohnung stehen dem Mieter ' + + 'gemeinsam mit den übrigen Bewohnern zur Mitbenutzung zur Verfügung.', + ); + doc.text(`Das Zimmer wird ${contract.furnished ? 'möbliert' : 'unmöbliert'} übergeben.`); + doc.text(`Internet/WLAN: ${contract.wifiIncluded ? 'im Mietpreis enthalten.' : 'nicht im Mietpreis enthalten.'}`); + }); - doc.fontSize(13).text('§ 3 Miete und Nebenkosten', { underline: true }); - doc.fontSize(11).moveDown(0.3); - doc.text(`Warmmiete gesamt: ${formatEuro(contract.totalWarmRent)} pro Monat, fällig am ${contract.paymentDueDay}. Werktag.`); - doc.text(UTILITY_LABEL[contract.utilityBillingModel]); - doc.text(`Kaution: ${formatEuro(contract.depositAmount)}.`); - doc.moveDown(1); + // --- § 3 Mietzeit --- + section('§ 3 Mietzeit', () => { + doc.text(`Das Mietverhältnis beginnt am ${formatDate(contract.startDate)}.`); + doc.text( + contract.endDate + ? `Es endet, ohne dass es einer Kündigung bedarf, am ${formatDate(contract.endDate)}.` + : 'Es läuft auf unbestimmte Zeit.', + ); + doc.text( + `Die ordentliche Kündigungsfrist beträgt ${contract.noticePeriodMonths} Monate zum Ende eines ` + + 'Kalendermonats. Die Kündigung bedarf der Schriftform. Das Recht zur außerordentlichen ' + + 'Kündigung aus wichtigem Grund bleibt unberührt.', + ); + }); - doc.fontSize(13).text('§ 4 Ausstattung', { underline: true }); - doc.fontSize(11).moveDown(0.3); - doc.text(`WLAN: ${contract.wifiIncluded ? 'im Mietpreis inbegriffen.' : 'nicht im Mietpreis enthalten.'}`); - doc.text(`Zimmer: ${contract.furnished ? 'möbliert übergeben.' : 'unmöbliert übergeben.'}`); - doc.moveDown(1); + // --- § 4 Miete und Nebenkosten --- + section('§ 4 Miete und Nebenkosten', () => { + doc.text(`Die Warmmiete beträgt ${formatEuro(contract.totalWarmRent)} monatlich.`); + doc.text( + `Sie ist im Voraus, spätestens am ${contract.paymentDueDay}. Werktag eines jeden Monats, auf das ` + + 'vom Vermieter benannte Konto zu entrichten.', + ); + doc.text(UTILITY_LABEL[contract.utilityBillingModel]); + }); - doc.fontSize(13).text('§ 5 Mietanpassung', { underline: true }); - doc.fontSize(11).moveDown(0.3); - doc.text(ADJUSTMENT_LABEL[contract.rentAdjustmentType]); - if (contract.rentAdjustmentClause) doc.text(contract.rentAdjustmentClause); - doc.moveDown(2); + // --- § 5 Kaution --- + section('§ 5 Kaution', () => { + doc.text( + `Der Mieter leistet eine Mietsicherheit (Kaution) in Höhe von ${formatEuro(contract.depositAmount)}. ` + + 'Die Kaution wird getrennt vom Vermögen des Vermieters angelegt und nach Beendigung des ' + + 'Mietverhältnisses und ordnungsgemäßer Rückgabe des Mietobjekts abzüglich etwaiger berechtigter ' + + 'Forderungen des Vermieters zurückerstattet.', + ); + }); + + // --- § 6 Instandhaltung und Schönheitsreparaturen --- + section('§ 6 Instandhaltung und Schönheitsreparaturen', () => { + doc.text( + 'Der Mieter hat das Mietobjekt pfleglich zu behandeln und Schäden, die über die vertragsgemäße ' + + 'Abnutzung hinausgehen, dem Vermieter unverzüglich anzuzeigen. Schäden, die durch den Mieter, ' + + 'seine Besucher oder von ihm veranlasste Dritte schuldhaft verursacht wurden, sind vom Mieter zu ' + + 'ersetzen. Für die Ausführung von Schönheitsreparaturen gelten die gesetzlichen Regelungen, ' + + 'soweit hierzu keine gesonderte, wirksame Vereinbarung getroffen wurde.', + ); + }); + + // --- § 7 Hausordnung und Gemeinschaftsleben --- + section('§ 7 Hausordnung und Gemeinschaftsleben', () => { + doc.text( + 'Der Mieter verpflichtet sich, die geltende Hausordnung sowie die innerhalb der Wohngemeinschaft ' + + 'einvernehmlich abgestimmten Regelungen (u. a. Putzplan) einzuhalten und auf die übrigen ' + + 'Bewohner Rücksicht zu nehmen. Die aktuelle Hausordnung ist im Dokumenten-Safe der ' + + 'Verwaltungs-App hinterlegt.', + ); + }); + + // --- § 8 Tierhaltung und Untervermietung --- + section('§ 8 Tierhaltung und Untervermietung', () => { + doc.text( + 'Die Haltung von Haustieren sowie die vollständige oder teilweise Untervermietung/Gebrauchsüberlassung ' + + 'des Mietobjekts an Dritte bedürfen der vorherigen schriftlichen Zustimmung des Vermieters. Ein ' + + 'gesetzlicher Anspruch auf Erteilung der Erlaubnis bleibt hiervon unberührt.', + ); + }); + + // --- § 9 Mietanpassung --- + section('§ 9 Mietanpassung', () => { + doc.text(ADJUSTMENT_LABEL[contract.rentAdjustmentType]); + if (contract.rentAdjustmentClause) doc.text(contract.rentAdjustmentClause); + }); + + // --- § 10 Sonstige Vereinbarungen --- + section('§ 10 Sonstige Vereinbarungen', () => { + doc.text( + 'Änderungen und Ergänzungen dieses Vertrags bedürfen der Schriftform. Dies gilt auch für die ' + + 'Aufhebung des Schriftformerfordernisses selbst. Sollte eine Bestimmung dieses Vertrags unwirksam ' + + 'sein oder werden, bleibt die Wirksamkeit der übrigen Bestimmungen hiervon unberührt.', + ); + }); + + // --- Unterschriften --- + doc.moveDown(1.4); + H1().text('Ort, Datum'); + BODY().moveDown(0.2).text('Nackenheim, ' + formatDate(contract.signedAt || new Date())); + doc.moveDown(1.2); + + const sigTop = doc.y; + doc.moveTo(56, sigTop + 40).lineTo(260, sigTop + 40).stroke(); + doc.moveTo(320, sigTop + 40).lineTo(524, sigTop + 40).stroke(); + doc.fontSize(9).text('Vermieter', 56, sigTop + 44, { width: 204 }); + doc.text('Mieter', 320, sigTop + 44, { width: 204 }); if (contract.signedAt && contract.tenantSignatureUrl) { - doc.fontSize(13).text('§ 6 Unterschrift', { underline: true }); - doc.fontSize(11).moveDown(0.3); - doc.text(`Digital unterschrieben von ${contract.user.fullName} am ${formatDate(contract.signedAt)}.`); - doc.moveDown(0.5); const match = /^data:image\/(png|jpeg);base64,(.+)$/.exec(contract.tenantSignatureUrl); if (match) { try { - doc.image(Buffer.from(match[2], 'base64'), { width: 200 }); + doc.image(Buffer.from(match[2], 'base64'), 320, sigTop - 36, { width: 180 }); } catch { - doc.text('(Unterschrift-Bild konnte nicht eingebettet werden)'); + /* Unterschrift-Bild ungültig — Zeile bleibt leer, Signatur-Vermerk unten reicht als Beleg. */ } } - } else { - doc.fontSize(11).text('Noch nicht unterschrieben.'); + doc.fontSize(9).fillColor('#555555').text( + `Digital unterschrieben von ${contract.user.fullName} am ${formatDate(contract.signedAt)}.`, + 56, + sigTop + 60, + ); + doc.fillColor('#000000'); + } + + // --- Fußzeile mit Hinweis auf allen Seiten --- + const pageCount = doc.bufferedPageRange().count; + for (let i = 0; i < pageCount; i++) { + doc.switchToPage(i); + doc.fontSize(7.5).fillColor('#888888').text( + 'Automatisiert aus den Angaben in der WG-Verwaltungs-App erstellte Vertragsvorlage — keine Rechtsberatung, ' + + `keine Gewähr für rechtliche Vollständigkeit. Seite ${i + 1} von ${pageCount}.`, + 56, + doc.page.height - 40, + { width: doc.page.width - 112, align: 'center' }, + ); + doc.fillColor('#000000'); } doc.end(); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8771621..f6c6239 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -281,6 +281,9 @@ model Contract { wifiIncluded Boolean @default(false) @map("wifi_included") furnished Boolean @default(false) utilityBillingModel UtilityBillingModel @default(PAUSCHALE) @map("utility_billing_model") + landlordName String @default("Familie Lombardo") @map("landlord_name") + landlordAddress String? @map("landlord_address") // vom Vermieter selbst einzutragen, keine erfundene Anschrift + propertyAddress String? @map("property_address") // Anschrift der Wohnung/des Hauses // Digitale Unterschrift des Mieters zum generierten Vertragsdokument tenantSignatureUrl String? @map("tenant_signature_url") // Data-URL des gezeichneten Unterschrift-Bilds diff --git a/web-dashboard/index.html b/web-dashboard/index.html index f7973f0..00d700b 100644 --- a/web-dashboard/index.html +++ b/web-dashboard/index.html @@ -2849,6 +2849,35 @@ function buildContractTemplateForm(c) { const form = el('div', 'contract-template-form'); + const landlordNameField = el('div', 'field'); + landlordNameField.style.minWidth = '180px'; + landlordNameField.appendChild(el('label', null, 'Vermieter (Name)')); + const landlordNameInput = document.createElement('input'); + landlordNameInput.type = 'text'; + landlordNameInput.value = c.landlordName || ''; + landlordNameField.appendChild(landlordNameInput); + form.appendChild(landlordNameField); + + const landlordAddressField = el('div', 'field'); + landlordAddressField.style.minWidth = '220px'; + landlordAddressField.appendChild(el('label', null, 'Anschrift des Vermieters')); + const landlordAddressInput = document.createElement('input'); + landlordAddressInput.type = 'text'; + landlordAddressInput.placeholder = 'Straße Hausnr., PLZ Ort'; + landlordAddressInput.value = c.landlordAddress || ''; + landlordAddressField.appendChild(landlordAddressInput); + form.appendChild(landlordAddressField); + + const propertyAddressField = el('div', 'field'); + propertyAddressField.style.minWidth = '220px'; + propertyAddressField.appendChild(el('label', null, 'Anschrift der Wohnung')); + const propertyAddressInput = document.createElement('input'); + propertyAddressInput.type = 'text'; + propertyAddressInput.placeholder = 'Straße Hausnr., PLZ Nackenheim'; + propertyAddressInput.value = c.propertyAddress || ''; + propertyAddressField.appendChild(propertyAddressInput); + form.appendChild(propertyAddressField); + const wifiField = el('label', 'field-checkbox'); const wifiCheckbox = document.createElement('input'); wifiCheckbox.type = 'checkbox'; @@ -2926,6 +2955,9 @@ rentAdjustmentType: adjustmentSelect.value, noticePeriodMonths: noticeInput.value, depositAmount: depositInput.value, + landlordName: landlordNameInput.value, + landlordAddress: landlordAddressInput.value, + propertyAddress: propertyAddressInput.value, }, generateBtn); form.appendChild(generateBtn);