Initial commit: Purrse personal finance app
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>
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user