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;
+ }
+ }
+}