diff --git a/MoneyMap/Pages/Upload.cshtml.cs b/MoneyMap/Pages/Upload.cshtml.cs index e87bc4b..33231d0 100644 --- a/MoneyMap/Pages/Upload.cshtml.cs +++ b/MoneyMap/Pages/Upload.cshtml.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.IO; using System.Linq; +using System.Text.Json; using System.Text.RegularExpressions; using CsvHelper; using CsvHelper.Configuration; @@ -11,6 +12,7 @@ using Microsoft.EntityFrameworkCore; using MoneyMap.Data; using MoneyMap.Models; using MoneyMap.Models.Import; +using MoneyMap.Services; namespace MoneyMap.Pages { @@ -18,23 +20,39 @@ namespace MoneyMap.Pages { private readonly MoneyMapContext _db; private readonly ITransactionImporter _importer; + private readonly ITransactionCategorizer _categorizer; - public UploadModel(MoneyMapContext db, ITransactionImporter importer) + public UploadModel(MoneyMapContext db, ITransactionImporter importer, ITransactionCategorizer categorizer) { _db = db; _importer = importer; + _categorizer = categorizer; } [BindProperty] public IFormFile? Csv { get; set; } - [BindProperty] public CardSelectMode CardMode { get; set; } = CardSelectMode.Auto; + [BindProperty] public PaymentSelectMode PaymentMode { get; set; } = PaymentSelectMode.Auto; [BindProperty] public int? SelectedCardId { get; set; } + [BindProperty] public int? SelectedAccountId { get; set; } public List Cards { get; set; } = new(); + public List Accounts { get; set; } = new(); public ImportResult? Result { get; set; } + public List PreviewTransactions { get; set; } = new(); + + private const string PreviewSessionKey = "TransactionPreview"; public async Task OnGetAsync() { - Cards = await _db.Cards.OrderBy(c => c.Owner).ThenBy(c => c.Last4).ToListAsync(); + Cards = await _db.Cards + .Include(c => c.Account) + .OrderBy(c => c.Owner) + .ThenBy(c => c.Last4) + .ToListAsync(); + + Accounts = await _db.Accounts + .OrderBy(a => a.Institution) + .ThenBy(a => a.Last4) + .ToListAsync(); } public async Task OnPostAsync() @@ -45,17 +63,135 @@ namespace MoneyMap.Pages return Page(); } - Cards = await _db.Cards.OrderBy(c => c.Owner).ThenBy(c => c.Last4).ToListAsync(); + Cards = await _db.Cards + .Include(c => c.Account) + .OrderBy(c => c.Owner) + .ThenBy(c => c.Last4) + .ToListAsync(); + + Accounts = await _db.Accounts + .OrderBy(a => a.Institution) + .ThenBy(a => a.Last4) + .ToListAsync(); var importContext = new ImportContext { - CardMode = CardMode, + PaymentMode = PaymentMode, SelectedCardId = SelectedCardId, + SelectedAccountId = SelectedAccountId, AvailableCards = Cards, + AvailableAccounts = Accounts, FileName = Csv!.FileName }; - var result = await _importer.ImportAsync(Csv!.OpenReadStream(), importContext); + var previewResult = await _importer.PreviewAsync(Csv!.OpenReadStream(), importContext); + + if (!previewResult.IsSuccess) + { + ModelState.AddModelError(string.Empty, previewResult.ErrorMessage!); + await OnGetAsync(); + return Page(); + } + + // Apply categorization to preview + foreach (var preview in previewResult.Data!) + { + if (string.IsNullOrWhiteSpace(preview.Transaction.Category)) + { + preview.SuggestedCategory = await _categorizer.CategorizeAsync(preview.Transaction.Name, preview.Transaction.Amount); + preview.Transaction.Category = preview.SuggestedCategory ?? ""; + } + } + + PreviewTransactions = previewResult.Data!; + + // Store preview data in Session for confirm step + HttpContext.Session.SetString(PreviewSessionKey, + JsonSerializer.Serialize(PreviewTransactions.Select(p => p.Transaction).ToList())); + + return Page(); + } + + public async Task OnPostConfirmAsync(string selectedIndices, string paymentData) + { + var previewDataJson = HttpContext.Session.GetString(PreviewSessionKey); + if (string.IsNullOrWhiteSpace(previewDataJson)) + { + ModelState.AddModelError(string.Empty, "Preview data expired. Please upload again."); + await OnGetAsync(); + return Page(); + } + + var transactions = JsonSerializer.Deserialize>(previewDataJson); + if (transactions == null || !transactions.Any()) + { + ModelState.AddModelError(string.Empty, "No transactions to import."); + await OnGetAsync(); + return Page(); + } + + // Parse selected indices + var selectedIndexList = string.IsNullOrWhiteSpace(selectedIndices) + ? new List() + : selectedIndices.Split(',').Select(int.Parse).ToList(); + + // Parse payment data + var paymentSelections = string.IsNullOrWhiteSpace(paymentData) + ? new Dictionary() + : JsonSerializer.Deserialize>(paymentData) ?? new(); + + // Filter transactions based on user selection and update payment methods + var transactionsToImport = new List(); + foreach (var index in selectedIndexList) + { + if (index >= 0 && index < transactions.Count) + { + var txn = transactions[index]; + + // Update payment method based on user selection + if (paymentSelections.TryGetValue(index, out var payment)) + { + // Account is required and set globally for all transactions + if (payment.AccountId.HasValue) + { + var account = await _db.Accounts.FindAsync(payment.AccountId.Value); + if (account != null) + { + txn.AccountId = payment.AccountId.Value; + txn.Last4 = account.Last4; + } + } + + // Card is optional per transaction + if (payment.CardId.HasValue) + { + var card = await _db.Cards.FindAsync(payment.CardId.Value); + if (card != null) + { + txn.CardId = card.Id; + } + } + else + { + txn.CardId = null; // Direct account transaction, no card + } + + // Category (user can edit in preview) + if (!string.IsNullOrWhiteSpace(payment.Category)) + { + txn.Category = payment.Category.Trim(); + } + } + + // Check for duplicates + if (!await IsDuplicate(txn)) + { + transactionsToImport.Add(txn); + } + } + } + + var result = await _importer.ImportAsync(transactionsToImport); if (!result.IsSuccess) { @@ -65,9 +201,22 @@ namespace MoneyMap.Pages } Result = result.Data; + HttpContext.Session.Remove(PreviewSessionKey); // Clear preview data + await OnGetAsync(); return Page(); } + private async Task IsDuplicate(Transaction txn) + { + return await _db.Transactions.AnyAsync(t => + t.Date == txn.Date && + t.Amount == txn.Amount && + t.Name == txn.Name && + t.Memo == txn.Memo && + t.CardId == txn.CardId && + t.AccountId == txn.AccountId); + } + private bool ValidateInput() { if (Csv is null || Csv.Length == 0) @@ -79,14 +228,15 @@ namespace MoneyMap.Pages } public record ImportResult(int Total, int Inserted, int Skipped, string? Last4FromFile); - public enum CardSelectMode { Auto, Manual } + public enum PaymentSelectMode { Auto, Card, Account } } // ===== Service Layer ===== public interface ITransactionImporter { - Task ImportAsync(Stream csvStream, ImportContext context); + Task PreviewAsync(Stream csvStream, ImportContext context); + Task ImportAsync(List transactions); } public class TransactionImporter : ITransactionImporter @@ -100,9 +250,9 @@ namespace MoneyMap.Pages _cardResolver = cardResolver; } - public async Task ImportAsync(Stream csvStream, ImportContext context) + public async Task PreviewAsync(Stream csvStream, ImportContext context) { - var stats = new ImportStats(); + var previewItems = new List(); var addedInThisBatch = new HashSet(); using var reader = new StreamReader(csvStream); @@ -121,34 +271,47 @@ namespace MoneyMap.Pages while (csv.Read()) { var row = csv.GetRecord(); - stats.Total++; - var cardResolution = await _cardResolver.ResolveCardAsync(row.Memo, context); - if (!cardResolution.IsSuccess) - return ImportOperationResult.Failure(cardResolution.ErrorMessage!); + var paymentResolution = await _cardResolver.ResolvePaymentAsync(row.Memo, context); + if (!paymentResolution.IsSuccess) + return PreviewOperationResult.Failure(paymentResolution.ErrorMessage!); - var transaction = MapToTransaction(row, cardResolution.CardId, cardResolution.Last4!); + var transaction = MapToTransaction(row, paymentResolution); var key = new TransactionKey(transaction); - // Check both database AND current batch for duplicates - if (addedInThisBatch.Contains(key) || await IsDuplicate(transaction)) - { - stats.Skipped++; - continue; - } + bool isDuplicate = addedInThisBatch.Contains(key) || await IsDuplicate(transaction); + + previewItems.Add(new TransactionPreview + { + Transaction = transaction, + IsDuplicate = isDuplicate, + PaymentMethodLabel = GetPaymentLabel(transaction, context) + }); - _db.Transactions.Add(transaction); addedInThisBatch.Add(key); - stats.Inserted++; + } + + return PreviewOperationResult.Success(previewItems); + } + + public async Task ImportAsync(List transactions) + { + int inserted = 0; + int skipped = 0; + + foreach (var transaction in transactions) + { + _db.Transactions.Add(transaction); + inserted++; } await _db.SaveChangesAsync(); var result = new UploadModel.ImportResult( - stats.Total, - stats.Inserted, - stats.Skipped, - CardIdentifierExtractor.FromFileName(context.FileName) + transactions.Count, + inserted, + skipped, + null ); return ImportOperationResult.Success(result); @@ -165,7 +328,7 @@ namespace MoneyMap.Pages t.AccountId == txn.AccountId); } - private static Transaction MapToTransaction(TransactionCsvRow row, int? cardId, string last4) + private static Transaction MapToTransaction(TransactionCsvRow row, PaymentResolutionResult paymentResolution) { return new Transaction { @@ -175,17 +338,33 @@ namespace MoneyMap.Pages Memo = row.Memo?.Trim() ?? "", Amount = row.Amount, Category = (row.Category ?? "").Trim(), - Last4 = last4, - CardId = cardId + Last4 = paymentResolution.Last4, + CardId = paymentResolution.CardId, + AccountId = paymentResolution.AccountId!.Value // Required - must be present }; } + + private string GetPaymentLabel(Transaction transaction, ImportContext context) + { + var account = context.AvailableAccounts.FirstOrDefault(a => a.Id == transaction.AccountId); + var accountLabel = account?.DisplayLabel ?? $"Account ···· {transaction.Last4}"; + + if (transaction.CardId.HasValue) + { + var card = context.AvailableCards.FirstOrDefault(c => c.Id == transaction.CardId); + var cardLabel = card?.DisplayLabel ?? $"Card ···· {transaction.Last4}"; + return $"{cardLabel} → {accountLabel}"; + } + + return accountLabel; + } } // ===== Card Resolution ===== public interface ICardResolver { - Task ResolveCardAsync(string? memo, ImportContext context); + Task ResolvePaymentAsync(string? memo, ImportContext context); } public class CardResolver : ICardResolver @@ -197,54 +376,100 @@ namespace MoneyMap.Pages _db = db; } - public async Task ResolveCardAsync(string? memo, ImportContext context) + public async Task ResolvePaymentAsync(string? memo, ImportContext context) { - if (context.CardMode == UploadModel.CardSelectMode.Manual) - return ResolveManually(context); + if (context.PaymentMode == UploadModel.PaymentSelectMode.Card) + return ResolveCard(context); + + if (context.PaymentMode == UploadModel.PaymentSelectMode.Account) + return ResolveAccount(context); return await ResolveAutomaticallyAsync(memo, context); } - private CardResolutionResult ResolveManually(ImportContext context) + private PaymentResolutionResult ResolveCard(ImportContext context) { if (context.SelectedCardId is null) - return CardResolutionResult.Failure("Pick a card or switch to Auto."); + 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 CardResolutionResult.Failure("Selected card not found."); + return PaymentResolutionResult.Failure("Selected card not found."); - return CardResolutionResult.Success(card.Id, card.Last4); + // 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 async Task ResolveAutomaticallyAsync(string? memo, ImportContext context) + private PaymentResolutionResult ResolveAccount(ImportContext context) { - var last4 = CardIdentifierExtractor.FromMemo(memo) - ?? CardIdentifierExtractor.FromFileName(context.FileName); + if (context.SelectedAccountId is null) + return PaymentResolutionResult.Failure("Pick an account or switch to Auto/Card mode."); - if (string.IsNullOrWhiteSpace(last4)) - return CardResolutionResult.Failure( - "Couldn't determine card from memo or file name. Choose a card manually."); + var account = context.AvailableAccounts.FirstOrDefault(a => a.Id == context.SelectedAccountId); + if (account is null) + return PaymentResolutionResult.Failure("Selected account not found."); - var card = context.AvailableCards.FirstOrDefault(c => c.Last4 == last4); + return PaymentResolutionResult.SuccessAccount(account.Id, account.Last4); + } - if (card is null) + private async 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)) { - // Auto-create the card - card = new Card - { - Last4 = last4, - Owner = "Unknown" // You can adjust this default - }; - - _db.Cards.Add(card); - await _db.SaveChangesAsync(); - - // Add to context so subsequent rows can use it - context.AvailableCards.Add(card); + var result = TryResolveByLast4(last4FromMemo, context); + if (result != null) return result; } - return CardResolutionResult.Success(card.Id, card.Last4); + // 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 result; + } + + // Nothing found - error + var searchedLast4 = last4FromMemo ?? last4FromFile; + if (string.IsNullOrWhiteSpace(searchedLast4)) + { + return PaymentResolutionResult.Failure( + "Couldn't determine card or account from memo or file name. Choose an account manually."); + } + + return 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 } } @@ -252,7 +477,8 @@ namespace MoneyMap.Pages public static class CardIdentifierExtractor { - private static readonly Regex MemoLast4Pattern = new(@"\b(?:\.|\s)(\d{4,6})\b", RegexOptions.Compiled); + // 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) @@ -260,12 +486,13 @@ namespace MoneyMap.Pages if (string.IsNullOrWhiteSpace(memo)) return null; - var match = MemoLast4Pattern.Match(memo); - if (!match.Success) + // 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; - var digits = match.Groups[1].Value; - return digits.Length >= Last4Length ? digits[^Last4Length..] : null; + // Return the last match (typically the card number at the end) + return matches[^1].Groups[1].Value; } public static string? FromFileName(string fileName) @@ -285,17 +512,19 @@ namespace MoneyMap.Pages // ===== Data Transfer Objects ===== - public record TransactionKey(DateTime Date, decimal Amount, string Name, string Memo, int? CardId, int? AccountId) + public record TransactionKey(DateTime Date, decimal Amount, string Name, string Memo, int AccountId, int? CardId) { public TransactionKey(Transaction txn) - : this(txn.Date, txn.Amount, txn.Name, txn.Memo, txn.CardId, txn.AccountId) { } + : this(txn.Date, txn.Amount, txn.Name, txn.Memo, txn.AccountId, txn.CardId) { } } public class ImportContext { - public required UploadModel.CardSelectMode CardMode { get; init; } + public required UploadModel.PaymentSelectMode PaymentMode { get; init; } public int? SelectedCardId { get; init; } + public int? SelectedAccountId { get; init; } public required List AvailableCards { get; init; } + public required List AvailableAccounts { get; init; } public required string FileName { get; init; } } @@ -306,17 +535,23 @@ namespace MoneyMap.Pages public int Skipped { get; set; } } - public class CardResolutionResult + public class PaymentResolutionResult { public bool IsSuccess { get; init; } public int? CardId { get; init; } + public int? AccountId { get; init; } // Required for all transactions public string? Last4 { get; init; } public string? ErrorMessage { get; init; } - public static CardResolutionResult Success(int? cardId, string last4) => - new() { IsSuccess = true, CardId = cardId, Last4 = last4 }; + // When a card is used: accountId is the account the card is linked to (or a default account) + public static PaymentResolutionResult SuccessCard(int cardId, int accountId, string last4) => + new() { IsSuccess = true, CardId = cardId, AccountId = accountId, Last4 = last4 }; - public static CardResolutionResult Failure(string error) => + // When no card: accountId is the primary account for the transaction + public static PaymentResolutionResult SuccessAccount(int accountId, string last4) => + new() { IsSuccess = true, AccountId = accountId, Last4 = last4 }; + + public static PaymentResolutionResult Failure(string error) => new() { IsSuccess = false, ErrorMessage = error }; } @@ -332,4 +567,32 @@ namespace MoneyMap.Pages public static ImportOperationResult Failure(string error) => new() { IsSuccess = false, ErrorMessage = error }; } + + public class PreviewOperationResult + { + public bool IsSuccess { get; init; } + public List? Data { get; init; } + public string? ErrorMessage { get; init; } + + public static PreviewOperationResult Success(List data) => + new() { IsSuccess = true, Data = data }; + + public static PreviewOperationResult Failure(string error) => + new() { IsSuccess = false, ErrorMessage = error }; + } + + public class TransactionPreview + { + public required Transaction Transaction { get; init; } + public bool IsDuplicate { get; init; } + public required string PaymentMethodLabel { get; init; } + public string? SuggestedCategory { get; set; } + } + + public class PaymentSelection + { + public int? AccountId { get; set; } // Set globally for all transactions + public int? CardId { get; set; } // Optional per transaction + public string? Category { get; set; } // User can edit in preview + } } \ No newline at end of file