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

This commit is contained in:
Giuseppe Lombardo 2026-08-12 16:31:07 +00:00
parent 29dbc8ba8f
commit a7a41b16e7

View File

@ -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<MatchResult> {
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, '');
}