add backend/src/modules/banking/paymentService.ts
This commit is contained in:
parent
a7a41b16e7
commit
1f6f9d5842
182
backend/src/modules/banking/paymentService.ts
Normal file
182
backend/src/modules/banking/paymentService.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user