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
+130
View File
@@ -0,0 +1,130 @@
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 PayeeService : IPayeeService
{
private readonly PurrseDbContext _db;
public PayeeService(PurrseDbContext db)
{
_db = db;
}
public async Task<List<PayeeResponse>> GetAllAsync(Guid userId)
{
return await _db.Payees
.Include(p => p.Aliases).Include(p => p.DefaultCategory)
.Where(p => p.UserId == userId)
.OrderBy(p => p.Name)
.Select(p => new PayeeResponse(p.Id, p.Name, p.DefaultCategoryId, p.DefaultCategory != null ? p.DefaultCategory.Name : null, p.IsActive, p.Aliases.Select(a => a.Alias).ToList()))
.ToListAsync();
}
public async Task<PayeeResponse?> GetByIdAsync(Guid userId, Guid payeeId)
{
var p = await _db.Payees.Include(p => p.Aliases).Include(p => p.DefaultCategory)
.FirstOrDefaultAsync(p => p.Id == payeeId && p.UserId == userId);
return p == null ? null : new PayeeResponse(p.Id, p.Name, p.DefaultCategoryId, p.DefaultCategory?.Name, p.IsActive, p.Aliases.Select(a => a.Alias).ToList());
}
public async Task<PayeeResponse> CreateAsync(Guid userId, CreatePayeeRequest request)
{
var payee = new Payee
{
Id = Guid.NewGuid(),
UserId = userId,
Name = request.Name,
DefaultCategoryId = request.DefaultCategoryId
};
if (request.Aliases?.Any() == true)
{
foreach (var alias in request.Aliases)
payee.Aliases.Add(new PayeeAlias { Id = Guid.NewGuid(), Alias = alias });
}
_db.Payees.Add(payee);
await _db.SaveChangesAsync();
return new PayeeResponse(payee.Id, payee.Name, payee.DefaultCategoryId, null, true, request.Aliases ?? new());
}
public async Task<PayeeResponse> UpdateAsync(Guid userId, Guid payeeId, UpdatePayeeRequest request)
{
var payee = await _db.Payees.Include(p => p.Aliases)
.FirstOrDefaultAsync(p => p.Id == payeeId && p.UserId == userId)
?? throw new KeyNotFoundException("Payee not found");
payee.Name = request.Name;
payee.DefaultCategoryId = request.DefaultCategoryId;
payee.IsActive = request.IsActive;
await _db.SaveChangesAsync();
return new PayeeResponse(payee.Id, payee.Name, payee.DefaultCategoryId, null, payee.IsActive, payee.Aliases.Select(a => a.Alias).ToList());
}
public async Task DeleteAsync(Guid userId, Guid payeeId)
{
var payee = await _db.Payees.FirstOrDefaultAsync(p => p.Id == payeeId && p.UserId == userId)
?? throw new KeyNotFoundException("Payee not found");
_db.Payees.Remove(payee);
await _db.SaveChangesAsync();
}
public async Task AddAliasAsync(Guid userId, Guid payeeId, string alias)
{
var payee = await _db.Payees.FirstOrDefaultAsync(p => p.Id == payeeId && p.UserId == userId)
?? throw new KeyNotFoundException("Payee not found");
_db.PayeeAliases.Add(new PayeeAlias { Id = Guid.NewGuid(), PayeeId = payeeId, Alias = alias });
await _db.SaveChangesAsync();
}
public async Task RemoveAliasAsync(Guid userId, Guid payeeId, Guid aliasId)
{
var alias = await _db.PayeeAliases.FirstOrDefaultAsync(a => a.Id == aliasId && a.PayeeId == payeeId)
?? throw new KeyNotFoundException("Alias not found");
_db.PayeeAliases.Remove(alias);
await _db.SaveChangesAsync();
}
public async Task<Payee?> MatchPayeeAsync(Guid userId, string importedName)
{
if (string.IsNullOrEmpty(importedName)) return null;
// Check exact match on name
var exact = await _db.Payees.FirstOrDefaultAsync(p => p.UserId == userId && p.Name == importedName && p.IsActive);
if (exact != null) return exact;
// Check aliases
var aliasMatch = await _db.PayeeAliases
.Include(a => a.Payee)
.FirstOrDefaultAsync(a => a.Payee.UserId == userId && a.Alias == importedName);
if (aliasMatch != null) return aliasMatch.Payee;
// Fuzzy match
var payees = await _db.Payees.Include(p => p.Aliases)
.Where(p => p.UserId == userId && p.IsActive).ToListAsync();
Payee? bestMatch = null;
double bestScore = 0;
foreach (var payee in payees)
{
var score = StringSimilarityHelper.Calculate(importedName, payee.Name);
if (score > bestScore) { bestScore = score; bestMatch = payee; }
foreach (var alias in payee.Aliases)
{
score = StringSimilarityHelper.Calculate(importedName, alias.Alias);
if (score > bestScore) { bestScore = score; bestMatch = payee; }
}
}
return bestScore >= 0.85 ? bestMatch : null;
}
}