using MoneyMap.Data; using MoneyMap.Models.Import; using System.Text.RegularExpressions; namespace MoneyMap.Services { /// /// Service for resolving payment methods (cards/accounts) for transactions. /// public interface ICardResolver { Task ResolvePaymentAsync(string? memo, ImportContext context); } public class CardResolver : ICardResolver { private readonly MoneyMapContext _db; public CardResolver(MoneyMapContext db) { _db = db; } public async Task 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 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 } } /// /// Utility class for extracting card/account identifiers from memos and filenames. /// 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; } } }