diff --git a/backend/src/modules/banking/paymentService.ts b/backend/src/modules/banking/paymentService.ts new file mode 100644 index 0000000..17c3f4f --- /dev/null +++ b/backend/src/modules/banking/paymentService.ts @@ -0,0 +1,182 @@ +import { Prisma, PrismaClient, PaymentStatus } from '@prisma/client'; +import { IncomingBankWebhookPayload, RentTrafficLight } from './types'; + +type ContractWithPayments = Prisma.ContractGetPayload<{ + include: { room: true; user: true; payments: true }; +}>; +type LandlordUser = Prisma.UserGetPayload<{}>; +import { findMatchingPayment } from './matching'; +import { sendPushNotification } from '../../services/notificationService'; + +export class PaymentService { + constructor(private prisma: PrismaClient) {} + + /** + * Verarbeitet eine eingehende Banktransaktion: Matching, Statuswechsel, + * Speicherung der bank_transaction_id (idempotent bei erneutem Webhook-Delivery). + */ + async processIncomingTransaction(tx: IncomingBankWebhookPayload) { + const alreadyProcessed = await this.prisma.payment.findUnique({ + where: { bankTransactionId: tx.transactionId }, + }); + if (alreadyProcessed) { + return { status: 'already_processed' as const, paymentId: alreadyProcessed.id }; + } + + const match = await findMatchingPayment(this.prisma, tx); + + if (match.confidence === 'UNCERTAIN' || !match.paymentId) { + // Keine automatische Zuordnung: Transaktion wird als "unzugeordnet" geloggt, + // damit der Vermieter sie im Cockpit manuell matchen kann (siehe Modell + // `UnmatchedTransactionLog` in prisma/schema.prisma). + await this.prisma.unmatchedTransactionLog.create({ + data: { + bankTransactionId: tx.transactionId, + amount: tx.amount, + remittanceInfo: tx.remittanceInfo, + reason: match.reason, + }, + }); + + await this.notifyLandlord( + 'RENT_MATCH_UNCERTAIN', + 'Zahlung erfordert manuelle Prüfung', + `Eingang über ${tx.amount.toFixed(2)} € konnte nicht automatisch zugeordnet werden: ${match.reason}`, + ); + + return { status: 'uncertain' as const, reason: match.reason }; + } + + const payment = await this.prisma.payment.update({ + where: { id: match.paymentId }, + data: { + status: PaymentStatus.PAID, + paidAt: new Date(tx.bookingDate), + bankTransactionId: tx.transactionId, + bankReferenceText: tx.remittanceInfo, + matchedAutomatically: match.confidence !== 'MEDIUM' ? true : true, + }, + include: { contract: { include: { user: true, room: true } } }, + }); + + if (match.confidence === 'MEDIUM') { + // Trotzdem automatisch gebucht, aber Vermieter zur Kontrolle informiert. + await this.notifyLandlord( + 'RENT_MATCHED_MEDIUM_CONFIDENCE', + 'Miete gebucht (bitte kurz prüfen)', + `${payment.contract.user.fullName} / ${payment.contract.room.roomNumber}: ${tx.amount.toFixed(2)} € automatisch zugeordnet (mittlere Konfidenz).`, + ); + } + + return { status: 'matched' as const, paymentId: payment.id, confidence: match.confidence }; + } + + /** + * 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. + */ + async getRentTrafficLights(referenceDate: Date = new Date()): Promise { + const activeContracts = await this.prisma.contract.findMany({ + where: { isActive: true }, + include: { + room: true, + user: true, + payments: { + where: { + dueDate: { + gte: startOfMonth(referenceDate), + lte: endOfMonth(referenceDate), + }, + }, + }, + }, + }); + + return activeContracts.map((c: ContractWithPayments) => { + const currentPayment = c.payments[0]; + const dueThreshold = thirdBusinessDay(referenceDate); + const paid = currentPayment?.status === PaymentStatus.PAID; + + let status: RentTrafficLight['status'] = 'YELLOW'; + if (paid) status = 'GREEN'; + else if (referenceDate > dueThreshold) status = 'RED'; + + return { + contractId: c.id, + roomNumber: c.room.roomNumber, + tenantName: c.user.fullName, + status, + dueDate: currentPayment?.dueDate.toISOString() ?? '', + amountDue: Number(currentPayment?.amount ?? c.totalWarmRent), + amountPaid: paid ? Number(currentPayment.amount) : 0, + }; + }); + } + + /** + * Täglicher Cron-Job: findet alle ROT-Fälle und verschickt (max. 1x) eine + * Mahn-Notification, sofern noch keine für diesen Zahlungslauf gesendet wurde. + */ + async runDailyDunningCheck(referenceDate: Date = new Date()) { + const overdue = await this.prisma.payment.findMany({ + where: { + status: { in: [PaymentStatus.PENDING, PaymentStatus.OVERDUE] }, + dueDate: { lt: thirdBusinessDay(referenceDate) }, + dunningSentAt: null, + }, + include: { contract: { include: { user: true, room: true } } }, + }); + + for (const payment of overdue) { + await this.prisma.payment.update({ + where: { id: payment.id }, + data: { status: PaymentStatus.OVERDUE, dunningSentAt: new Date() }, + }); + + await sendPushNotification({ + userId: payment.contract.userId, + type: 'RENT_OVERDUE', + title: 'Miete überfällig', + body: `Deine Miete für ${payment.contract.room.roomNumber} (${Number(payment.amount).toFixed(2)} €) ist überfällig. Bitte zeitnah überweisen.`, + }); + + await this.notifyLandlord( + 'RENT_OVERDUE_LANDLORD', + `Miete überfällig: ${payment.contract.room.roomNumber}`, + `${payment.contract.user.fullName} hat die Miete (${Number(payment.amount).toFixed(2)} €) noch nicht bezahlt.`, + ); + } + + return { dunningsSent: overdue.length }; + } + + private async notifyLandlord(type: string, title: string, body: string) { + const landlords = await this.prisma.user.findMany({ where: { role: 'LANDLORD' } }); + await Promise.all( + landlords.map((l: LandlordUser) => sendPushNotification({ userId: l.id, type, title, body })), + ); + } +} + +// --- Datumshilfen ----------------------------------------------------------- + +function startOfMonth(d: Date): Date { + return new Date(d.getFullYear(), d.getMonth(), 1); +} + +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; +}