a689681872
Use clean payee name instead of raw bank string when a payee match is found during import. When payees are created/renamed or aliases added, automatically update or link matching unlinked transactions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
203 lines
7.2 KiB
C#
203 lines
7.2 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 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 => new PayeeAliasResponse(a.Id, 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 => new PayeeAliasResponse(a.Id, 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();
|
|
|
|
await MatchUnlinkedTransactions(userId, payee);
|
|
|
|
return new PayeeResponse(payee.Id, payee.Name, payee.DefaultCategoryId, null, true, payee.Aliases.Select(a => new PayeeAliasResponse(a.Id, a.Alias)).ToList());
|
|
}
|
|
|
|
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();
|
|
|
|
await NormalizeLinkedTransactions(payee);
|
|
|
|
return new PayeeResponse(payee.Id, payee.Name, payee.DefaultCategoryId, null, payee.IsActive, payee.Aliases.Select(a => new PayeeAliasResponse(a.Id, 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.Include(p => p.Aliases)
|
|
.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();
|
|
|
|
// Reload aliases so the newly added one is included
|
|
await _db.Entry(payee).Collection(p => p.Aliases).LoadAsync();
|
|
await MatchUnlinkedTransactions(userId, payee);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
private async Task NormalizeLinkedTransactions(Payee payee)
|
|
{
|
|
var transactions = await _db.Transactions
|
|
.Where(t => t.PayeeId == payee.Id && t.PayeeName != payee.Name)
|
|
.ToListAsync();
|
|
|
|
foreach (var txn in transactions)
|
|
txn.PayeeName = payee.Name;
|
|
|
|
if (transactions.Count > 0)
|
|
await _db.SaveChangesAsync();
|
|
}
|
|
|
|
private async Task MatchUnlinkedTransactions(Guid userId, Payee payee)
|
|
{
|
|
var unlinked = await _db.Transactions
|
|
.Include(t => t.Account)
|
|
.Where(t => t.Account.UserId == userId && t.PayeeId == null && t.PayeeName != null)
|
|
.ToListAsync();
|
|
|
|
var matched = false;
|
|
foreach (var txn in unlinked)
|
|
{
|
|
if (!IsMatch(txn.PayeeName!, payee))
|
|
continue;
|
|
|
|
txn.PayeeId = payee.Id;
|
|
txn.PayeeName = payee.Name;
|
|
|
|
if (txn.CategoryId == null && payee.DefaultCategoryId != null)
|
|
txn.CategoryId = payee.DefaultCategoryId;
|
|
|
|
matched = true;
|
|
}
|
|
|
|
if (matched)
|
|
await _db.SaveChangesAsync();
|
|
}
|
|
|
|
private static bool IsMatch(string transactionPayeeName, Payee payee)
|
|
{
|
|
if (string.Equals(transactionPayeeName, payee.Name, StringComparison.OrdinalIgnoreCase))
|
|
return true;
|
|
|
|
foreach (var alias in payee.Aliases)
|
|
{
|
|
if (string.Equals(transactionPayeeName, alias.Alias, StringComparison.OrdinalIgnoreCase))
|
|
return true;
|
|
}
|
|
|
|
if (StringSimilarityHelper.Calculate(transactionPayeeName, payee.Name) >= 0.85)
|
|
return true;
|
|
|
|
foreach (var alias in payee.Aliases)
|
|
{
|
|
if (StringSimilarityHelper.Calculate(transactionPayeeName, alias.Alias) >= 0.85)
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|