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.
* 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[]> {
const activeContracts = await this.prisma.contract.findMany({
@ -94,19 +95,19 @@ export class PaymentService {
return activeContracts.map((c: ContractWithPayments) => {
const currentPayment = c.payments[0];
const dueThreshold = thirdBusinessDay(referenceDate);
const dueDate = dueDateForMonth(c.paymentDueDay, referenceDate);
const paid = currentPayment?.status === PaymentStatus.PAID;
let status: RentTrafficLight['status'] = 'YELLOW';
if (paid) status = 'GREEN';
else if (referenceDate > dueThreshold) status = 'RED';
else if (referenceDate > dueDate) status = 'RED';
return {
contractId: c.id,
roomNumber: c.room.roomNumber,
tenantName: c.user.fullName,
status,
dueDate: currentPayment?.dueDate.toISOString() ?? '',
dueDate: dueDate.toISOString(),
amountDue: Number(currentPayment?.amount ?? c.totalWarmRent),
amountPaid: paid ? Number(currentPayment.amount) : 0,
};
@ -118,10 +119,13 @@ export class PaymentService {
* Mahn-Notification, sofern noch keine für diesen Zahlungslauf gesendet wurde.
*/
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({
where: {
status: { in: [PaymentStatus.PENDING, PaymentStatus.OVERDUE] },
dueDate: { lt: thirdBusinessDay(referenceDate) },
dueDate: { lt: referenceDate },
dunningSentAt: null,
},
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);
}
/** Berechnet den 3. Werktag (Mo-Fr, ohne Feiertagsberücksichtigung) des Monats von `d`. */
function thirdBusinessDay(d: Date): Date {
let count = 0;
const cursor = startOfMonth(d);
while (count < 3) {
const day = cursor.getDay();
if (day !== 0 && day !== 6) count++;
if (count < 3) cursor.setDate(cursor.getDate() + 1);
}
cursor.setHours(23, 59, 59, 999);
return cursor;
/**
* Fälligkeitsdatum der Miete im Monat von `referenceDate`, gemäß dem im
* Mietvertrag festgelegten Kalendertag (Contract.paymentDueDay, z.B. 1 oder
* 15). Liegt der Tag nach dem Monatsende (z.B. 31 im Februar), wird auf den
* letzten Tag des Monats begrenzt.
*/
function dueDateForMonth(paymentDueDay: number, referenceDate: Date): Date {
const lastDayOfMonth = endOfMonth(referenceDate).getDate();
const day = Math.min(paymentDueDay, lastDayOfMonth);
const due = new Date(referenceDate.getFullYear(), referenceDate.getMonth(), day);
due.setHours(23, 59, 59, 999);
return due;
}

View File

@ -453,6 +453,9 @@ contractsRouter.post(
if (!Number.isFinite(Number(depositAmount)) || Number(depositAmount) < 0) {
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 } });
if (!user) return res.status(404).json({ error: 'Mieter nicht gefunden' });
@ -486,7 +489,7 @@ contractsRouter.post(
endDate: parsedEnd,
totalWarmRent: Number(totalWarmRent),
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,
isActive: true,
},

View File

@ -34,7 +34,7 @@ invitationsRouter.post(
requireAuth,
requireRole('LANDLORD', 'ADMIN'),
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()) {
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))) {
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();
parsedContractEnd = contractEndDate ? new Date(contractEndDate) : null;
@ -98,6 +101,7 @@ invitationsRouter.post(
roomId: roomId || null,
contractStartDate: parsedContractStart,
contractEndDate: parsedContractEnd,
paymentDueDay: roomId && paymentDueDay ? Number(paymentDueDay) : null,
invitedById: req.user!.id,
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
invitationsRouter.get('/invitations/:token/preview', async (req: Request, res: Response) => {
const invitation = await prisma.invitation.findUnique({
@ -274,6 +296,7 @@ invitationsRouter.post('/invitations/:token/accept', async (req: Request, res: R
roomId: room.id,
startDate: invitation.contractStartDate || new Date(),
endDate: invitation.contractEndDate,
paymentDueDay: invitation.paymentDueDay || 1,
totalWarmRent: Number(room.baseRent) + Number(room.utilityPauschal),
depositAmount: Number(room.baseRent) * 3,
isActive: true,

View File

@ -119,7 +119,7 @@ export function generateContractPdf(contract: ContractForPdf): Promise<Buffer> {
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 ` +
`Sie ist im Voraus, spätestens am ${contract.paymentDueDay}. eines jeden Monats, auf das ` +
'vom Vermieter benannte Konto zu entrichten.',
);
doc.text(UTILITY_LABEL[contract.utilityBillingModel]);

View File

@ -249,6 +249,7 @@ model Invitation {
acceptedAt DateTime? @map("accepted_at")
contractStartDate DateTime? @map("contract_start_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")
@ -273,7 +274,7 @@ model Contract {
totalWarmRent Decimal @map("total_warm_rent") @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
noticePeriodMonths Int @default(3) @map("notice_period_months")

View File

@ -681,6 +681,13 @@
<input type="checkbox" id="inviteUnbefristet" checked /> unbefristet
</label>
</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>
</form>
<div id="inviteLinkResult" style="display:none" class="invite-link-box">
@ -1553,11 +1560,14 @@
const startInput = document.getElementById('inviteStartDate');
const endInput = document.getElementById('inviteEndDate');
const unbefristetCheckbox = document.getElementById('inviteUnbefristet');
const dueDayField = document.getElementById('inviteDueDayField');
const dueDaySelect = document.getElementById('inviteDueDay');
roomSelect.onchange = () => {
const hasRoom = !!roomSelect.value;
startField.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);
};
unbefristetCheckbox.onchange = () => {
@ -1581,9 +1591,10 @@
const roomId = roomSelect.value || undefined;
const contractStartDate = roomId ? (startInput.value || undefined) : undefined;
const contractEndDate = roomId && !unbefristetCheckbox.checked ? (endInput.value || undefined) : undefined;
const paymentDueDay = roomId ? Number(dueDaySelect.value) : undefined;
const data = await apiFetch('/invitations', {
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('inviteLinkResult').style.display = 'flex';
@ -1591,6 +1602,7 @@
document.getElementById('inviteEmail').value = '';
startField.style.display = 'none';
endField.style.display = 'none';
dueDayField.style.display = 'none';
startInput.value = '';
endInput.value = '';
unbefristetCheckbox.checked = true;
@ -1639,6 +1651,13 @@
revokeBtn.onclick = () => revokeInvitation(inv.id);
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);
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
// ---------------------------------------------------------------------
@ -1739,6 +1768,13 @@
amountRow.appendChild(el('span', 'value', formatEuro(light.amountDue)));
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') {
const paidRow = el('div', 'amount-row');
paidRow.appendChild(el('span', 'label', 'Bezahlt'));
@ -3755,6 +3791,19 @@
depositField.appendChild(depositInput);
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 = {};
// Datumsbewusste Zimmerauswahl: bei jeder Änderung von Mietbeginn/-ende
@ -3816,6 +3865,7 @@
endDate: unbefristetCheckbox.checked ? undefined : (endInput.value || undefined),
totalWarmRent: rentInput.value,
depositAmount: depositInput.value,
paymentDueDay: Number(dueDaySelect.value),
}, submitBtn);
formArea.appendChild(submitBtn);