From 2ceb3a7b5784c6db727bfff9a93aaf7309ffa931 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 24 Nov 2025 21:11:39 -0500 Subject: [PATCH] Refactor: Extract import services from Upload page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract to separate files for better maintainability: - Models/Import/ImportContext.cs - Import context and PaymentSelectMode enum - Models/Import/ImportResults.cs - Import result DTOs and TransactionKey - Models/Import/PaymentResolutionResult.cs - Payment resolution DTO - Services/TransactionImporter.cs - CSV import logic - Services/CardResolver.cs - Payment method resolution Reduces Upload.cshtml.cs from 615 lines to 216 lines. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- MoneyMap/Models/Import/ImportContext.cs | 28 ++ MoneyMap/Models/Import/ImportResults.cs | 69 +++ .../Models/Import/PaymentResolutionResult.cs | 32 ++ MoneyMap/Pages/Upload.cshtml.cs | 401 +----------------- MoneyMap/Services/CardResolver.cs | 158 +++++++ MoneyMap/Services/TransactionImporter.cs | 167 ++++++++ 6 files changed, 455 insertions(+), 400 deletions(-) create mode 100644 MoneyMap/Models/Import/ImportContext.cs create mode 100644 MoneyMap/Models/Import/ImportResults.cs create mode 100644 MoneyMap/Models/Import/PaymentResolutionResult.cs create mode 100644 MoneyMap/Services/CardResolver.cs create mode 100644 MoneyMap/Services/TransactionImporter.cs diff --git a/MoneyMap/Models/Import/ImportContext.cs b/MoneyMap/Models/Import/ImportContext.cs new file mode 100644 index 0000000..6fa0ee3 --- /dev/null +++ b/MoneyMap/Models/Import/ImportContext.cs @@ -0,0 +1,28 @@ +namespace MoneyMap.Models.Import +{ + /// + /// Context for transaction import operations, containing payment selection mode and available options. + /// + public class ImportContext + { + public required 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; } + } + + /// + /// Specifies how to determine the payment method for imported transactions. + /// + public enum PaymentSelectMode + { + /// Auto-detect from memo or filename. + Auto, + /// Use a specific card for all transactions. + Card, + /// Use a specific account for all transactions. + Account + } +} diff --git a/MoneyMap/Models/Import/ImportResults.cs b/MoneyMap/Models/Import/ImportResults.cs new file mode 100644 index 0000000..41c9514 --- /dev/null +++ b/MoneyMap/Models/Import/ImportResults.cs @@ -0,0 +1,69 @@ +namespace MoneyMap.Models.Import +{ + /// + /// Result of an import operation, showing counts of processed transactions. + /// + public record ImportResult(int Total, int Inserted, int Skipped, string? Last4FromFile); + + /// + /// Wrapper for import operation result with success/failure state. + /// + public class ImportOperationResult + { + public bool IsSuccess { get; init; } + public ImportResult? Data { get; init; } + public string? ErrorMessage { get; init; } + + public static ImportOperationResult Success(ImportResult data) => + new() { IsSuccess = true, Data = data }; + + public static ImportOperationResult Failure(string error) => + new() { IsSuccess = false, ErrorMessage = error }; + } + + /// + /// Wrapper for preview operation result with success/failure state. + /// + 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 }; + } + + /// + /// Preview of a transaction before import, with duplicate detection info. + /// + 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; } + } + + /// + /// User's selection for payment method during import confirmation. + /// + public class PaymentSelection + { + public int? AccountId { get; set; } + public int? CardId { get; set; } + public string? Category { get; set; } + } + + /// + /// Key for detecting duplicate transactions. + /// + 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.AccountId, txn.CardId) { } + } +} diff --git a/MoneyMap/Models/Import/PaymentResolutionResult.cs b/MoneyMap/Models/Import/PaymentResolutionResult.cs new file mode 100644 index 0000000..bbfc2cd --- /dev/null +++ b/MoneyMap/Models/Import/PaymentResolutionResult.cs @@ -0,0 +1,32 @@ +namespace MoneyMap.Models.Import +{ + /// + /// Result of resolving a payment method (card or account) for a transaction. + /// + public class PaymentResolutionResult + { + public bool IsSuccess { get; init; } + public int? CardId { get; init; } + public int? AccountId { get; init; } + public string? Last4 { get; init; } + public string? ErrorMessage { get; init; } + + /// + /// Creates a successful result when a card is used. + /// + public static PaymentResolutionResult SuccessCard(int cardId, int accountId, string last4) => + new() { IsSuccess = true, CardId = cardId, AccountId = accountId, Last4 = last4 }; + + /// + /// Creates a successful result when a direct account transaction (no card). + /// + public static PaymentResolutionResult SuccessAccount(int accountId, string last4) => + new() { IsSuccess = true, AccountId = accountId, Last4 = last4 }; + + /// + /// Creates a failure result with error message. + /// + public static PaymentResolutionResult Failure(string error) => + new() { IsSuccess = false, ErrorMessage = error }; + } +} diff --git a/MoneyMap/Pages/Upload.cshtml.cs b/MoneyMap/Pages/Upload.cshtml.cs index 4d7bc39..115caaf 100644 --- a/MoneyMap/Pages/Upload.cshtml.cs +++ b/MoneyMap/Pages/Upload.cshtml.cs @@ -1,5 +1,3 @@ -using CsvHelper; -using CsvHelper.Configuration; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.EntityFrameworkCore; @@ -7,9 +5,7 @@ using MoneyMap.Data; using MoneyMap.Models; using MoneyMap.Models.Import; using MoneyMap.Services; -using System.Globalization; using System.Text.Json; -using System.Text.RegularExpressions; namespace MoneyMap.Pages { @@ -216,400 +212,5 @@ namespace MoneyMap.Pages } return true; } - - public record ImportResult(int Total, int Inserted, int Skipped, string? Last4FromFile); - public enum PaymentSelectMode { Auto, Card, Account } } - - // ===== Service Layer ===== - - public interface ITransactionImporter - { - Task PreviewAsync(Stream csvStream, ImportContext context); - Task ImportAsync(List transactions); - } - - public class TransactionImporter : ITransactionImporter - { - private readonly MoneyMapContext _db; - private readonly ICardResolver _cardResolver; - - public TransactionImporter(MoneyMapContext db, ICardResolver cardResolver) - { - _db = db; - _cardResolver = cardResolver; - } - - public async Task PreviewAsync(Stream csvStream, ImportContext context) - { - var previewItems = new List(); - var addedInThisBatch = new HashSet(); - - // First pass: read CSV to get date range and all transactions - var csvTransactions = new List<(TransactionCsvRow Row, Transaction Transaction, TransactionKey Key)>(); - DateTime? minDate = null; - DateTime? maxDate = null; - - using (var reader = new StreamReader(csvStream)) - using (var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture) - { - HasHeaderRecord = true, - HeaderValidated = null, - MissingFieldFound = null - })) - { - csv.Read(); - csv.ReadHeader(); - var hasCategory = csv.HeaderRecord?.Any(h => h.Equals("Category", StringComparison.OrdinalIgnoreCase)) ?? false; - csv.Context.RegisterClassMap(new TransactionCsvRowMap(hasCategory)); - - while (csv.Read()) - { - var row = csv.GetRecord(); - - var paymentResolution = await _cardResolver.ResolvePaymentAsync(row.Memo, context); - if (!paymentResolution.IsSuccess) - return PreviewOperationResult.Failure(paymentResolution.ErrorMessage!); - - var transaction = MapToTransaction(row, paymentResolution); - var key = new TransactionKey(transaction); - - csvTransactions.Add((row, transaction, key)); - - // Track date range - if (minDate == null || transaction.Date < minDate) minDate = transaction.Date; - if (maxDate == null || transaction.Date > maxDate) maxDate = transaction.Date; - } - } - - // Load existing transactions within the date range for fast duplicate checking - HashSet existingTransactions; - if (minDate.HasValue && maxDate.HasValue) - { - // Add a buffer of 1 day on each side to catch any edge cases - var startDate = minDate.Value.AddDays(-1); - var endDate = maxDate.Value.AddDays(1); - - existingTransactions = await _db.Transactions - .Where(t => t.Date >= startDate && t.Date <= endDate) - .Select(t => new TransactionKey(t.Date, t.Amount, t.Name, t.Memo, t.AccountId, t.CardId)) - .ToHashSetAsync(); - } - else - { - existingTransactions = new HashSet(); - } - - // Second pass: check for duplicates and build preview - foreach (var (row, transaction, key) in csvTransactions) - { - // Fast in-memory duplicate checking - bool isDuplicate = addedInThisBatch.Contains(key) || existingTransactions.Contains(key); - - previewItems.Add(new TransactionPreview - { - Transaction = transaction, - IsDuplicate = isDuplicate, - PaymentMethodLabel = GetPaymentLabel(transaction, context) - }); - - addedInThisBatch.Add(key); - } - - // Order by date descending (newest first) - var orderedPreview = previewItems.OrderByDescending(p => p.Transaction.Date).ToList(); - - return PreviewOperationResult.Success(orderedPreview); - } - - 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( - transactions.Count, - inserted, - skipped, - null - ); - - return ImportOperationResult.Success(result); - } - - private static Transaction MapToTransaction(TransactionCsvRow row, PaymentResolutionResult paymentResolution) - { - return new Transaction - { - Date = row.Date, - TransactionType = row.Transaction?.Trim() ?? "", - Name = row.Name?.Trim() ?? "", - Memo = row.Memo?.Trim() ?? "", - Amount = row.Amount, - Category = (row.Category ?? "").Trim(), - 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 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 == UploadModel.PaymentSelectMode.Card) - return ResolveCard(context); - - if (context.PaymentMode == UploadModel.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 - } - } - - // ===== Helper Classes ===== - - 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; - } - } - - // ===== Data Transfer Objects ===== - - 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.AccountId, txn.CardId) { } - } - - public class ImportContext - { - 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; } - } - - public class ImportStats - { - public int Total { get; set; } - public int Inserted { get; set; } - public int Skipped { get; set; } - } - - 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; } - - // 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 }; - - // 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 }; - } - - public class ImportOperationResult - { - public bool IsSuccess { get; init; } - public UploadModel.ImportResult? Data { get; init; } - public string? ErrorMessage { get; init; } - - public static ImportOperationResult Success(UploadModel.ImportResult data) => - new() { IsSuccess = true, Data = data }; - - 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 +} diff --git a/MoneyMap/Services/CardResolver.cs b/MoneyMap/Services/CardResolver.cs new file mode 100644 index 0000000..d2ac36a --- /dev/null +++ b/MoneyMap/Services/CardResolver.cs @@ -0,0 +1,158 @@ +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; + } + } +} diff --git a/MoneyMap/Services/TransactionImporter.cs b/MoneyMap/Services/TransactionImporter.cs new file mode 100644 index 0000000..dbee359 --- /dev/null +++ b/MoneyMap/Services/TransactionImporter.cs @@ -0,0 +1,167 @@ +using CsvHelper; +using CsvHelper.Configuration; +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; +using MoneyMap.Models; +using MoneyMap.Models.Import; +using System.Globalization; + +namespace MoneyMap.Services +{ + /// + /// Service for importing transactions from CSV files. + /// + public interface ITransactionImporter + { + Task PreviewAsync(Stream csvStream, ImportContext context); + Task ImportAsync(List transactions); + } + + public class TransactionImporter : ITransactionImporter + { + private readonly MoneyMapContext _db; + private readonly ICardResolver _cardResolver; + + public TransactionImporter(MoneyMapContext db, ICardResolver cardResolver) + { + _db = db; + _cardResolver = cardResolver; + } + + public async Task PreviewAsync(Stream csvStream, ImportContext context) + { + var previewItems = new List(); + var addedInThisBatch = new HashSet(); + + // First pass: read CSV to get date range and all transactions + var csvTransactions = new List<(TransactionCsvRow Row, Transaction Transaction, TransactionKey Key)>(); + DateTime? minDate = null; + DateTime? maxDate = null; + + using (var reader = new StreamReader(csvStream)) + using (var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture) + { + HasHeaderRecord = true, + HeaderValidated = null, + MissingFieldFound = null + })) + { + csv.Read(); + csv.ReadHeader(); + var hasCategory = csv.HeaderRecord?.Any(h => h.Equals("Category", StringComparison.OrdinalIgnoreCase)) ?? false; + csv.Context.RegisterClassMap(new TransactionCsvRowMap(hasCategory)); + + while (csv.Read()) + { + var row = csv.GetRecord(); + + var paymentResolution = await _cardResolver.ResolvePaymentAsync(row.Memo, context); + if (!paymentResolution.IsSuccess) + return PreviewOperationResult.Failure(paymentResolution.ErrorMessage!); + + var transaction = MapToTransaction(row, paymentResolution); + var key = new TransactionKey(transaction); + + csvTransactions.Add((row, transaction, key)); + + // Track date range + if (minDate == null || transaction.Date < minDate) minDate = transaction.Date; + if (maxDate == null || transaction.Date > maxDate) maxDate = transaction.Date; + } + } + + // Load existing transactions within the date range for fast duplicate checking + HashSet existingTransactions; + if (minDate.HasValue && maxDate.HasValue) + { + // Add a buffer of 1 day on each side to catch any edge cases + var startDate = minDate.Value.AddDays(-1); + var endDate = maxDate.Value.AddDays(1); + + existingTransactions = await _db.Transactions + .Where(t => t.Date >= startDate && t.Date <= endDate) + .Select(t => new TransactionKey(t.Date, t.Amount, t.Name, t.Memo, t.AccountId, t.CardId)) + .ToHashSetAsync(); + } + else + { + existingTransactions = new HashSet(); + } + + // Second pass: check for duplicates and build preview + foreach (var (row, transaction, key) in csvTransactions) + { + // Fast in-memory duplicate checking + bool isDuplicate = addedInThisBatch.Contains(key) || existingTransactions.Contains(key); + + previewItems.Add(new TransactionPreview + { + Transaction = transaction, + IsDuplicate = isDuplicate, + PaymentMethodLabel = GetPaymentLabel(transaction, context) + }); + + addedInThisBatch.Add(key); + } + + // Order by date descending (newest first) + var orderedPreview = previewItems.OrderByDescending(p => p.Transaction.Date).ToList(); + + return PreviewOperationResult.Success(orderedPreview); + } + + 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 ImportResult( + transactions.Count, + inserted, + skipped, + null + ); + + return ImportOperationResult.Success(result); + } + + private static Transaction MapToTransaction(TransactionCsvRow row, PaymentResolutionResult paymentResolution) + { + return new Transaction + { + Date = row.Date, + TransactionType = row.Transaction?.Trim() ?? "", + Name = row.Name?.Trim() ?? "", + Memo = row.Memo?.Trim() ?? "", + Amount = row.Amount, + Category = (row.Category ?? "").Trim(), + Last4 = paymentResolution.Last4, + CardId = paymentResolution.CardId, + AccountId = paymentResolution.AccountId!.Value + }; + } + + 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; + } + } +}