Feature: add TransactionService and ReceiptMatchingService
Add two new services to extract business logic from PageModels: - TransactionService: Handles core transaction operations including duplicate detection, retrieval, and deletion - ReceiptMatchingService: Implements intelligent receipt-to-transaction matching using date, merchant, and amount scoring Both services follow the established service layer pattern with interfaces for dependency injection and improved testability. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,8 @@ builder.Services.AddScoped<ITransactionImporter, TransactionImporter>();
|
|||||||
builder.Services.AddScoped<ICardResolver, CardResolver>();
|
builder.Services.AddScoped<ICardResolver, CardResolver>();
|
||||||
builder.Services.AddScoped<ITransactionCategorizer, TransactionCategorizer>();
|
builder.Services.AddScoped<ITransactionCategorizer, TransactionCategorizer>();
|
||||||
builder.Services.AddScoped<IMerchantService, MerchantService>();
|
builder.Services.AddScoped<IMerchantService, MerchantService>();
|
||||||
|
builder.Services.AddScoped<ITransactionService, TransactionService>();
|
||||||
|
builder.Services.AddScoped<IReceiptMatchingService, ReceiptMatchingService>();
|
||||||
|
|
||||||
// Dashboard services
|
// Dashboard services
|
||||||
builder.Services.AddScoped<IDashboardService, DashboardService>();
|
builder.Services.AddScoped<IDashboardService, DashboardService>();
|
||||||
|
|||||||
237
MoneyMap/Services/ReceiptMatchingService.cs
Normal file
237
MoneyMap/Services/ReceiptMatchingService.cs
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using MoneyMap.Data;
|
||||||
|
using MoneyMap.Models;
|
||||||
|
|
||||||
|
namespace MoneyMap.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for matching receipts to transactions based on date, merchant, and amount.
|
||||||
|
/// </summary>
|
||||||
|
public interface IReceiptMatchingService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Finds matching transactions for a receipt based on date range, merchant name,
|
||||||
|
/// and amount tolerance. Returns transactions sorted by relevance.
|
||||||
|
/// </summary>
|
||||||
|
Task<List<TransactionMatch>> FindMatchingTransactionsAsync(ReceiptMatchCriteria criteria);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a set of transaction IDs that already have receipts mapped to them.
|
||||||
|
/// </summary>
|
||||||
|
Task<HashSet<long>> GetTransactionIdsWithReceiptsAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ReceiptMatchingService : IReceiptMatchingService
|
||||||
|
{
|
||||||
|
private readonly MoneyMapContext _db;
|
||||||
|
|
||||||
|
public ReceiptMatchingService(MoneyMapContext db)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HashSet<long>> GetTransactionIdsWithReceiptsAsync()
|
||||||
|
{
|
||||||
|
var transactionIds = await _db.Receipts
|
||||||
|
.Where(r => r.TransactionId != null)
|
||||||
|
.Select(r => r.TransactionId!.Value)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return new HashSet<long>(transactionIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<TransactionMatch>> FindMatchingTransactionsAsync(ReceiptMatchCriteria criteria)
|
||||||
|
{
|
||||||
|
var query = _db.Transactions
|
||||||
|
.Include(t => t.Card)
|
||||||
|
.Include(t => t.Account)
|
||||||
|
.Include(t => t.Merchant)
|
||||||
|
.Where(t => !criteria.ExcludeTransactionIds.Contains(t.Id))
|
||||||
|
.AsQueryable();
|
||||||
|
|
||||||
|
// Apply date filtering based on receipt type
|
||||||
|
query = ApplyDateFilter(query, criteria);
|
||||||
|
|
||||||
|
// Get all candidates within date range
|
||||||
|
var candidates = await query.ToListAsync();
|
||||||
|
|
||||||
|
// Sort by merchant/name relevance using word matching
|
||||||
|
if (!string.IsNullOrWhiteSpace(criteria.MerchantName))
|
||||||
|
{
|
||||||
|
candidates = SortByMerchantRelevance(candidates, criteria.MerchantName);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// No merchant filter, just sort by date
|
||||||
|
candidates = candidates
|
||||||
|
.OrderByDescending(t => t.Date)
|
||||||
|
.ThenByDescending(t => t.Id)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by amount (±10% tolerance) if receipt has a total
|
||||||
|
if (criteria.Total.HasValue)
|
||||||
|
{
|
||||||
|
candidates = FilterByAmountTolerance(candidates, criteria.Total.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to match results with scoring
|
||||||
|
var matches = ConvertToMatches(candidates, criteria);
|
||||||
|
|
||||||
|
// If no date-filtered matches, fall back to recent transactions
|
||||||
|
if (!matches.Any() && !criteria.ReceiptDate.HasValue)
|
||||||
|
{
|
||||||
|
matches = await GetFallbackMatches(criteria.ExcludeTransactionIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IQueryable<Transaction> ApplyDateFilter(IQueryable<Transaction> query, ReceiptMatchCriteria criteria)
|
||||||
|
{
|
||||||
|
// For bills with due dates: use range from bill date to due date + 5 days
|
||||||
|
// (to account for auto-pay processing delays, weekends, etc.)
|
||||||
|
if (criteria.ReceiptDate.HasValue && criteria.DueDate.HasValue)
|
||||||
|
{
|
||||||
|
var minDate = criteria.ReceiptDate.Value;
|
||||||
|
var maxDate = criteria.DueDate.Value.AddDays(5);
|
||||||
|
return query.Where(t => t.Date >= minDate && t.Date <= maxDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For regular receipts: use +/- 3 days
|
||||||
|
if (criteria.ReceiptDate.HasValue)
|
||||||
|
{
|
||||||
|
var minDate = criteria.ReceiptDate.Value.AddDays(-3);
|
||||||
|
var maxDate = criteria.ReceiptDate.Value.AddDays(3);
|
||||||
|
return query.Where(t => t.Date >= minDate && t.Date <= maxDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Transaction> SortByMerchantRelevance(List<Transaction> candidates, string merchantName)
|
||||||
|
{
|
||||||
|
var receiptWords = merchantName.ToLower().Split(new[] { ' ', '-', '_', '.' }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
.OrderByDescending(t =>
|
||||||
|
{
|
||||||
|
var merchantNameLower = t.Merchant?.Name?.ToLower() ?? "";
|
||||||
|
var transactionNameLower = t.Name?.ToLower() ?? "";
|
||||||
|
|
||||||
|
// Exact match gets highest score
|
||||||
|
if (merchantNameLower == merchantName.ToLower() || transactionNameLower == merchantName.ToLower())
|
||||||
|
return 1000;
|
||||||
|
|
||||||
|
// Count matching words
|
||||||
|
var merchantWords = merchantNameLower.Split(new[] { ' ', '-', '_', '.' }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
var transactionWords = transactionNameLower.Split(new[] { ' ', '-', '_', '.' }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
|
var merchantMatches = receiptWords.Count(rw => merchantWords.Any(mw => mw.Contains(rw) || rw.Contains(mw)));
|
||||||
|
var transactionMatches = receiptWords.Count(rw => transactionWords.Any(tw => tw.Contains(rw) || rw.Contains(tw)));
|
||||||
|
|
||||||
|
// Return the higher match count
|
||||||
|
return Math.Max(merchantMatches * 10, transactionMatches * 10);
|
||||||
|
})
|
||||||
|
.ThenByDescending(t => t.Date)
|
||||||
|
.ThenByDescending(t => t.Id)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Transaction> FilterByAmountTolerance(List<Transaction> candidates, decimal total)
|
||||||
|
{
|
||||||
|
var receiptTotal = Math.Round(Math.Abs(total), 2);
|
||||||
|
var tolerance = receiptTotal * 0.10m; // 10% tolerance
|
||||||
|
var minAmount = receiptTotal - tolerance;
|
||||||
|
var maxAmount = receiptTotal + tolerance;
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
.Where(t =>
|
||||||
|
{
|
||||||
|
var transactionAmount = Math.Round(Math.Abs(t.Amount), 2);
|
||||||
|
return transactionAmount >= minAmount && transactionAmount <= maxAmount;
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<TransactionMatch> ConvertToMatches(List<Transaction> candidates, ReceiptMatchCriteria criteria)
|
||||||
|
{
|
||||||
|
return candidates.Select(t =>
|
||||||
|
{
|
||||||
|
var match = new TransactionMatch
|
||||||
|
{
|
||||||
|
Id = t.Id,
|
||||||
|
Date = t.Date,
|
||||||
|
Name = t.Name,
|
||||||
|
Amount = t.Amount,
|
||||||
|
MerchantName = t.Merchant?.Name,
|
||||||
|
PaymentMethod = t.PaymentMethodLabel,
|
||||||
|
IsExactAmount = false,
|
||||||
|
IsCloseAmount = false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Amount matching flags
|
||||||
|
if (criteria.Total.HasValue)
|
||||||
|
{
|
||||||
|
var receiptTotal = Math.Round(Math.Abs(criteria.Total.Value), 2);
|
||||||
|
var transactionAmount = Math.Round(Math.Abs(t.Amount), 2);
|
||||||
|
match.IsExactAmount = transactionAmount == receiptTotal;
|
||||||
|
var tolerance = receiptTotal * 0.10m;
|
||||||
|
match.IsCloseAmount = !match.IsExactAmount && Math.Abs(transactionAmount - receiptTotal) <= tolerance;
|
||||||
|
}
|
||||||
|
|
||||||
|
return match;
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<TransactionMatch>> GetFallbackMatches(HashSet<long> excludeIds)
|
||||||
|
{
|
||||||
|
return await _db.Transactions
|
||||||
|
.Include(t => t.Card)
|
||||||
|
.Include(t => t.Account)
|
||||||
|
.Include(t => t.Merchant)
|
||||||
|
.Where(t => !excludeIds.Contains(t.Id))
|
||||||
|
.OrderByDescending(t => t.Date)
|
||||||
|
.ThenByDescending(t => t.Id)
|
||||||
|
.Take(50)
|
||||||
|
.Select(t => new TransactionMatch
|
||||||
|
{
|
||||||
|
Id = t.Id,
|
||||||
|
Date = t.Date,
|
||||||
|
Name = t.Name,
|
||||||
|
Amount = t.Amount,
|
||||||
|
MerchantName = t.Merchant != null ? t.Merchant.Name : null,
|
||||||
|
PaymentMethod = t.PaymentMethodLabel,
|
||||||
|
IsExactAmount = false,
|
||||||
|
IsCloseAmount = false
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Criteria for matching receipts to transactions.
|
||||||
|
/// </summary>
|
||||||
|
public class ReceiptMatchCriteria
|
||||||
|
{
|
||||||
|
public DateTime? ReceiptDate { get; set; }
|
||||||
|
public DateTime? DueDate { get; set; }
|
||||||
|
public decimal? Total { get; set; }
|
||||||
|
public string? MerchantName { get; set; }
|
||||||
|
public HashSet<long> ExcludeTransactionIds { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a transaction that matches a receipt, with scoring information.
|
||||||
|
/// </summary>
|
||||||
|
public class TransactionMatch
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public DateTime Date { get; set; }
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
public string? MerchantName { get; set; }
|
||||||
|
public string PaymentMethod { get; set; } = "";
|
||||||
|
public bool IsExactAmount { get; set; }
|
||||||
|
public bool IsCloseAmount { get; set; }
|
||||||
|
}
|
||||||
79
MoneyMap/Services/TransactionService.cs
Normal file
79
MoneyMap/Services/TransactionService.cs
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using MoneyMap.Data;
|
||||||
|
using MoneyMap.Models;
|
||||||
|
|
||||||
|
namespace MoneyMap.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for core transaction operations including duplicate detection,
|
||||||
|
/// retrieval, and deletion.
|
||||||
|
/// </summary>
|
||||||
|
public interface ITransactionService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if a transaction is a duplicate based on date, amount, name, memo,
|
||||||
|
/// account, and card.
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> IsDuplicateAsync(Transaction transaction);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a transaction by ID with optional related data.
|
||||||
|
/// </summary>
|
||||||
|
Task<Transaction?> GetTransactionByIdAsync(long id, bool includeRelated = false);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes a transaction and all related data (receipts, parse logs, line items).
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> DeleteTransactionAsync(long id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TransactionService : ITransactionService
|
||||||
|
{
|
||||||
|
private readonly MoneyMapContext _db;
|
||||||
|
|
||||||
|
public TransactionService(MoneyMapContext db)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> IsDuplicateAsync(Transaction transaction)
|
||||||
|
{
|
||||||
|
return await _db.Transactions.AnyAsync(t =>
|
||||||
|
t.Date == transaction.Date &&
|
||||||
|
t.Amount == transaction.Amount &&
|
||||||
|
t.Name == transaction.Name &&
|
||||||
|
t.Memo == transaction.Memo &&
|
||||||
|
t.AccountId == transaction.AccountId &&
|
||||||
|
t.CardId == transaction.CardId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Transaction?> GetTransactionByIdAsync(long id, bool includeRelated = false)
|
||||||
|
{
|
||||||
|
var query = _db.Transactions.AsQueryable();
|
||||||
|
|
||||||
|
if (includeRelated)
|
||||||
|
{
|
||||||
|
query = query
|
||||||
|
.Include(t => t.Card)
|
||||||
|
.ThenInclude(c => c!.Account)
|
||||||
|
.Include(t => t.Account)
|
||||||
|
.Include(t => t.TransferToAccount)
|
||||||
|
.Include(t => t.Merchant)
|
||||||
|
.Include(t => t.Receipts)
|
||||||
|
.ThenInclude(r => r.LineItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await query.FirstOrDefaultAsync(t => t.Id == id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeleteTransactionAsync(long id)
|
||||||
|
{
|
||||||
|
var transaction = await _db.Transactions.FindAsync(id);
|
||||||
|
if (transaction == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
_db.Transactions.Remove(transaction);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user