using Microsoft.EntityFrameworkCore; using Purrse.Core.DTOs; using Purrse.Core.Helpers; using Purrse.Core.Interfaces.Services; using Purrse.Core.Models; using Purrse.Data; namespace Purrse.Api.Services; public class DuplicateDetectionService : IDuplicateDetectionService { private readonly PurrseDbContext _db; public DuplicateDetectionService(PurrseDbContext db) { _db = db; } public async Task FindDuplicateAsync(Guid accountId, DateTime date, decimal amount, string? payeeName, string? fitId, string? checkNumber) { var fingerprint = TransactionFingerprint.Generate(accountId, date, amount, fitId); // Check exact fingerprint match var fpMatch = await _db.Transactions.FirstOrDefaultAsync(t => t.AccountId == accountId && t.Fingerprint == fingerprint); if (fpMatch != null) return new DuplicateMatch(fpMatch.Id, 1.0, "Exact fingerprint match"); // Check FITID match if (!string.IsNullOrEmpty(fitId)) { var fitIdMatch = await _db.Transactions.FirstOrDefaultAsync(t => t.AccountId == accountId && t.FitId == fitId); if (fitIdMatch != null) return new DuplicateMatch(fitIdMatch.Id, 0.95, "Matching FITID/Reference"); } // Multi-factor scoring on nearby transactions var candidates = await _db.Transactions .Where(t => t.AccountId == accountId && t.Date >= date.AddDays(-3) && t.Date <= date.AddDays(3) && !t.IsVoid) .ToListAsync(); DuplicateMatch? bestMatch = null; foreach (var existing in candidates) { var confidence = CalculateConfidence(existing, date, amount, payeeName, fitId, checkNumber); if (confidence >= 0.50 && (bestMatch == null || confidence > bestMatch.Confidence)) { var reasons = new List(); if (existing.Amount == amount) reasons.Add("amount"); if (existing.Date.Date == date.Date) reasons.Add("date"); if (!string.IsNullOrEmpty(payeeName) && !string.IsNullOrEmpty(existing.PayeeName)) reasons.Add("payee"); bestMatch = new DuplicateMatch(existing.Id, confidence, $"Matched on: {string.Join(", ", reasons)}"); } } return bestMatch; } public double CalculateConfidence(Transaction existing, DateTime date, decimal amount, string? payeeName, string? fitId, string? checkNumber) { double score = 0; // Amount matching if (existing.Amount == amount) score += 0.40; else if (Math.Abs(existing.Amount - amount) <= 0.01m) score += 0.35; // Date matching var daysDiff = Math.Abs((existing.Date.Date - date.Date).Days); if (daysDiff == 0) score += 0.30; else if (daysDiff <= 1) score += 0.20; else if (daysDiff <= 2) score += 0.10; // Payee matching (Jaro-Winkler) if (!string.IsNullOrEmpty(payeeName) && !string.IsNullOrEmpty(existing.PayeeName)) { var similarity = StringSimilarityHelper.Calculate(payeeName, existing.PayeeName); if (similarity >= 0.85) score += 0.20; else if (similarity >= 0.70) score += 0.10; } // Check number matching if (!string.IsNullOrEmpty(checkNumber) && existing.CheckNumber == checkNumber) score += 0.10; return score; } }