Restructure generated contract as a full-format rental agreement

Rewrites contractDocumentGenerator.ts from a short summary into a
proper Wohnraummietvertrag layout: numbered §§ for Vertragsparteien,
Mieträume, Mietzeit, Miete/Nebenkosten, Kaution, Instandhaltung/
Schönheitsreparaturen, Hausordnung, Tierhaltung/Untervermietung,
Mietanpassung, sonstige Vereinbarungen, salvatorische Klausel, plus
signature lines for both landlord and tenant (with the drawn signature
embedded once signed) and a page-footer disclaimer instead of a
top-of-document warning box.

Adds Contract.landlordName/landlordAddress/propertyAddress so the
document can show a real letterhead — addresses default to a
"please fill in" placeholder rather than a fabricated address, since
this is a real WG's data. Landlord fills them once via new fields in
the contract generator form; persisted per contract like the other
template options.
This commit is contained in:
Giuseppe Lombardo 2026-08-13 10:37:57 +00:00
parent bba4fc318b
commit 410db93c30
4 changed files with 226 additions and 56 deletions

View File

@ -98,6 +98,9 @@ const CONTRACT_DOCUMENTS_SELECT = {
wifiIncluded: true, wifiIncluded: true,
furnished: true, furnished: true,
utilityBillingModel: true, utilityBillingModel: true,
landlordName: true,
landlordAddress: true,
propertyAddress: true,
signedAt: true, signedAt: true,
user: { select: { id: true, fullName: true } }, user: { select: { id: true, fullName: true } },
room: { select: { id: true, roomNumber: true, sizeSqm: true } }, room: { select: { id: true, roomNumber: true, sizeSqm: true } },
@ -193,6 +196,9 @@ contractsRouter.post(
depositAmount, depositAmount,
rentAdjustmentType, rentAdjustmentType,
rentAdjustmentClause, rentAdjustmentClause,
landlordName,
landlordAddress,
propertyAddress,
} = req.body || {}; } = req.body || {};
if (utilityBillingModel !== undefined && !UTILITY_MODELS.includes(utilityBillingModel)) { if (utilityBillingModel !== undefined && !UTILITY_MODELS.includes(utilityBillingModel)) {
@ -212,6 +218,9 @@ contractsRouter.post(
depositAmount: Number.isFinite(Number(depositAmount)) ? Number(depositAmount) : undefined, depositAmount: Number.isFinite(Number(depositAmount)) ? Number(depositAmount) : undefined,
rentAdjustmentType: rentAdjustmentType || undefined, rentAdjustmentType: rentAdjustmentType || undefined,
rentAdjustmentClause: typeof rentAdjustmentClause === 'string' ? rentAdjustmentClause.trim() || null : 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 }, include: { room: true, user: true },
}).catch(() => null); }).catch(() => null);

View File

@ -3,15 +3,24 @@ import { Contract, Room, User, UtilityBillingModel, RentAdjustmentType } from '@
type ContractForPdf = Contract & { room: Room; user: User }; type ContractForPdf = Contract & { room: Room; user: User };
const ADJUSTMENT_LABEL: Record<RentAdjustmentType, string> = { const UTILITY_LABEL: Record<UtilityBillingModel, string> = {
NONE: 'Keine Mietanpassungsklausel.', PAUSCHALE:
INDEX_MIETE: 'Indexmietklausel: Die Miete kann sich gemäß der Entwicklung des Verbraucherpreisindexes ändern.', 'Die Nebenkosten werden als Pauschale erhoben und sind in der in § 4 genannten Warmmiete enthalten. ' +
STAFFEL_MIETE: 'Staffelmietklausel: Die Miete steigt zu vorab vereinbarten Zeitpunkten in vereinbarten Schritten.', '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<UtilityBillingModel, string> = { const ADJUSTMENT_LABEL: Record<RentAdjustmentType, string> = {
PAUSCHALE: 'Die Nebenkosten sind als Pauschale in der Warmmiete enthalten und werden nicht separat abgerechnet.', NONE: 'Es ist keine Mietanpassungsklausel vereinbart. Mieterhöhungen richten sich nach den gesetzlichen Vorschriften (§§ 557 ff. BGB).',
ABRECHNUNG: 'Die Nebenkosten werden jährlich auf Basis der tatsächlichen Kosten anteilig nach Zimmergröße abgerechnet.', 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 { function formatEuro(value: unknown): string {
@ -19,82 +28,199 @@ function formatEuro(value: unknown): string {
} }
function formatDate(date: Date | null | undefined): 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: * Erzeugt aus den Vertrags-Baukasten-Feldern ein vollständig strukturiertes
* wifiIncluded, furnished, utilityBillingModel, noticePeriodMonths, * PDF im Format eines gewöhnlichen Wohnraum-/WG-Zimmer-Mietvertrags
* rentAdjustmentType, depositAmount, ...) ein PDF-Dokument als Buffer. * (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 * Ausdrücklich eine aus den App-Daten generierte Vorlage, keine
* rechtsverbindliche oder anwaltlich geprüfte Vertragsvorlage. * anwaltlich geprüfte oder rechtsverbindlich garantierte Vertragsurkunde
* siehe Hinweis am Dokumentende.
*/ */
export function generateContractPdf(contract: ContractForPdf): Promise<Buffer> { export function generateContractPdf(contract: ContractForPdf): Promise<Buffer> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const doc = new PDFDocument({ margin: 56 }); const doc = new PDFDocument({ margin: 56, bufferPages: true, size: 'A4' });
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
doc.on('data', (chunk) => chunks.push(chunk)); doc.on('data', (chunk) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks))); doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject); doc.on('error', reject);
doc.fontSize(18).text('Mietvertrag über Wohnraum (Vorlage)', { underline: true }); const H1 = () => doc.font('Helvetica-Bold').fontSize(11);
doc.moveDown(0.3); const BODY = () => doc.font('Helvetica').fontSize(10.5);
doc.fontSize(9).fillColor('#666666').text( const section = (title: string, bodyFn: () => void) => {
'Automatisch erstellte Vorlage auf Basis der Angaben in der WG-Verwaltungs-App. ' + doc.moveDown(0.9);
'Keine Rechtsberatung, keine Gewähr auf rechtliche Vollständigkeit.', 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.fillColor('#000000');
doc.moveDown(1); doc.moveDown(1.2);
doc.fontSize(13).text('§ 1 Mietparteien und Mietobjekt', { underline: true }); // --- § 1 Vertragsparteien ---
doc.fontSize(11).moveDown(0.3); section('§ 1 Vertragsparteien', () => {
doc.text(`Vermieter: Familie Lombardo, WG Nackenheim`); 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(`Mieter: ${contract.user.fullName}`);
doc.text(`Mietobjekt: ${contract.room.roomNumber}, ${Number(contract.room.sizeSqm)} qm, WG Nackenheim`); doc.text(`E-Mail: ${contract.user.email}`);
doc.moveDown(1); 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 }); // --- § 2 Mieträume ---
doc.fontSize(11).moveDown(0.3); section('§ 2 Mieträume', () => {
doc.text(`Mietbeginn: ${formatDate(contract.startDate)}`); doc.text(`Anschrift der Wohnung: ${addressOrPlaceholder(contract.propertyAddress)}`);
doc.text(`Mietende: ${formatDate(contract.endDate)}`); doc.text(`Vermietetes Zimmer: ${contract.room.roomNumber}, ca. ${Number(contract.room.sizeSqm)} m².`);
doc.text(`Kündigungsfrist: ${contract.noticePeriodMonths} Monate zum Monatsende.`); doc.text(
doc.moveDown(1); '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 }); // --- § 3 Mietzeit ---
doc.fontSize(11).moveDown(0.3); section('§ 3 Mietzeit', () => {
doc.text(`Warmmiete gesamt: ${formatEuro(contract.totalWarmRent)} pro Monat, fällig am ${contract.paymentDueDay}. Werktag.`); 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.',
);
});
// --- § 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.text(UTILITY_LABEL[contract.utilityBillingModel]);
doc.text(`Kaution: ${formatEuro(contract.depositAmount)}.`); });
doc.moveDown(1);
doc.fontSize(13).text('§ 4 Ausstattung', { underline: true }); // --- § 5 Kaution ---
doc.fontSize(11).moveDown(0.3); section('§ 5 Kaution', () => {
doc.text(`WLAN: ${contract.wifiIncluded ? 'im Mietpreis inbegriffen.' : 'nicht im Mietpreis enthalten.'}`); doc.text(
doc.text(`Zimmer: ${contract.furnished ? 'möbliert übergeben.' : 'unmöbliert übergeben.'}`); `Der Mieter leistet eine Mietsicherheit (Kaution) in Höhe von ${formatEuro(contract.depositAmount)}. ` +
doc.moveDown(1); '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.',
);
});
doc.fontSize(13).text('§ 5 Mietanpassung', { underline: true }); // --- § 6 Instandhaltung und Schönheitsreparaturen ---
doc.fontSize(11).moveDown(0.3); 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]); doc.text(ADJUSTMENT_LABEL[contract.rentAdjustmentType]);
if (contract.rentAdjustmentClause) doc.text(contract.rentAdjustmentClause); if (contract.rentAdjustmentClause) doc.text(contract.rentAdjustmentClause);
doc.moveDown(2); });
// --- § 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) { 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); const match = /^data:image\/(png|jpeg);base64,(.+)$/.exec(contract.tenantSignatureUrl);
if (match) { if (match) {
try { try {
doc.image(Buffer.from(match[2], 'base64'), { width: 200 }); doc.image(Buffer.from(match[2], 'base64'), 320, sigTop - 36, { width: 180 });
} catch { } 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(9).fillColor('#555555').text(
doc.fontSize(11).text('Noch nicht unterschrieben.'); `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(); doc.end();

View File

@ -281,6 +281,9 @@ model Contract {
wifiIncluded Boolean @default(false) @map("wifi_included") wifiIncluded Boolean @default(false) @map("wifi_included")
furnished Boolean @default(false) furnished Boolean @default(false)
utilityBillingModel UtilityBillingModel @default(PAUSCHALE) @map("utility_billing_model") 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 // Digitale Unterschrift des Mieters zum generierten Vertragsdokument
tenantSignatureUrl String? @map("tenant_signature_url") // Data-URL des gezeichneten Unterschrift-Bilds tenantSignatureUrl String? @map("tenant_signature_url") // Data-URL des gezeichneten Unterschrift-Bilds

View File

@ -2849,6 +2849,35 @@
function buildContractTemplateForm(c) { function buildContractTemplateForm(c) {
const form = el('div', 'contract-template-form'); 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 wifiField = el('label', 'field-checkbox');
const wifiCheckbox = document.createElement('input'); const wifiCheckbox = document.createElement('input');
wifiCheckbox.type = 'checkbox'; wifiCheckbox.type = 'checkbox';
@ -2926,6 +2955,9 @@
rentAdjustmentType: adjustmentSelect.value, rentAdjustmentType: adjustmentSelect.value,
noticePeriodMonths: noticeInput.value, noticePeriodMonths: noticeInput.value,
depositAmount: depositInput.value, depositAmount: depositInput.value,
landlordName: landlordNameInput.value,
landlordAddress: landlordAddressInput.value,
propertyAddress: propertyAddressInput.value,
}, generateBtn); }, generateBtn);
form.appendChild(generateBtn); form.appendChild(generateBtn);