refactor: move services and AITools to MoneyMap.Core
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models.Import;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace MoneyMap.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Service for resolving payment methods (cards/accounts) for transactions.
|
||||
/// </summary>
|
||||
public interface ICardResolver
|
||||
{
|
||||
Task<PaymentResolutionResult> ResolvePaymentAsync(string? memo, ImportContext context);
|
||||
}
|
||||
|
||||
public class CardResolver : ICardResolver
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public CardResolver(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<PaymentResolutionResult> ResolvePaymentAsync(string? memo, ImportContext context)
|
||||
{
|
||||
if (context.PaymentMode == PaymentSelectMode.Card)
|
||||
return ResolveCard(context);
|
||||
|
||||
if (context.PaymentMode == PaymentSelectMode.Account)
|
||||
return ResolveAccount(context);
|
||||
|
||||
return await ResolveAutomaticallyAsync(memo, context);
|
||||
}
|
||||
|
||||
private PaymentResolutionResult ResolveCard(ImportContext context)
|
||||
{
|
||||
if (context.SelectedCardId is null)
|
||||
return PaymentResolutionResult.Failure("Pick a card or switch to Auto.");
|
||||
|
||||
var card = context.AvailableCards.FirstOrDefault(c => c.Id == context.SelectedCardId);
|
||||
if (card is null)
|
||||
return PaymentResolutionResult.Failure("Selected card not found.");
|
||||
|
||||
// Card must have a linked account
|
||||
if (!card.AccountId.HasValue)
|
||||
return PaymentResolutionResult.Failure($"Card {card.DisplayLabel} is not linked to an account. Please link it to an account first.");
|
||||
|
||||
return PaymentResolutionResult.SuccessCard(card.Id, card.AccountId.Value, card.Last4);
|
||||
}
|
||||
|
||||
private PaymentResolutionResult ResolveAccount(ImportContext context)
|
||||
{
|
||||
if (context.SelectedAccountId is null)
|
||||
return PaymentResolutionResult.Failure("Pick an account or switch to Auto/Card mode.");
|
||||
|
||||
var account = context.AvailableAccounts.FirstOrDefault(a => a.Id == context.SelectedAccountId);
|
||||
if (account is null)
|
||||
return PaymentResolutionResult.Failure("Selected account not found.");
|
||||
|
||||
return PaymentResolutionResult.SuccessAccount(account.Id, account.Last4);
|
||||
}
|
||||
|
||||
private Task<PaymentResolutionResult> ResolveAutomaticallyAsync(string? memo, ImportContext context)
|
||||
{
|
||||
// Extract last4 from both memo and filename
|
||||
var last4FromFile = CardIdentifierExtractor.FromFileName(context.FileName);
|
||||
var last4FromMemo = CardIdentifierExtractor.FromMemo(memo);
|
||||
|
||||
// PRIORITY 1: Try memo first (for per-transaction card detection like "usbank.com.2765")
|
||||
if (!string.IsNullOrWhiteSpace(last4FromMemo))
|
||||
{
|
||||
var result = TryResolveByLast4(last4FromMemo, context);
|
||||
if (result != null) return Task.FromResult(result);
|
||||
}
|
||||
|
||||
// PRIORITY 2: Fall back to filename (for account-level CSVs or when memo has no card)
|
||||
if (!string.IsNullOrWhiteSpace(last4FromFile))
|
||||
{
|
||||
var result = TryResolveByLast4(last4FromFile, context);
|
||||
if (result != null) return Task.FromResult(result);
|
||||
}
|
||||
|
||||
// Nothing found - error
|
||||
var searchedLast4 = last4FromMemo ?? last4FromFile;
|
||||
if (string.IsNullOrWhiteSpace(searchedLast4))
|
||||
{
|
||||
return Task.FromResult(PaymentResolutionResult.Failure(
|
||||
"Couldn't determine card or account from memo or file name. Choose an account manually."));
|
||||
}
|
||||
|
||||
return Task.FromResult(PaymentResolutionResult.Failure(
|
||||
$"Couldn't find account or card with last4 '{searchedLast4}'. Choose an account manually."));
|
||||
}
|
||||
|
||||
private PaymentResolutionResult? TryResolveByLast4(string last4, ImportContext context)
|
||||
{
|
||||
// Look for both card and account matches
|
||||
var matchingCard = context.AvailableCards.FirstOrDefault(c => c.Last4 == last4);
|
||||
var matchingAccount = context.AvailableAccounts.FirstOrDefault(a => a.Last4 == last4);
|
||||
|
||||
// Prioritize card matches (for credit card CSVs or memo-based card detection)
|
||||
if (matchingCard != null)
|
||||
{
|
||||
// Card found - it must have an account
|
||||
if (!matchingCard.AccountId.HasValue)
|
||||
return PaymentResolutionResult.Failure($"Card {matchingCard.DisplayLabel} is not linked to an account. Please link it first or choose an account manually.");
|
||||
|
||||
return PaymentResolutionResult.SuccessCard(matchingCard.Id, matchingCard.AccountId.Value, matchingCard.Last4);
|
||||
}
|
||||
|
||||
// Fall back to account match (for direct account transactions)
|
||||
if (matchingAccount != null)
|
||||
{
|
||||
return PaymentResolutionResult.SuccessAccount(matchingAccount.Id, matchingAccount.Last4);
|
||||
}
|
||||
|
||||
return null; // No match found
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Utility class for extracting card/account identifiers from memos and filenames.
|
||||
/// </summary>
|
||||
public static class CardIdentifierExtractor
|
||||
{
|
||||
// Match patterns: "usbank.com.2765" or "usbank.com.0479" or similar formats
|
||||
private static readonly Regex MemoLast4Pattern = new(@"\.(\d{4})(?:\D|$)", RegexOptions.Compiled);
|
||||
private const int Last4Length = 4;
|
||||
|
||||
public static string? FromMemo(string? memo)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(memo))
|
||||
return null;
|
||||
|
||||
// Try to find all matches and return the last one (most likely to be card/account number)
|
||||
var matches = MemoLast4Pattern.Matches(memo);
|
||||
if (matches.Count == 0)
|
||||
return null;
|
||||
|
||||
// Return the last match (typically the card number at the end)
|
||||
return matches[^1].Groups[1].Value;
|
||||
}
|
||||
|
||||
public static string? FromFileName(string fileName)
|
||||
{
|
||||
var name = Path.GetFileNameWithoutExtension(fileName);
|
||||
var parts = name.Split(new[] { '-', '_', ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
foreach (var part in parts.Select(p => p.Trim()))
|
||||
{
|
||||
if (part.Length == Last4Length && int.TryParse(part, out _))
|
||||
return part;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user