add backend/src/modules/banking/matching.ts
This commit is contained in:
parent
29dbc8ba8f
commit
a7a41b16e7
80
backend/src/modules/banking/matching.ts
Normal file
80
backend/src/modules/banking/matching.ts
Normal 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, '');
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user