Fälligkeit der Miete bei Vertragserstellung (1./15.) + endgültiges Löschen zurückgezogener Einladungen

This commit is contained in:
Giuseppe Lombardo 2026-08-13 17:45:29 +00:00
parent 3111f64ecd
commit 89d2a911e1
6 changed files with 103 additions and 21 deletions

View File

@ -73,7 +73,8 @@ export class PaymentService {
/** /**
* Berechnet die Ampel-Status je aktivem Vertrag für das Vermieter-Cockpit. * Berechnet die Ampel-Status je aktivem Vertrag für das Vermieter-Cockpit.
* GRÜN = bezahlt, GELB = fällig aber Frist (3. Werktag) nicht erreicht, ROT = überfällig. * GRÜN = bezahlt, GELB = fällig aber Frist (vertraglicher Fälligkeitstag,
* Contract.paymentDueDay) noch nicht erreicht, ROT = überfällig.
*/ */
async getRentTrafficLights(referenceDate: Date = new Date()): Promise<RentTrafficLight[]> { async getRentTrafficLights(referenceDate: Date = new Date()): Promise<RentTrafficLight[]> {
const activeContracts = await this.prisma.contract.findMany({ const activeContracts = await this.prisma.contract.findMany({
@ -94,19 +95,19 @@ export class PaymentService {
return activeContracts.map((c: ContractWithPayments) => { return activeContracts.map((c: ContractWithPayments) => {
const currentPayment = c.payments[0]; const currentPayment = c.payments[0];
const dueThreshold = thirdBusinessDay(referenceDate); const dueDate = dueDateForMonth(c.paymentDueDay, referenceDate);
const paid = currentPayment?.status === PaymentStatus.PAID; const paid = currentPayment?.status === PaymentStatus.PAID;
let status: RentTrafficLight['status'] = 'YELLOW'; let status: RentTrafficLight['status'] = 'YELLOW';
if (paid) status = 'GREEN'; if (paid) status = 'GREEN';
else if (referenceDate > dueThreshold) status = 'RED'; else if (referenceDate > dueDate) status = 'RED';
return { return {
contractId: c.id, contractId: c.id,
roomNumber: c.room.roomNumber, roomNumber: c.room.roomNumber,
tenantName: c.user.fullName, tenantName: c.user.fullName,
status, status,
dueDate: currentPayment?.dueDate.toISOString() ?? '', dueDate: dueDate.toISOString(),
amountDue: Number(currentPayment?.amount ?? c.totalWarmRent), amountDue: Number(currentPayment?.amount ?? c.totalWarmRent),
amountPaid: paid ? Number(currentPayment.amount) : 0, amountPaid: paid ? Number(currentPayment.amount) : 0,
}; };
@ -118,10 +119,13 @@ export class PaymentService {
* Mahn-Notification, sofern noch keine für diesen Zahlungslauf gesendet wurde. * Mahn-Notification, sofern noch keine für diesen Zahlungslauf gesendet wurde.
*/ */
async runDailyDunningCheck(referenceDate: Date = new Date()) { async runDailyDunningCheck(referenceDate: Date = new Date()) {
// Payment.dueDate ist der je Zahlung konkret hinterlegte Fälligkeitstermin
// (abgeleitet aus Contract.paymentDueDay) — überfällig, sobald er
// verstrichen ist, keine erneute Näherung über den Vertrag nötig.
const overdue = await this.prisma.payment.findMany({ const overdue = await this.prisma.payment.findMany({
where: { where: {
status: { in: [PaymentStatus.PENDING, PaymentStatus.OVERDUE] }, status: { in: [PaymentStatus.PENDING, PaymentStatus.OVERDUE] },
dueDate: { lt: thirdBusinessDay(referenceDate) }, dueDate: { lt: referenceDate },
dunningSentAt: null, dunningSentAt: null,
}, },
include: { contract: { include: { user: true, room: true } } }, include: { contract: { include: { user: true, room: true } } },
@ -168,15 +172,16 @@ function endOfMonth(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth() + 1, 0, 23, 59, 59); return new Date(d.getFullYear(), d.getMonth() + 1, 0, 23, 59, 59);
} }
/** Berechnet den 3. Werktag (Mo-Fr, ohne Feiertagsberücksichtigung) des Monats von `d`. */ /**
function thirdBusinessDay(d: Date): Date { * Fälligkeitsdatum der Miete im Monat von `referenceDate`, gemäß dem im
let count = 0; * Mietvertrag festgelegten Kalendertag (Contract.paymentDueDay, z.B. 1 oder
const cursor = startOfMonth(d); * 15). Liegt der Tag nach dem Monatsende (z.B. 31 im Februar), wird auf den
while (count < 3) { * letzten Tag des Monats begrenzt.
const day = cursor.getDay(); */
if (day !== 0 && day !== 6) count++; function dueDateForMonth(paymentDueDay: number, referenceDate: Date): Date {
if (count < 3) cursor.setDate(cursor.getDate() + 1); const lastDayOfMonth = endOfMonth(referenceDate).getDate();
} const day = Math.min(paymentDueDay, lastDayOfMonth);
cursor.setHours(23, 59, 59, 999); const due = new Date(referenceDate.getFullYear(), referenceDate.getMonth(), day);
return cursor; due.setHours(23, 59, 59, 999);
return due;
} }

View File

@ -453,6 +453,9 @@ contractsRouter.post(
if (!Number.isFinite(Number(depositAmount)) || Number(depositAmount) < 0) { if (!Number.isFinite(Number(depositAmount)) || Number(depositAmount) < 0) {
return res.status(400).json({ error: 'depositAmount muss eine Zahl sein' }); return res.status(400).json({ error: 'depositAmount muss eine Zahl sein' });
} }
if (paymentDueDay !== undefined && paymentDueDay !== null && ![1, 15].includes(Number(paymentDueDay))) {
return res.status(400).json({ error: 'paymentDueDay muss 1 oder 15 sein' });
}
const user = await prisma.user.findUnique({ where: { id: userId } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) return res.status(404).json({ error: 'Mieter nicht gefunden' }); if (!user) return res.status(404).json({ error: 'Mieter nicht gefunden' });
@ -486,7 +489,7 @@ contractsRouter.post(
endDate: parsedEnd, endDate: parsedEnd,
totalWarmRent: Number(totalWarmRent), totalWarmRent: Number(totalWarmRent),
depositAmount: Number(depositAmount), depositAmount: Number(depositAmount),
paymentDueDay: Number.isFinite(Number(paymentDueDay)) ? Number(paymentDueDay) : 3, paymentDueDay: Number.isFinite(Number(paymentDueDay)) ? Number(paymentDueDay) : 1,
noticePeriodMonths: Number.isFinite(Number(noticePeriodMonths)) ? Number(noticePeriodMonths) : 3, noticePeriodMonths: Number.isFinite(Number(noticePeriodMonths)) ? Number(noticePeriodMonths) : 3,
isActive: true, isActive: true,
}, },

View File

@ -34,7 +34,7 @@ invitationsRouter.post(
requireAuth, requireAuth,
requireRole('LANDLORD', 'ADMIN'), requireRole('LANDLORD', 'ADMIN'),
async (req: AuthedRequest, res: Response) => { async (req: AuthedRequest, res: Response) => {
const { email, fullName, roomId, role, contractStartDate, contractEndDate } = req.body || {}; const { email, fullName, roomId, role, contractStartDate, contractEndDate, paymentDueDay } = req.body || {};
if (!email || !String(email).trim()) { if (!email || !String(email).trim()) {
return res.status(400).json({ error: 'E-Mail-Adresse erforderlich' }); return res.status(400).json({ error: 'E-Mail-Adresse erforderlich' });
} }
@ -74,6 +74,9 @@ invitationsRouter.post(
if (contractStartDate !== undefined && contractStartDate !== null && Number.isNaN(Date.parse(contractStartDate))) { if (contractStartDate !== undefined && contractStartDate !== null && Number.isNaN(Date.parse(contractStartDate))) {
return res.status(400).json({ error: 'Mietbeginn ist ungültig' }); return res.status(400).json({ error: 'Mietbeginn ist ungültig' });
} }
if (paymentDueDay !== undefined && paymentDueDay !== null && ![1, 15].includes(Number(paymentDueDay))) {
return res.status(400).json({ error: 'Fälligkeit muss der 1. oder der 15. sein' });
}
parsedContractStart = contractStartDate ? new Date(contractStartDate) : new Date(); parsedContractStart = contractStartDate ? new Date(contractStartDate) : new Date();
parsedContractEnd = contractEndDate ? new Date(contractEndDate) : null; parsedContractEnd = contractEndDate ? new Date(contractEndDate) : null;
@ -98,6 +101,7 @@ invitationsRouter.post(
roomId: roomId || null, roomId: roomId || null,
contractStartDate: parsedContractStart, contractStartDate: parsedContractStart,
contractEndDate: parsedContractEnd, contractEndDate: parsedContractEnd,
paymentDueDay: roomId && paymentDueDay ? Number(paymentDueDay) : null,
invitedById: req.user!.id, invitedById: req.user!.id,
expiresAt, expiresAt,
}, },
@ -140,6 +144,24 @@ invitationsRouter.delete(
}, },
); );
// DELETE /v1/invitations/:id/permanent (nur Admin) — zurückgezogene Einladung
// endgültig aus der Datenbank entfernen (im Gegensatz zum "Zurückziehen"
// oben, das nur den Status auf REVOKED setzt).
invitationsRouter.delete(
'/invitations/:id/permanent',
requireAuth,
requireRole('ADMIN'),
async (req: AuthedRequest, res: Response) => {
const result = await prisma.invitation.deleteMany({
where: { id: req.params.id, status: 'REVOKED' },
});
if (result.count === 0) {
return res.status(404).json({ error: 'Zurückgezogene Einladung nicht gefunden' });
}
res.status(204).send();
},
);
// GET /v1/invitations/:token/preview (öffentlich) — Infos zur Einladung vor Annahme // GET /v1/invitations/:token/preview (öffentlich) — Infos zur Einladung vor Annahme
invitationsRouter.get('/invitations/:token/preview', async (req: Request, res: Response) => { invitationsRouter.get('/invitations/:token/preview', async (req: Request, res: Response) => {
const invitation = await prisma.invitation.findUnique({ const invitation = await prisma.invitation.findUnique({
@ -274,6 +296,7 @@ invitationsRouter.post('/invitations/:token/accept', async (req: Request, res: R
roomId: room.id, roomId: room.id,
startDate: invitation.contractStartDate || new Date(), startDate: invitation.contractStartDate || new Date(),
endDate: invitation.contractEndDate, endDate: invitation.contractEndDate,
paymentDueDay: invitation.paymentDueDay || 1,
totalWarmRent: Number(room.baseRent) + Number(room.utilityPauschal), totalWarmRent: Number(room.baseRent) + Number(room.utilityPauschal),
depositAmount: Number(room.baseRent) * 3, depositAmount: Number(room.baseRent) * 3,
isActive: true, isActive: true,

View File

@ -119,7 +119,7 @@ export function generateContractPdf(contract: ContractForPdf): Promise<Buffer> {
section('§ 4 Miete und Nebenkosten', () => { section('§ 4 Miete und Nebenkosten', () => {
doc.text(`Die Warmmiete beträgt ${formatEuro(contract.totalWarmRent)} monatlich.`); doc.text(`Die Warmmiete beträgt ${formatEuro(contract.totalWarmRent)} monatlich.`);
doc.text( doc.text(
`Sie ist im Voraus, spätestens am ${contract.paymentDueDay}. Werktag eines jeden Monats, auf das ` + `Sie ist im Voraus, spätestens am ${contract.paymentDueDay}. eines jeden Monats, auf das ` +
'vom Vermieter benannte Konto zu entrichten.', 'vom Vermieter benannte Konto zu entrichten.',
); );
doc.text(UTILITY_LABEL[contract.utilityBillingModel]); doc.text(UTILITY_LABEL[contract.utilityBillingModel]);

View File

@ -249,6 +249,7 @@ model Invitation {
acceptedAt DateTime? @map("accepted_at") acceptedAt DateTime? @map("accepted_at")
contractStartDate DateTime? @map("contract_start_date") contractStartDate DateTime? @map("contract_start_date")
contractEndDate DateTime? @map("contract_end_date") contractEndDate DateTime? @map("contract_end_date")
paymentDueDay Int? @map("payment_due_day") // Fälligkeit: 1 oder 15
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
@ -273,7 +274,7 @@ model Contract {
totalWarmRent Decimal @map("total_warm_rent") @db.Decimal(8, 2) totalWarmRent Decimal @map("total_warm_rent") @db.Decimal(8, 2)
depositAmount Decimal @map("deposit_amount") @db.Decimal(8, 2) depositAmount Decimal @map("deposit_amount") @db.Decimal(8, 2)
paymentDueDay Int @default(3) @map("payment_due_day") // Fälligkeit: 3. Werktag paymentDueDay Int @default(1) @map("payment_due_day") // Fälligkeit: Kalendertag im Monat (z.B. 1 oder 15)
// Kündigungsfristen & Anpassungsklauseln // Kündigungsfristen & Anpassungsklauseln
noticePeriodMonths Int @default(3) @map("notice_period_months") noticePeriodMonths Int @default(3) @map("notice_period_months")

View File

@ -681,6 +681,13 @@
<input type="checkbox" id="inviteUnbefristet" checked /> unbefristet <input type="checkbox" id="inviteUnbefristet" checked /> unbefristet
</label> </label>
</div> </div>
<div class="field" id="inviteDueDayField" style="display:none">
<label for="inviteDueDay">Fälligkeit der Miete</label>
<select id="inviteDueDay">
<option value="1">Immer zum 1.</option>
<option value="15">Immer zum 15.</option>
</select>
</div>
<button type="submit" class="btn-primary" style="width:auto" id="inviteSubmitBtn">Einladen</button> <button type="submit" class="btn-primary" style="width:auto" id="inviteSubmitBtn">Einladen</button>
</form> </form>
<div id="inviteLinkResult" style="display:none" class="invite-link-box"> <div id="inviteLinkResult" style="display:none" class="invite-link-box">
@ -1553,11 +1560,14 @@
const startInput = document.getElementById('inviteStartDate'); const startInput = document.getElementById('inviteStartDate');
const endInput = document.getElementById('inviteEndDate'); const endInput = document.getElementById('inviteEndDate');
const unbefristetCheckbox = document.getElementById('inviteUnbefristet'); const unbefristetCheckbox = document.getElementById('inviteUnbefristet');
const dueDayField = document.getElementById('inviteDueDayField');
const dueDaySelect = document.getElementById('inviteDueDay');
roomSelect.onchange = () => { roomSelect.onchange = () => {
const hasRoom = !!roomSelect.value; const hasRoom = !!roomSelect.value;
startField.style.display = hasRoom ? 'flex' : 'none'; startField.style.display = hasRoom ? 'flex' : 'none';
endField.style.display = hasRoom ? 'flex' : 'none'; endField.style.display = hasRoom ? 'flex' : 'none';
dueDayField.style.display = hasRoom ? 'flex' : 'none';
if (hasRoom && !startInput.value) startInput.value = new Date().toISOString().slice(0, 10); if (hasRoom && !startInput.value) startInput.value = new Date().toISOString().slice(0, 10);
}; };
unbefristetCheckbox.onchange = () => { unbefristetCheckbox.onchange = () => {
@ -1581,9 +1591,10 @@
const roomId = roomSelect.value || undefined; const roomId = roomSelect.value || undefined;
const contractStartDate = roomId ? (startInput.value || undefined) : undefined; const contractStartDate = roomId ? (startInput.value || undefined) : undefined;
const contractEndDate = roomId && !unbefristetCheckbox.checked ? (endInput.value || undefined) : undefined; const contractEndDate = roomId && !unbefristetCheckbox.checked ? (endInput.value || undefined) : undefined;
const paymentDueDay = roomId ? Number(dueDaySelect.value) : undefined;
const data = await apiFetch('/invitations', { const data = await apiFetch('/invitations', {
method: 'POST', method: 'POST',
body: JSON.stringify({ email, fullName, roomId, contractStartDate, contractEndDate }), body: JSON.stringify({ email, fullName, roomId, contractStartDate, contractEndDate, paymentDueDay }),
}); });
document.getElementById('inviteLinkText').textContent = data.inviteLink; document.getElementById('inviteLinkText').textContent = data.inviteLink;
document.getElementById('inviteLinkResult').style.display = 'flex'; document.getElementById('inviteLinkResult').style.display = 'flex';
@ -1591,6 +1602,7 @@
document.getElementById('inviteEmail').value = ''; document.getElementById('inviteEmail').value = '';
startField.style.display = 'none'; startField.style.display = 'none';
endField.style.display = 'none'; endField.style.display = 'none';
dueDayField.style.display = 'none';
startInput.value = ''; startInput.value = '';
endInput.value = ''; endInput.value = '';
unbefristetCheckbox.checked = true; unbefristetCheckbox.checked = true;
@ -1639,6 +1651,13 @@
revokeBtn.onclick = () => revokeInvitation(inv.id); revokeBtn.onclick = () => revokeInvitation(inv.id);
actionsTd.appendChild(revokeBtn); actionsTd.appendChild(revokeBtn);
} }
if (inv.status === 'REVOKED' && currentUser.role === 'ADMIN') {
const deleteBtn = document.createElement('button');
deleteBtn.className = 'btn-secondary';
deleteBtn.textContent = 'Endgültig löschen';
deleteBtn.onclick = () => deleteInvitationPermanently(inv.id);
actionsTd.appendChild(deleteBtn);
}
tr.appendChild(actionsTd); tr.appendChild(actionsTd);
body.appendChild(tr); body.appendChild(tr);
}); });
@ -1657,6 +1676,16 @@
} }
} }
async function deleteInvitationPermanently(id) {
if (!confirm('Diese zurückgezogene Einladung endgültig löschen? Das kann nicht rückgängig gemacht werden.')) return;
try {
await apiFetch(`/invitations/${id}/permanent`, { method: 'DELETE' });
loadInvitations();
} catch (err) {
alert(err.message);
}
}
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// COCKPIT-DATEN // COCKPIT-DATEN
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
@ -1739,6 +1768,13 @@
amountRow.appendChild(el('span', 'value', formatEuro(light.amountDue))); amountRow.appendChild(el('span', 'value', formatEuro(light.amountDue)));
card.appendChild(amountRow); card.appendChild(amountRow);
if (light.dueDate) {
const dueDateRow = el('div', 'amount-row');
dueDateRow.appendChild(el('span', 'label', 'Fälligkeitsdatum'));
dueDateRow.appendChild(el('span', 'value', new Date(light.dueDate).toLocaleDateString('de-DE')));
card.appendChild(dueDateRow);
}
if (light.status === 'GREEN') { if (light.status === 'GREEN') {
const paidRow = el('div', 'amount-row'); const paidRow = el('div', 'amount-row');
paidRow.appendChild(el('span', 'label', 'Bezahlt')); paidRow.appendChild(el('span', 'label', 'Bezahlt'));
@ -3755,6 +3791,19 @@
depositField.appendChild(depositInput); depositField.appendChild(depositInput);
formArea.appendChild(depositField); formArea.appendChild(depositField);
const dueDayField = el('div', 'field');
dueDayField.style.minWidth = '150px';
dueDayField.appendChild(el('label', null, 'Fälligkeit der Miete'));
const dueDaySelect = document.createElement('select');
[[1, 'Immer zum 1.'], [15, 'Immer zum 15.']].forEach(([value, label]) => {
const opt = document.createElement('option');
opt.value = value;
opt.textContent = label;
dueDaySelect.appendChild(opt);
});
dueDayField.appendChild(dueDaySelect);
formArea.appendChild(dueDayField);
const roomInfoById = {}; const roomInfoById = {};
// Datumsbewusste Zimmerauswahl: bei jeder Änderung von Mietbeginn/-ende // Datumsbewusste Zimmerauswahl: bei jeder Änderung von Mietbeginn/-ende
@ -3816,6 +3865,7 @@
endDate: unbefristetCheckbox.checked ? undefined : (endInput.value || undefined), endDate: unbefristetCheckbox.checked ? undefined : (endInput.value || undefined),
totalWarmRent: rentInput.value, totalWarmRent: rentInput.value,
depositAmount: depositInput.value, depositAmount: depositInput.value,
paymentDueDay: Number(dueDaySelect.value),
}, submitBtn); }, submitBtn);
formArea.appendChild(submitBtn); formArea.appendChild(submitBtn);