add backend/src/modules/banking/paymentService.ts

This commit is contained in:
Giuseppe Lombardo 2026-08-12 16:35:01 +00:00
parent a7a41b16e7
commit 1f6f9d5842

View File

@ -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<RentTrafficLight[]> {
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;
}