diff --git a/backend/src/modules/banking/matching.ts b/backend/src/modules/banking/matching.ts new file mode 100644 index 0000000..033ccf7 --- /dev/null +++ b/backend/src/modules/banking/matching.ts @@ -0,0 +1,80 @@ +import { Prisma, PrismaClient, Payment, PaymentStatus } from '@prisma/client'; +import { IncomingBankWebhookPayload, MatchResult } from './types'; + +// Typ des Payment-Datensatzes inkl. der für das Matching benötigten Relationen. +type PaymentWithContract = Prisma.PaymentGetPayload<{ + include: { contract: { include: { room: true; user: true } } }; +}>; + +/** + * Sucht anhand von Betrag + Verwendungszweck die passende offene Zahlung. + * + * Strategie (Konfidenz absteigend): + * 1) HIGH: exakter Betrag + Referenz enthält Zimmernummer oder Nachnamen des Mieters + * 2) MEDIUM: exakter Betrag, aber nur GENAU EINE offene Zahlung mit diesem Betrag + * 3) UNCERTAIN: mehrere Kandidaten oder kein Betrag-Match -> manuelle Prüfung + */ +export async function findMatchingPayment( + prisma: PrismaClient, + tx: IncomingBankWebhookPayload, +): Promise { + const openPayments = await prisma.payment.findMany({ + where: { + status: { in: [PaymentStatus.PENDING, PaymentStatus.OVERDUE, PaymentStatus.PARTIAL] }, + }, + include: { + contract: { + include: { room: true, user: true }, + }, + }, + }); + + const amountMatches = openPayments.filter((p: PaymentWithContract) => amountsEqual(p.amount, tx.amount)); + + if (amountMatches.length === 0) { + return { + confidence: 'UNCERTAIN', + reason: `Kein offener Betrag entspricht ${tx.amount.toFixed(2)} EUR.`, + }; + } + + const referenceNormalized = normalize(tx.remittanceInfo); + + const highConfidenceMatch = amountMatches.find((p: PaymentWithContract) => { + const roomNumberDigits = p.contract.room.roomNumber.replace(/\D/g, ''); // "Zimmer 1" -> "1" + const lastName = normalize(p.contract.user.fullName.split(' ').slice(-1)[0] ?? ''); + return ( + (roomNumberDigits && referenceNormalized.includes(`zimmer${roomNumberDigits}`)) || + (lastName.length > 2 && referenceNormalized.includes(lastName)) + ); + }); + + if (highConfidenceMatch) { + return { + confidence: 'HIGH', + paymentId: highConfidenceMatch.id, + reason: 'Betrag und Referenz (Zimmer/Name) stimmen überein.', + }; + } + + if (amountMatches.length === 1) { + return { + confidence: 'MEDIUM', + paymentId: amountMatches[0].id, + reason: 'Betrag eindeutig, aber Referenz ohne klaren Zimmer-/Namensbezug.', + }; + } + + return { + confidence: 'UNCERTAIN', + reason: `${amountMatches.length} offene Zahlungen mit identischem Betrag – keine eindeutige Zuordnung möglich.`, + }; +} + +function amountsEqual(a: Payment['amount'], b: number): boolean { + return Math.abs(Number(a) - b) < 0.01; +} + +function normalize(s: string): string { + return s.toLowerCase().replace(/[^a-z0-9äöüß]/g, ''); +}