Fälligkeit der Miete bei Vertragserstellung (1./15.) + endgültiges Löschen zurückgezogener Einladungen
This commit is contained in:
parent
3111f64ecd
commit
89d2a911e1
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user