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