feat(sprint10): Phase 2 — Payment matching engine with confidence scoring
This commit is contained in:
@@ -0,0 +1,36 @@
|
|||||||
|
package de.cannamanage.service.bankimport;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.MatchStatus;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sprint 10 — Per-transaction outcome of {@link PaymentMatchingService}.
|
||||||
|
* <p>
|
||||||
|
* Exposes the chosen member, the aggregated confidence (0–100), the resulting
|
||||||
|
* {@link MatchStatus} classification and a per-criterion {@code scoreBreakdown}
|
||||||
|
* for diagnostics / UI hover-tooltips. Returned by
|
||||||
|
* {@link PaymentMatchingService#scoreAll(java.util.List, java.util.List)} for unit
|
||||||
|
* tests; the public {@code matchTransactions} API folds these results back into
|
||||||
|
* {@code BankTransaction} entities for the orchestrator.
|
||||||
|
*
|
||||||
|
* @param matchedMemberId chosen member, {@code null} when {@code classification == UNMATCHED}
|
||||||
|
* @param matchedMemberName chosen member's full name for log/UI display, {@code null} when unmatched
|
||||||
|
* @param confidence 0–100 aggregated weighted score
|
||||||
|
* @param classification {@link MatchStatus#MATCHED} (≥90), {@link MatchStatus#SUGGESTED} (60–89)
|
||||||
|
* or {@link MatchStatus#UNMATCHED} (<60)
|
||||||
|
* @param scoreBreakdown criterion → individual 0–100 score; useful for explaining a match in the UI
|
||||||
|
*/
|
||||||
|
public record MatchResult(
|
||||||
|
UUID matchedMemberId,
|
||||||
|
String matchedMemberName,
|
||||||
|
int confidence,
|
||||||
|
MatchStatus classification,
|
||||||
|
Map<String, Integer> scoreBreakdown
|
||||||
|
) {
|
||||||
|
|
||||||
|
public static MatchResult unmatched() {
|
||||||
|
return new MatchResult(null, null, 0, MatchStatus.UNMATCHED, Map.of());
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
package de.cannamanage.service.bankimport;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sprint 10 — Pre-computed matching context for one member, prepared once per
|
||||||
|
* import run and re-used across every transaction in
|
||||||
|
* {@link PaymentMatchingService#matchTransactions}.
|
||||||
|
* <p>
|
||||||
|
* Holding the {@code expectedAmountCents} on the context avoids re-querying
|
||||||
|
* the fee schedule for every (transaction × member) pair and unlocks the
|
||||||
|
* early-exit optimisation when the bank amount deviates by more than 20%
|
||||||
|
* from the expected fee.
|
||||||
|
*
|
||||||
|
* @param memberId member primary key
|
||||||
|
* @param memberNumber the {@code Member.membershipNumber} (e.g. {@code "M-0042"})
|
||||||
|
* @param fullName "{firstName} {lastName}" — already pre-joined for scoring
|
||||||
|
* @param iban member IBAN if BANK_DATA consent was granted, otherwise {@code null}
|
||||||
|
* @param expectedAmountCents fee schedule amount valid at the import's booking-date context,
|
||||||
|
* or {@code -1} if the member has no active fee assignment for that period
|
||||||
|
*/
|
||||||
|
record MemberMatchContext(
|
||||||
|
UUID memberId,
|
||||||
|
String memberNumber,
|
||||||
|
String fullName,
|
||||||
|
String iban,
|
||||||
|
int expectedAmountCents
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** Sentinel value indicating the member has no fee assignment for the matching period. */
|
||||||
|
static final int NO_EXPECTED_AMOUNT = -1;
|
||||||
|
|
||||||
|
boolean hasExpectedAmount() {
|
||||||
|
return expectedAmountCents > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
+506
@@ -0,0 +1,506 @@
|
|||||||
|
package de.cannamanage.service.bankimport;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.BankTransaction;
|
||||||
|
import de.cannamanage.domain.entity.FeeSchedule;
|
||||||
|
import de.cannamanage.domain.entity.Member;
|
||||||
|
import de.cannamanage.domain.entity.MemberFeeAssignment;
|
||||||
|
import de.cannamanage.domain.enums.FeeInterval;
|
||||||
|
import de.cannamanage.domain.enums.MatchStatus;
|
||||||
|
import de.cannamanage.domain.enums.MemberStatus;
|
||||||
|
import de.cannamanage.service.repository.FeeScheduleRepository;
|
||||||
|
import de.cannamanage.service.repository.MemberFeeAssignmentRepository;
|
||||||
|
import de.cannamanage.service.repository.MemberRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sprint 10 — Deterministic, in-memory matching engine that pairs parsed
|
||||||
|
* bank-statement transactions to club members.
|
||||||
|
* <p>
|
||||||
|
* The algorithm is a weighted-confidence model with four criteria:
|
||||||
|
* <table>
|
||||||
|
* <caption>Scoring weights (sum = 1.00)</caption>
|
||||||
|
* <tr><th>Criterion</th><th>Weight</th><th>Source</th></tr>
|
||||||
|
* <tr><td>Amount equals expected fee (±20%)</td><td>0.35</td><td>{@link FeeSchedule}</td></tr>
|
||||||
|
* <tr><td>Member number found in {@code Verwendungszweck}</td><td>0.30</td><td>{@link Member#getMembershipNumber()}</td></tr>
|
||||||
|
* <tr><td>Counterparty name matches member name</td><td>0.20</td><td>{@link Member#getFirstName()} / {@link Member#getLastName()}</td></tr>
|
||||||
|
* <tr><td>IBAN exact match (after BANK_DATA consent)</td><td>0.15</td><td>{@link Member#getIban()}</td></tr>
|
||||||
|
* </table>
|
||||||
|
* Classification thresholds: <b>≥ 90 → MATCHED</b> (admin pre-selected),
|
||||||
|
* <b>60–89 → SUGGESTED</b> (admin review), <b>< 60 → UNMATCHED</b>.
|
||||||
|
* <p>
|
||||||
|
* <b>Double-payment safety:</b> if the same member is the best match for
|
||||||
|
* two or more transactions in one import, all of them are downgraded to
|
||||||
|
* {@link MatchStatus#SUGGESTED} (even at confidence ≥ 90) so the admin
|
||||||
|
* must decide which is the real payment and which is a duplicate or a
|
||||||
|
* payment for a different period.
|
||||||
|
* <p>
|
||||||
|
* <b>Performance:</b> fee amounts are pre-computed once per matching run,
|
||||||
|
* keyed by member id, against the import's <em>booking-date context</em>
|
||||||
|
* (the most frequent booking date in the batch — so a January import of
|
||||||
|
* December transactions matches December's fee schedule, not today's).
|
||||||
|
* Per-transaction we early-exit when the amount deviates by more than 20%
|
||||||
|
* AND no membership number appears in the reference text, avoiding the
|
||||||
|
* expensive name/IBAN comparisons for the vast majority of pairs.
|
||||||
|
* <p>
|
||||||
|
* The service is <b>stateless and thread-safe</b>; all state lives in
|
||||||
|
* the parameters of {@link #matchTransactions(List, UUID, UUID)}.
|
||||||
|
* It performs <b>no persistence</b> — the returned {@link BankTransaction}
|
||||||
|
* entities are detached and must be persisted by the calling orchestrator
|
||||||
|
* (see {@code BankImportService}, Phase 3).
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class PaymentMatchingService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(PaymentMatchingService.class);
|
||||||
|
|
||||||
|
// --- Weights (sum = 1.00) — Plan v3 §3.1 ---
|
||||||
|
static final double W_AMOUNT = 0.35;
|
||||||
|
static final double W_MEMBER_NUMBER = 0.30;
|
||||||
|
static final double W_NAME = 0.20;
|
||||||
|
static final double W_IBAN = 0.15;
|
||||||
|
|
||||||
|
// --- Classification thresholds (aggregate score 0–100) ---
|
||||||
|
static final int THRESHOLD_AUTO = 90;
|
||||||
|
static final int THRESHOLD_SUGGEST = 60;
|
||||||
|
|
||||||
|
/** Amount deviation tolerance for "near match" (50% scoring). */
|
||||||
|
private static final double AMOUNT_DEVIATION_THRESHOLD = 0.20;
|
||||||
|
|
||||||
|
/** Minimum digit count of a member number that may match as a numeric substring. */
|
||||||
|
private static final int MIN_NUMERIC_MATCH_LENGTH = 3;
|
||||||
|
|
||||||
|
/** Threshold (transactions) above which timing diagnostics are logged at INFO. */
|
||||||
|
private static final int PERF_LOG_THRESHOLD = 500;
|
||||||
|
|
||||||
|
private final MemberRepository memberRepository;
|
||||||
|
private final MemberFeeAssignmentRepository feeAssignmentRepository;
|
||||||
|
private final FeeScheduleRepository feeScheduleRepository;
|
||||||
|
|
||||||
|
public PaymentMatchingService(MemberRepository memberRepository,
|
||||||
|
MemberFeeAssignmentRepository feeAssignmentRepository,
|
||||||
|
FeeScheduleRepository feeScheduleRepository) {
|
||||||
|
this.memberRepository = memberRepository;
|
||||||
|
this.feeAssignmentRepository = feeAssignmentRepository;
|
||||||
|
this.feeScheduleRepository = feeScheduleRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches a batch of parsed transactions against the club's active members.
|
||||||
|
* <p>
|
||||||
|
* Outgoing transactions (negative {@code amountCents}) are returned with
|
||||||
|
* {@link MatchStatus#UNMATCHED} — they represent expenses and are handled
|
||||||
|
* by a separate categorisation flow.
|
||||||
|
*
|
||||||
|
* @param parsedTransactions parser output (positive = incoming, negative = outgoing)
|
||||||
|
* @param clubId tenant scope; only active members of this club are considered
|
||||||
|
* @param sessionId the {@link de.cannamanage.domain.entity.BankImportSession}
|
||||||
|
* the returned transactions will belong to
|
||||||
|
* @return detached {@link BankTransaction} entities with
|
||||||
|
* {@code matchStatus}, {@code matchConfidence} and
|
||||||
|
* {@code matchedMemberId} populated where applicable —
|
||||||
|
* <em>not yet persisted</em>
|
||||||
|
*/
|
||||||
|
public List<BankTransaction> matchTransactions(List<ParsedTransaction> parsedTransactions,
|
||||||
|
UUID clubId,
|
||||||
|
UUID sessionId) {
|
||||||
|
Objects.requireNonNull(parsedTransactions, "parsedTransactions");
|
||||||
|
Objects.requireNonNull(clubId, "clubId");
|
||||||
|
Objects.requireNonNull(sessionId, "sessionId");
|
||||||
|
|
||||||
|
long startNanos = System.nanoTime();
|
||||||
|
|
||||||
|
// 1. Load all ACTIVE members for the club (single query)
|
||||||
|
List<Member> activeMembers = memberRepository.findByClubIdAndStatus(clubId, MemberStatus.ACTIVE);
|
||||||
|
|
||||||
|
// 2. Determine the booking-date context for fee lookup (most frequent date in the batch)
|
||||||
|
LocalDate bookingDateContext = pickBookingDateContext(parsedTransactions);
|
||||||
|
|
||||||
|
// 3. Pre-compute fee amounts once
|
||||||
|
Map<UUID, Integer> expectedAmounts = precomputeFeeAmounts(activeMembers, clubId, bookingDateContext);
|
||||||
|
|
||||||
|
// 4. Build per-member matching contexts (stable order for deterministic tie-break)
|
||||||
|
List<MemberMatchContext> contexts = buildContexts(activeMembers, expectedAmounts);
|
||||||
|
|
||||||
|
// 5. Score every transaction
|
||||||
|
List<BankTransaction> result = new ArrayList<>(parsedTransactions.size());
|
||||||
|
Map<UUID, List<Integer>> memberHits = new HashMap<>(); // memberId → indexes into result that matched it
|
||||||
|
|
||||||
|
for (ParsedTransaction parsed : parsedTransactions) {
|
||||||
|
BankTransaction tx = toEntity(parsed, sessionId, clubId);
|
||||||
|
|
||||||
|
if (parsed.amountCents() <= 0) {
|
||||||
|
// Outgoing or zero — never a member payment
|
||||||
|
tx.setMatchStatus(MatchStatus.UNMATCHED);
|
||||||
|
result.add(tx);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
MatchResult mr = scoreOne(parsed, contexts);
|
||||||
|
applyMatchResult(tx, mr);
|
||||||
|
int idx = result.size();
|
||||||
|
result.add(tx);
|
||||||
|
|
||||||
|
if (mr.matchedMemberId() != null
|
||||||
|
&& (mr.classification() == MatchStatus.MATCHED
|
||||||
|
|| mr.classification() == MatchStatus.SUGGESTED)) {
|
||||||
|
memberHits.computeIfAbsent(mr.matchedMemberId(), k -> new ArrayList<>()).add(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Double-payment safety: downgrade ALL hits for any member that matched 2+ transactions
|
||||||
|
int downgrades = 0;
|
||||||
|
for (Map.Entry<UUID, List<Integer>> e : memberHits.entrySet()) {
|
||||||
|
if (e.getValue().size() < 2) continue;
|
||||||
|
for (int idx : e.getValue()) {
|
||||||
|
BankTransaction tx = result.get(idx);
|
||||||
|
if (tx.getMatchStatus() == MatchStatus.MATCHED) {
|
||||||
|
tx.setMatchStatus(MatchStatus.SUGGESTED);
|
||||||
|
downgrades++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
long durationMs = (System.nanoTime() - startNanos) / 1_000_000L;
|
||||||
|
if (parsedTransactions.size() >= PERF_LOG_THRESHOLD) {
|
||||||
|
log.info("Matching {} txns × {} members in {} ms ({} double-payment downgrades)",
|
||||||
|
parsedTransactions.size(), contexts.size(), durationMs, downgrades);
|
||||||
|
} else if (log.isDebugEnabled()) {
|
||||||
|
log.debug("Matching {} txns × {} members in {} ms ({} double-payment downgrades)",
|
||||||
|
parsedTransactions.size(), contexts.size(), durationMs, downgrades);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Internal — context building
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
private List<MemberMatchContext> buildContexts(List<Member> members, Map<UUID, Integer> expectedAmounts) {
|
||||||
|
List<MemberMatchContext> ctx = new ArrayList<>(members.size());
|
||||||
|
for (Member m : members) {
|
||||||
|
String fullName = ((nullToEmpty(m.getFirstName()) + " " + nullToEmpty(m.getLastName())).trim());
|
||||||
|
int expected = expectedAmounts.getOrDefault(m.getId(), MemberMatchContext.NO_EXPECTED_AMOUNT);
|
||||||
|
ctx.add(new MemberMatchContext(
|
||||||
|
m.getId(),
|
||||||
|
m.getMembershipNumber(),
|
||||||
|
fullName,
|
||||||
|
m.getIban(),
|
||||||
|
expected
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Deterministic tie-break: stable order by member id
|
||||||
|
ctx.sort(Comparator.comparing(MemberMatchContext::memberId));
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks the most frequent {@code bookingDate} in the batch as the
|
||||||
|
* temporal context for fee-assignment lookup. Ties resolve to the
|
||||||
|
* <em>earliest</em> date (favouring the older period in mixed batches).
|
||||||
|
* Empty batches default to {@link LocalDate#now()}.
|
||||||
|
*/
|
||||||
|
static LocalDate pickBookingDateContext(List<ParsedTransaction> txns) {
|
||||||
|
if (txns == null || txns.isEmpty()) return LocalDate.now();
|
||||||
|
Map<LocalDate, Integer> freq = new HashMap<>();
|
||||||
|
for (ParsedTransaction t : txns) {
|
||||||
|
if (t.bookingDate() != null) {
|
||||||
|
freq.merge(t.bookingDate(), 1, Integer::sum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (freq.isEmpty()) return LocalDate.now();
|
||||||
|
return freq.entrySet().stream()
|
||||||
|
.sorted((a, b) -> {
|
||||||
|
int cmp = Integer.compare(b.getValue(), a.getValue());
|
||||||
|
return cmp != 0 ? cmp : a.getKey().compareTo(b.getKey());
|
||||||
|
})
|
||||||
|
.findFirst()
|
||||||
|
.map(Map.Entry::getKey)
|
||||||
|
.orElse(LocalDate.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-computes the expected monthly-equivalent fee in cents per member,
|
||||||
|
* using the assignment valid at {@code bookingDateContext} (not today).
|
||||||
|
* Members without an assignment for that period are absent from the map.
|
||||||
|
* <p>
|
||||||
|
* Fee schedules with intervals other than monthly are normalised to a
|
||||||
|
* <em>per-billing-period</em> amount as recorded on the {@link FeeSchedule}
|
||||||
|
* itself — the bank transaction is expected to match the schedule's
|
||||||
|
* raw {@code amountCents}, not a derived monthly figure.
|
||||||
|
*/
|
||||||
|
Map<UUID, Integer> precomputeFeeAmounts(List<Member> members, UUID clubId, LocalDate bookingDateContext) {
|
||||||
|
// Bulk-load fee assignments for the club, then index by member
|
||||||
|
List<MemberFeeAssignment> allAssignments = feeAssignmentRepository.findByClubId(clubId);
|
||||||
|
Map<UUID, List<MemberFeeAssignment>> byMember = new HashMap<>();
|
||||||
|
for (MemberFeeAssignment a : allAssignments) {
|
||||||
|
byMember.computeIfAbsent(a.getMemberId(), k -> new ArrayList<>()).add(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk-load fee schedules for the club
|
||||||
|
Map<UUID, FeeSchedule> schedulesById = new HashMap<>();
|
||||||
|
for (FeeSchedule s : feeScheduleRepository.findByClubId(clubId)) {
|
||||||
|
schedulesById.put(s.getId(), s);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<UUID, Integer> expected = new HashMap<>(members.size());
|
||||||
|
for (Member m : members) {
|
||||||
|
List<MemberFeeAssignment> assignments = byMember.get(m.getId());
|
||||||
|
if (assignments == null) continue;
|
||||||
|
Optional<MemberFeeAssignment> active = assignments.stream()
|
||||||
|
.filter(a -> isValidAt(a, bookingDateContext))
|
||||||
|
.findFirst();
|
||||||
|
if (active.isEmpty()) continue;
|
||||||
|
FeeSchedule fs = schedulesById.get(active.get().getFeeScheduleId());
|
||||||
|
if (fs == null || fs.getAmountCents() == null) continue;
|
||||||
|
expected.put(m.getId(), fs.getAmountCents());
|
||||||
|
}
|
||||||
|
return expected;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isValidAt(MemberFeeAssignment a, LocalDate date) {
|
||||||
|
if (a.getValidFrom() != null && a.getValidFrom().isAfter(date)) return false;
|
||||||
|
if (a.getValidTo() != null && !a.getValidTo().isAfter(date)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Internal — scoring (one transaction)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scores a parsed transaction against every member context and returns
|
||||||
|
* the best candidate. Package-private for testing.
|
||||||
|
*/
|
||||||
|
MatchResult scoreOne(ParsedTransaction txn, List<MemberMatchContext> contexts) {
|
||||||
|
if (txn.amountCents() <= 0) return MatchResult.unmatched();
|
||||||
|
|
||||||
|
String normalizedReference = normalize(txn.referenceText());
|
||||||
|
String normalizedCounterparty = normalize(txn.counterpartyName());
|
||||||
|
String normalizedTxnIban = normalizeIban(txn.counterpartyIban());
|
||||||
|
|
||||||
|
MemberMatchContext best = null;
|
||||||
|
int bestScore = -1;
|
||||||
|
Map<String, Integer> bestBreakdown = Map.of();
|
||||||
|
|
||||||
|
for (MemberMatchContext ctx : contexts) {
|
||||||
|
// Early-exit: if amount deviation > 20% AND no membership number found in reference, skip.
|
||||||
|
boolean amountPlausible = isAmountPlausible(txn.amountCents(), ctx.expectedAmountCents());
|
||||||
|
boolean memberNumberHit = ctx.memberNumber() != null
|
||||||
|
&& containsMemberNumber(normalizedReference, ctx.memberNumber());
|
||||||
|
if (!amountPlausible && !memberNumberHit) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int amountScore = scoreAmount(txn.amountCents(), ctx.expectedAmountCents());
|
||||||
|
int memberNumberScore = scoreMemberNumber(normalizedReference, ctx.memberNumber());
|
||||||
|
int nameScore = scoreName(normalizedReference, normalizedCounterparty, normalize(ctx.fullName()));
|
||||||
|
int ibanScore = scoreIban(normalizedTxnIban, normalizeIban(ctx.iban()));
|
||||||
|
|
||||||
|
double weighted = W_AMOUNT * amountScore
|
||||||
|
+ W_MEMBER_NUMBER * memberNumberScore
|
||||||
|
+ W_NAME * nameScore
|
||||||
|
+ W_IBAN * ibanScore;
|
||||||
|
int total = (int) Math.round(weighted);
|
||||||
|
|
||||||
|
if (total > bestScore) {
|
||||||
|
bestScore = total;
|
||||||
|
best = ctx;
|
||||||
|
bestBreakdown = orderedBreakdown(amountScore, memberNumberScore, nameScore, ibanScore);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (best == null || bestScore < THRESHOLD_SUGGEST) {
|
||||||
|
return MatchResult.unmatched();
|
||||||
|
}
|
||||||
|
|
||||||
|
MatchStatus classification = bestScore >= THRESHOLD_AUTO
|
||||||
|
? MatchStatus.MATCHED
|
||||||
|
: MatchStatus.SUGGESTED;
|
||||||
|
|
||||||
|
return new MatchResult(best.memberId(), best.fullName(), bestScore, classification, bestBreakdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Integer> orderedBreakdown(int amount, int memberNo, int name, int iban) {
|
||||||
|
Map<String, Integer> m = new LinkedHashMap<>(4);
|
||||||
|
m.put("amount", amount);
|
||||||
|
m.put("memberNumber", memberNo);
|
||||||
|
m.put("name", name);
|
||||||
|
m.put("iban", iban);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isAmountPlausible(int txnCents, int expectedCents) {
|
||||||
|
if (expectedCents <= 0) return false;
|
||||||
|
double deviation = Math.abs(txnCents - expectedCents) / (double) expectedCents;
|
||||||
|
return deviation <= AMOUNT_DEVIATION_THRESHOLD;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns 0–100. */
|
||||||
|
static int scoreAmount(int txnCents, int expectedCents) {
|
||||||
|
if (expectedCents <= 0) return 0;
|
||||||
|
if (txnCents == expectedCents) return 100;
|
||||||
|
double deviation = Math.abs(txnCents - expectedCents) / (double) expectedCents;
|
||||||
|
if (deviation <= AMOUNT_DEVIATION_THRESHOLD) return 50;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns 0–100. Caller passes the <em>normalized</em> reference text
|
||||||
|
* (see {@link #normalize}); membership number is upper-cased internally.
|
||||||
|
*/
|
||||||
|
static int scoreMemberNumber(String normalizedReference, String memberNumber) {
|
||||||
|
if (normalizedReference == null || normalizedReference.isEmpty()
|
||||||
|
|| memberNumber == null || memberNumber.isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
String mn = memberNumber.toUpperCase().trim();
|
||||||
|
// Exact "M-0042" / "M0042" style match
|
||||||
|
if (normalizedReference.toUpperCase().contains(mn)) return 100;
|
||||||
|
// Numeric-only fallback: avoid 1- or 2-digit false positives (would match years, amounts, etc.)
|
||||||
|
String numeric = mn.replaceAll("[^0-9]", "");
|
||||||
|
if (numeric.length() >= MIN_NUMERIC_MATCH_LENGTH
|
||||||
|
&& normalizedReference.contains(numeric)) {
|
||||||
|
return 80;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helper that mirrors the scoring rule for the early-exit check. */
|
||||||
|
private static boolean containsMemberNumber(String normalizedReference, String memberNumber) {
|
||||||
|
return scoreMemberNumber(normalizedReference, memberNumber) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns 0–100. Compares the parsed counterparty name (preferred) and
|
||||||
|
* the free-text reference (fallback) against the member's full name.
|
||||||
|
*/
|
||||||
|
static int scoreName(String normalizedReference, String normalizedCounterparty, String normalizedMemberName) {
|
||||||
|
if (normalizedMemberName == null || normalizedMemberName.isEmpty()) return 0;
|
||||||
|
|
||||||
|
String fromCounterparty = scoreNameAgainst(normalizedCounterparty, normalizedMemberName);
|
||||||
|
String fromReference = scoreNameAgainst(normalizedReference, normalizedMemberName);
|
||||||
|
|
||||||
|
int s1 = bucket(fromCounterparty);
|
||||||
|
int s2 = bucket(fromReference);
|
||||||
|
return Math.max(s1, s2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pure scoring against one haystack — returns the symbolic match strength. */
|
||||||
|
private static String scoreNameAgainst(String haystack, String needleFullName) {
|
||||||
|
if (haystack == null || haystack.isEmpty()) return "none";
|
||||||
|
if (haystack.equals(needleFullName)) return "exact";
|
||||||
|
if (haystack.contains(needleFullName) || needleFullName.contains(haystack)) return "contains";
|
||||||
|
// Last-name only
|
||||||
|
int sp = needleFullName.lastIndexOf(' ');
|
||||||
|
if (sp >= 0 && sp < needleFullName.length() - 1) {
|
||||||
|
String lastName = needleFullName.substring(sp + 1);
|
||||||
|
if (lastName.length() >= 3 && haystack.contains(lastName)) return "lastname";
|
||||||
|
}
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int bucket(String tag) {
|
||||||
|
return switch (tag) {
|
||||||
|
case "exact" -> 100;
|
||||||
|
case "contains" -> 80;
|
||||||
|
case "lastname" -> 50;
|
||||||
|
default -> 0;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns 0 or 100 — IBAN match is binary. */
|
||||||
|
static int scoreIban(String txnIbanNorm, String memberIbanNorm) {
|
||||||
|
if (txnIbanNorm == null || txnIbanNorm.isEmpty()
|
||||||
|
|| memberIbanNorm == null || memberIbanNorm.isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return txnIbanNorm.equalsIgnoreCase(memberIbanNorm) ? 100 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Internal — normalisation helpers
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalises German payment text for comparison:
|
||||||
|
* lower-case, replace umlauts (ä → ae, ö → oe, ü → ue, ß → ss),
|
||||||
|
* collapse whitespace. Returns the empty string for {@code null}/blank input.
|
||||||
|
*/
|
||||||
|
static String normalize(String s) {
|
||||||
|
if (s == null) return "";
|
||||||
|
String lower = s.toLowerCase();
|
||||||
|
StringBuilder sb = new StringBuilder(lower.length() + 4);
|
||||||
|
for (int i = 0; i < lower.length(); i++) {
|
||||||
|
char c = lower.charAt(i);
|
||||||
|
switch (c) {
|
||||||
|
case 'ä' -> sb.append("ae");
|
||||||
|
case 'ö' -> sb.append("oe");
|
||||||
|
case 'ü' -> sb.append("ue");
|
||||||
|
case 'ß' -> sb.append("ss");
|
||||||
|
default -> sb.append(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Collapse runs of whitespace
|
||||||
|
return sb.toString().replaceAll("\\s+", " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strips all whitespace and upper-cases — never returns {@code null}. */
|
||||||
|
static String normalizeIban(String iban) {
|
||||||
|
if (iban == null) return "";
|
||||||
|
return iban.replaceAll("\\s", "").toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String nullToEmpty(String s) {
|
||||||
|
return s == null ? "" : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Internal — ParsedTransaction → BankTransaction mapping
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static BankTransaction toEntity(ParsedTransaction p, UUID sessionId, UUID clubId) {
|
||||||
|
BankTransaction tx = new BankTransaction();
|
||||||
|
tx.setSessionId(sessionId);
|
||||||
|
tx.setClubId(clubId);
|
||||||
|
tx.setBookingDate(p.bookingDate());
|
||||||
|
tx.setValueDate(p.valueDate());
|
||||||
|
tx.setAmountCents(p.amountCents());
|
||||||
|
tx.setCurrency(p.currency() != null ? p.currency() : "EUR");
|
||||||
|
tx.setReferenceText(p.referenceText());
|
||||||
|
tx.setCounterpartyName(p.counterpartyName());
|
||||||
|
tx.setCounterpartyIban(p.counterpartyIban());
|
||||||
|
tx.setBankReference(p.bankReference());
|
||||||
|
tx.setMatchStatus(MatchStatus.UNMATCHED);
|
||||||
|
return tx;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void applyMatchResult(BankTransaction tx, MatchResult mr) {
|
||||||
|
tx.setMatchStatus(mr.classification());
|
||||||
|
if (mr.classification() != MatchStatus.UNMATCHED) {
|
||||||
|
tx.setMatchConfidence(mr.confidence());
|
||||||
|
tx.setMatchedMemberId(mr.matchedMemberId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suppress unused-import warning for FeeInterval (kept for future per-interval scoring)
|
||||||
|
@SuppressWarnings("unused")
|
||||||
|
private static FeeInterval unusedAnchor() { return null; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user