Private
Public Access
1
0

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:
Catherine Renelle
2026-02-08 08:56:46 -05:00
commit 6520ebf221
169 changed files with 7180 additions and 0 deletions
@@ -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;
}
}