diff --git a/backend/src/modules/banking/paymentService.ts b/backend/src/modules/banking/paymentService.ts index 17c3f4f..ea80752 100644 --- a/backend/src/modules/banking/paymentService.ts +++ b/backend/src/modules/banking/paymentService.ts @@ -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 { 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; } diff --git a/backend/src/routes/contracts.ts b/backend/src/routes/contracts.ts index c774dee..88ac4d5 100644 --- a/backend/src/routes/contracts.ts +++ b/backend/src/routes/contracts.ts @@ -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, }, diff --git a/backend/src/routes/invitations.ts b/backend/src/routes/invitations.ts index 93fe942..53d8e22 100644 --- a/backend/src/routes/invitations.ts +++ b/backend/src/routes/invitations.ts @@ -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, diff --git a/backend/src/services/contractDocumentGenerator.ts b/backend/src/services/contractDocumentGenerator.ts index f98a0c5..c638fa5 100644 --- a/backend/src/services/contractDocumentGenerator.ts +++ b/backend/src/services/contractDocumentGenerator.ts @@ -119,7 +119,7 @@ export function generateContractPdf(contract: ContractForPdf): Promise { 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]); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8ee043e..e8b1417 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") diff --git a/web-dashboard/index.html b/web-dashboard/index.html index 3305100..d92e066 100644 --- a/web-dashboard/index.html +++ b/web-dashboard/index.html @@ -681,6 +681,13 @@ unbefristet +