diff --git a/MoneyMap/Pages/Upload.cshtml.cs b/MoneyMap/Pages/Upload.cshtml.cs
index e87bc4b..33231d0 100644
--- a/MoneyMap/Pages/Upload.cshtml.cs
+++ b/MoneyMap/Pages/Upload.cshtml.cs
@@ -1,6 +1,7 @@
using System.Globalization;
using System.IO;
using System.Linq;
+using System.Text.Json;
using System.Text.RegularExpressions;
using CsvHelper;
using CsvHelper.Configuration;
@@ -11,6 +12,7 @@ using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Models;
using MoneyMap.Models.Import;
+using MoneyMap.Services;
namespace MoneyMap.Pages
{
@@ -18,23 +20,39 @@ namespace MoneyMap.Pages
{
private readonly MoneyMapContext _db;
private readonly ITransactionImporter _importer;
+ private readonly ITransactionCategorizer _categorizer;
- public UploadModel(MoneyMapContext db, ITransactionImporter importer)
+ public UploadModel(MoneyMapContext db, ITransactionImporter importer, ITransactionCategorizer categorizer)
{
_db = db;
_importer = importer;
+ _categorizer = categorizer;
}
[BindProperty] public IFormFile? Csv { get; set; }
- [BindProperty] public CardSelectMode CardMode { get; set; } = CardSelectMode.Auto;
+ [BindProperty] public PaymentSelectMode PaymentMode { get; set; } = PaymentSelectMode.Auto;
[BindProperty] public int? SelectedCardId { get; set; }
+ [BindProperty] public int? SelectedAccountId { get; set; }
public List Cards { get; set; } = new();
+ public List Accounts { get; set; } = new();
public ImportResult? Result { get; set; }
+ public List PreviewTransactions { get; set; } = new();
+
+ private const string PreviewSessionKey = "TransactionPreview";
public async Task OnGetAsync()
{
- Cards = await _db.Cards.OrderBy(c => c.Owner).ThenBy(c => c.Last4).ToListAsync();
+ 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 OnPostAsync()
@@ -45,17 +63,135 @@ namespace MoneyMap.Pages
return Page();
}
- Cards = await _db.Cards.OrderBy(c => c.Owner).ThenBy(c => c.Last4).ToListAsync();
+ 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
{
- CardMode = CardMode,
+ PaymentMode = PaymentMode,
SelectedCardId = SelectedCardId,
+ SelectedAccountId = SelectedAccountId,
AvailableCards = Cards,
+ AvailableAccounts = Accounts,
FileName = Csv!.FileName
};
- var result = await _importer.ImportAsync(Csv!.OpenReadStream(), importContext);
+ 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 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>(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()
+ : selectedIndices.Split(',').Select(int.Parse).ToList();
+
+ // Parse payment data
+ var paymentSelections = string.IsNullOrWhiteSpace(paymentData)
+ ? new Dictionary()
+ : JsonSerializer.Deserialize>(paymentData) ?? new();
+
+ // Filter transactions based on user selection and update payment methods
+ var transactionsToImport = new List();
+ 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)
{
@@ -65,9 +201,22 @@ namespace MoneyMap.Pages
}
Result = result.Data;
+ HttpContext.Session.Remove(PreviewSessionKey); // Clear preview data
+ await OnGetAsync();
return Page();
}
+ private async Task 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)
@@ -79,14 +228,15 @@ namespace MoneyMap.Pages
}
public record ImportResult(int Total, int Inserted, int Skipped, string? Last4FromFile);
- public enum CardSelectMode { Auto, Manual }
+ public enum PaymentSelectMode { Auto, Card, Account }
}
// ===== Service Layer =====
public interface ITransactionImporter
{
- Task ImportAsync(Stream csvStream, ImportContext context);
+ Task PreviewAsync(Stream csvStream, ImportContext context);
+ Task ImportAsync(List transactions);
}
public class TransactionImporter : ITransactionImporter
@@ -100,9 +250,9 @@ namespace MoneyMap.Pages
_cardResolver = cardResolver;
}
- public async Task ImportAsync(Stream csvStream, ImportContext context)
+ public async Task PreviewAsync(Stream csvStream, ImportContext context)
{
- var stats = new ImportStats();
+ var previewItems = new List();
var addedInThisBatch = new HashSet();
using var reader = new StreamReader(csvStream);
@@ -121,34 +271,47 @@ namespace MoneyMap.Pages
while (csv.Read())
{
var row = csv.GetRecord();
- stats.Total++;
- var cardResolution = await _cardResolver.ResolveCardAsync(row.Memo, context);
- if (!cardResolution.IsSuccess)
- return ImportOperationResult.Failure(cardResolution.ErrorMessage!);
+ var paymentResolution = await _cardResolver.ResolvePaymentAsync(row.Memo, context);
+ if (!paymentResolution.IsSuccess)
+ return PreviewOperationResult.Failure(paymentResolution.ErrorMessage!);
- var transaction = MapToTransaction(row, cardResolution.CardId, cardResolution.Last4!);
+ var transaction = MapToTransaction(row, paymentResolution);
var key = new TransactionKey(transaction);
- // Check both database AND current batch for duplicates
- if (addedInThisBatch.Contains(key) || await IsDuplicate(transaction))
- {
- stats.Skipped++;
- continue;
- }
+ bool isDuplicate = addedInThisBatch.Contains(key) || await IsDuplicate(transaction);
+
+ previewItems.Add(new TransactionPreview
+ {
+ Transaction = transaction,
+ IsDuplicate = isDuplicate,
+ PaymentMethodLabel = GetPaymentLabel(transaction, context)
+ });
- _db.Transactions.Add(transaction);
addedInThisBatch.Add(key);
- stats.Inserted++;
+ }
+
+ return PreviewOperationResult.Success(previewItems);
+ }
+
+ 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(
- stats.Total,
- stats.Inserted,
- stats.Skipped,
- CardIdentifierExtractor.FromFileName(context.FileName)
+ transactions.Count,
+ inserted,
+ skipped,
+ null
);
return ImportOperationResult.Success(result);
@@ -165,7 +328,7 @@ namespace MoneyMap.Pages
t.AccountId == txn.AccountId);
}
- private static Transaction MapToTransaction(TransactionCsvRow row, int? cardId, string last4)
+ private static Transaction MapToTransaction(TransactionCsvRow row, PaymentResolutionResult paymentResolution)
{
return new Transaction
{
@@ -175,17 +338,33 @@ namespace MoneyMap.Pages
Memo = row.Memo?.Trim() ?? "",
Amount = row.Amount,
Category = (row.Category ?? "").Trim(),
- Last4 = last4,
- CardId = cardId
+ 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 ResolveCardAsync(string? memo, ImportContext context);
+ Task ResolvePaymentAsync(string? memo, ImportContext context);
}
public class CardResolver : ICardResolver
@@ -197,54 +376,100 @@ namespace MoneyMap.Pages
_db = db;
}
- public async Task ResolveCardAsync(string? memo, ImportContext context)
+ public async Task ResolvePaymentAsync(string? memo, ImportContext context)
{
- if (context.CardMode == UploadModel.CardSelectMode.Manual)
- return ResolveManually(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 CardResolutionResult ResolveManually(ImportContext context)
+ private PaymentResolutionResult ResolveCard(ImportContext context)
{
if (context.SelectedCardId is null)
- return CardResolutionResult.Failure("Pick a card or switch to Auto.");
+ 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 CardResolutionResult.Failure("Selected card not found.");
+ return PaymentResolutionResult.Failure("Selected card not found.");
- return CardResolutionResult.Success(card.Id, card.Last4);
+ // 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 async Task ResolveAutomaticallyAsync(string? memo, ImportContext context)
+ private PaymentResolutionResult ResolveAccount(ImportContext context)
{
- var last4 = CardIdentifierExtractor.FromMemo(memo)
- ?? CardIdentifierExtractor.FromFileName(context.FileName);
+ if (context.SelectedAccountId is null)
+ return PaymentResolutionResult.Failure("Pick an account or switch to Auto/Card mode.");
- if (string.IsNullOrWhiteSpace(last4))
- return CardResolutionResult.Failure(
- "Couldn't determine card from memo or file name. Choose a card manually.");
+ var account = context.AvailableAccounts.FirstOrDefault(a => a.Id == context.SelectedAccountId);
+ if (account is null)
+ return PaymentResolutionResult.Failure("Selected account not found.");
- var card = context.AvailableCards.FirstOrDefault(c => c.Last4 == last4);
+ return PaymentResolutionResult.SuccessAccount(account.Id, account.Last4);
+ }
- if (card is null)
+ private async 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))
{
- // Auto-create the card
- card = new Card
- {
- Last4 = last4,
- Owner = "Unknown" // You can adjust this default
- };
-
- _db.Cards.Add(card);
- await _db.SaveChangesAsync();
-
- // Add to context so subsequent rows can use it
- context.AvailableCards.Add(card);
+ var result = TryResolveByLast4(last4FromMemo, context);
+ if (result != null) return result;
}
- return CardResolutionResult.Success(card.Id, card.Last4);
+ // 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 result;
+ }
+
+ // Nothing found - error
+ var searchedLast4 = last4FromMemo ?? last4FromFile;
+ if (string.IsNullOrWhiteSpace(searchedLast4))
+ {
+ return PaymentResolutionResult.Failure(
+ "Couldn't determine card or account from memo or file name. Choose an account manually.");
+ }
+
+ return 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
}
}
@@ -252,7 +477,8 @@ namespace MoneyMap.Pages
public static class CardIdentifierExtractor
{
- private static readonly Regex MemoLast4Pattern = new(@"\b(?:\.|\s)(\d{4,6})\b", RegexOptions.Compiled);
+ // 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)
@@ -260,12 +486,13 @@ namespace MoneyMap.Pages
if (string.IsNullOrWhiteSpace(memo))
return null;
- var match = MemoLast4Pattern.Match(memo);
- if (!match.Success)
+ // 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;
- var digits = match.Groups[1].Value;
- return digits.Length >= Last4Length ? digits[^Last4Length..] : null;
+ // Return the last match (typically the card number at the end)
+ return matches[^1].Groups[1].Value;
}
public static string? FromFileName(string fileName)
@@ -285,17 +512,19 @@ namespace MoneyMap.Pages
// ===== Data Transfer Objects =====
- public record TransactionKey(DateTime Date, decimal Amount, string Name, string Memo, int? CardId, int? AccountId)
+ 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.CardId, txn.AccountId) { }
+ : this(txn.Date, txn.Amount, txn.Name, txn.Memo, txn.AccountId, txn.CardId) { }
}
public class ImportContext
{
- public required UploadModel.CardSelectMode CardMode { get; init; }
+ 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; }
}
@@ -306,17 +535,23 @@ namespace MoneyMap.Pages
public int Skipped { get; set; }
}
- public class CardResolutionResult
+ 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; }
- public static CardResolutionResult Success(int? cardId, string last4) =>
- new() { IsSuccess = true, CardId = cardId, Last4 = last4 };
+ // 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 };
- public static CardResolutionResult Failure(string error) =>
+ // 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 };
}
@@ -332,4 +567,32 @@ namespace MoneyMap.Pages
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