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> 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 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 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 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 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; } }