Files
MoneyMap/MoneyMap/Pages/Upload.cshtml.cs
AJ 86b7312dd6 Refactor: rename OpenAIReceiptParser to AIReceiptParser
Rename to reflect multi-provider support (OpenAI + Anthropic):
- Rename Services/OpenAIReceiptParser.cs to Services/AIReceiptParser.cs
- Update class name from OpenAIReceiptParser to AIReceiptParser
- Update DI registration in Program.cs

The parser now supports both OpenAI and Anthropic models, so the more generic name better reflects its capabilities.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-19 16:19:27 -04:00

632 lines
24 KiB
C#

using CsvHelper;
using CsvHelper.Configuration;
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;
using System.Globalization;
using System.Text.Json;
using System.Text.RegularExpressions;
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))
{
var categorizationResult = await _categorizer.CategorizeAsync(preview.Transaction.Name, preview.Transaction.Amount);
preview.Transaction.Category = categorizationResult.Category;
preview.Transaction.MerchantId = categorizationResult.MerchantId;
preview.SuggestedCategory = categorizationResult.Category;
}
}
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>();
// 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);
}
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
}
}