6520ebf221
Self-hosted, plugin-extensible personal finance manager built with ASP.NET Core 9.0, Vue 3 + Vuetify 3, and PostgreSQL 17. Backend (8 .NET projects): - Core: 19 domain models, 6 enums, 14 DTOs, 12 service interfaces - Data: EF Core DbContext, 16 entity configurations, category seeder - API: 14 controllers, 15 services, JWT auth, SignalR, middleware - Plugins: Abstractions + OFX/CSV/QIF file parsers - Tests: 28 xUnit tests (fingerprinting, duplicate detection, parsers) Frontend (Vue 3 + Vuetify 3 + TypeScript): - 13 views, Pinia stores, Axios API services with JWT interceptors - Dashboard, accounts, transactions, categories, imports, and more Deployment: - Docker Compose (PostgreSQL 17 + .NET API + nginx frontend) - Auto-migration on startup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
92 lines
3.4 KiB
C#
92 lines
3.4 KiB
C#
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<DuplicateMatch?> 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<string>();
|
|
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;
|
|
}
|
|
}
|