Files
MoneyMap/MoneyMap/Pages/Upload.cshtml.cs
AJ 3d6b47d537 Optimize transaction preview duplicate checking for large imports
Improve performance when uploading thousands of transactions by loading all existing transactions into memory once for duplicate detection, rather than running individual database queries for each transaction. This reduces database calls from O(n) to O(1) for n transactions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 22:17:37 -04:00

604 lines
23 KiB
C#

using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.RegularExpressions;
using CsvHelper;
using CsvHelper.Configuration;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Models;
using MoneyMap.Models.Import;
using MoneyMap.Services;
namespace MoneyMap.Pages
{
public class UploadModel : PageModel
{
private readonly MoneyMapContext _db;
private readonly ITransactionImporter _importer;
private readonly ITransactionCategorizer _categorizer;
public UploadModel(MoneyMapContext db, ITransactionImporter importer, ITransactionCategorizer categorizer)
{
_db = db;
_importer = importer;
_categorizer = categorizer;
}
[BindProperty] public IFormFile? Csv { get; set; }
[BindProperty] public PaymentSelectMode PaymentMode { get; set; } = PaymentSelectMode.Auto;
[BindProperty] public int? SelectedCardId { get; set; }
[BindProperty] public int? SelectedAccountId { get; set; }
public List<Card> Cards { get; set; } = new();
public List<Account> Accounts { get; set; } = new();
public ImportResult? Result { get; set; }
public List<TransactionPreview> PreviewTransactions { get; set; } = new();
private const string PreviewSessionKey = "TransactionPreview";
public async Task OnGetAsync()
{
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<IActionResult> OnPostAsync()
{
if (!ValidateInput())
{
await OnGetAsync();
return Page();
}
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
{
PaymentMode = PaymentMode,
SelectedCardId = SelectedCardId,
SelectedAccountId = SelectedAccountId,
AvailableCards = Cards,
AvailableAccounts = Accounts,
FileName = Csv!.FileName
};
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<IActionResult> 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<List<Transaction>>(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<int>()
: selectedIndices.Split(',').Select(int.Parse).ToList();
// Parse payment data
var paymentSelections = string.IsNullOrWhiteSpace(paymentData)
? new Dictionary<int, PaymentSelection>()
: JsonSerializer.Deserialize<Dictionary<int, PaymentSelection>>(paymentData) ?? new();
// Filter transactions based on user selection and update payment methods
var transactionsToImport = new List<Transaction>();
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)
{
ModelState.AddModelError(string.Empty, result.ErrorMessage!);
await OnGetAsync();
return Page();
}
Result = result.Data;
HttpContext.Session.Remove(PreviewSessionKey); // Clear preview data
await OnGetAsync();
return Page();
}
private async Task<bool> 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)
{
ModelState.AddModelError(nameof(Csv), "Please choose a CSV file.");
return false;
}
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<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>();
// Load all existing transactions into memory for fast duplicate checking
var existingTransactions = await _db.Transactions
.Select(t => new TransactionKey(t.Date, t.Amount, t.Name, t.Memo, t.AccountId, t.CardId))
.ToHashSetAsync();
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);
// 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);
}
return PreviewOperationResult.Success(previewItems);
}
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 UploadModel.ImportResult(
transactions.Count,
inserted,
skipped,
null
);
return ImportOperationResult.Success(result);
}
private async Task<bool> 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 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<PaymentResolutionResult> ResolvePaymentAsync(string? memo, ImportContext context);
}
public class CardResolver : ICardResolver
{
private readonly MoneyMapContext _db;
public CardResolver(MoneyMapContext db)
{
_db = db;
}
public async Task<PaymentResolutionResult> 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<PaymentResolutionResult> 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<Card> AvailableCards { get; init; }
public required List<Account> 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<TransactionPreview>? Data { get; init; }
public string? ErrorMessage { get; init; }
public static PreviewOperationResult Success(List<TransactionPreview> 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
}
}