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