3b01efd8a6
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
168 lines
6.3 KiB
C#
168 lines
6.3 KiB
C#
using CsvHelper;
|
|
using CsvHelper.Configuration;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using MoneyMap.Data;
|
|
using MoneyMap.Models;
|
|
using MoneyMap.Models.Import;
|
|
using System.Globalization;
|
|
|
|
namespace MoneyMap.Services
|
|
{
|
|
/// <summary>
|
|
/// Service for importing transactions from CSV files.
|
|
/// </summary>
|
|
public interface ITransactionImporter
|
|
{
|
|
Task<PreviewOperationResult> PreviewAsync(Stream csvStream, ImportContext context);
|
|
Task<ImportOperationResult> ImportAsync(List<Transaction> 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<PreviewOperationResult> PreviewAsync(Stream csvStream, ImportContext context)
|
|
{
|
|
var previewItems = new List<TransactionPreview>();
|
|
var addedInThisBatch = new HashSet<TransactionKey>();
|
|
|
|
// 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<TransactionCsvRow>();
|
|
|
|
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<TransactionKey> 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<TransactionKey>();
|
|
}
|
|
|
|
// 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<ImportOperationResult> ImportAsync(List<Transaction> 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;
|
|
}
|
|
}
|
|
}
|