Improve card auto-mapping by prioritizing memo over filename
Changed the card resolution logic to check transaction memos first before falling back to the CSV filename. This enables per-transaction card detection when account CSVs contain card identifiers in memos (e.g., "usbank.com.2765"). Also prioritize card matches over account matches in TryResolveByLast4 to ensure cards are correctly identified even when they share the same last4 digits as their linked account. This fixes the issue where card dropdowns showed "none / direct debit" even when card information was present in transaction memos. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Text.Json;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using CsvHelper;
|
using CsvHelper;
|
||||||
using CsvHelper.Configuration;
|
using CsvHelper.Configuration;
|
||||||
@@ -11,6 +12,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using MoneyMap.Data;
|
using MoneyMap.Data;
|
||||||
using MoneyMap.Models;
|
using MoneyMap.Models;
|
||||||
using MoneyMap.Models.Import;
|
using MoneyMap.Models.Import;
|
||||||
|
using MoneyMap.Services;
|
||||||
|
|
||||||
namespace MoneyMap.Pages
|
namespace MoneyMap.Pages
|
||||||
{
|
{
|
||||||
@@ -18,23 +20,39 @@ namespace MoneyMap.Pages
|
|||||||
{
|
{
|
||||||
private readonly MoneyMapContext _db;
|
private readonly MoneyMapContext _db;
|
||||||
private readonly ITransactionImporter _importer;
|
private readonly ITransactionImporter _importer;
|
||||||
|
private readonly ITransactionCategorizer _categorizer;
|
||||||
|
|
||||||
public UploadModel(MoneyMapContext db, ITransactionImporter importer)
|
public UploadModel(MoneyMapContext db, ITransactionImporter importer, ITransactionCategorizer categorizer)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_importer = importer;
|
_importer = importer;
|
||||||
|
_categorizer = categorizer;
|
||||||
}
|
}
|
||||||
|
|
||||||
[BindProperty] public IFormFile? Csv { get; set; }
|
[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? SelectedCardId { get; set; }
|
||||||
|
[BindProperty] public int? SelectedAccountId { get; set; }
|
||||||
|
|
||||||
public List<Card> Cards { get; set; } = new();
|
public List<Card> Cards { get; set; } = new();
|
||||||
|
public List<Account> Accounts { get; set; } = new();
|
||||||
public ImportResult? Result { get; set; }
|
public ImportResult? Result { get; set; }
|
||||||
|
public List<TransactionPreview> PreviewTransactions { get; set; } = new();
|
||||||
|
|
||||||
|
private const string PreviewSessionKey = "TransactionPreview";
|
||||||
|
|
||||||
public async Task OnGetAsync()
|
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<IActionResult> OnPostAsync()
|
public async Task<IActionResult> OnPostAsync()
|
||||||
@@ -45,17 +63,135 @@ namespace MoneyMap.Pages
|
|||||||
return Page();
|
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
|
var importContext = new ImportContext
|
||||||
{
|
{
|
||||||
CardMode = CardMode,
|
PaymentMode = PaymentMode,
|
||||||
SelectedCardId = SelectedCardId,
|
SelectedCardId = SelectedCardId,
|
||||||
|
SelectedAccountId = SelectedAccountId,
|
||||||
AvailableCards = Cards,
|
AvailableCards = Cards,
|
||||||
|
AvailableAccounts = Accounts,
|
||||||
FileName = Csv!.FileName
|
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<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)
|
if (!result.IsSuccess)
|
||||||
{
|
{
|
||||||
@@ -65,9 +201,22 @@ namespace MoneyMap.Pages
|
|||||||
}
|
}
|
||||||
|
|
||||||
Result = result.Data;
|
Result = result.Data;
|
||||||
|
HttpContext.Session.Remove(PreviewSessionKey); // Clear preview data
|
||||||
|
await OnGetAsync();
|
||||||
return Page();
|
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()
|
private bool ValidateInput()
|
||||||
{
|
{
|
||||||
if (Csv is null || Csv.Length == 0)
|
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 record ImportResult(int Total, int Inserted, int Skipped, string? Last4FromFile);
|
||||||
public enum CardSelectMode { Auto, Manual }
|
public enum PaymentSelectMode { Auto, Card, Account }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Service Layer =====
|
// ===== Service Layer =====
|
||||||
|
|
||||||
public interface ITransactionImporter
|
public interface ITransactionImporter
|
||||||
{
|
{
|
||||||
Task<ImportOperationResult> ImportAsync(Stream csvStream, ImportContext context);
|
Task<PreviewOperationResult> PreviewAsync(Stream csvStream, ImportContext context);
|
||||||
|
Task<ImportOperationResult> ImportAsync(List<Transaction> transactions);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class TransactionImporter : ITransactionImporter
|
public class TransactionImporter : ITransactionImporter
|
||||||
@@ -100,9 +250,9 @@ namespace MoneyMap.Pages
|
|||||||
_cardResolver = cardResolver;
|
_cardResolver = cardResolver;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ImportOperationResult> ImportAsync(Stream csvStream, ImportContext context)
|
public async Task<PreviewOperationResult> PreviewAsync(Stream csvStream, ImportContext context)
|
||||||
{
|
{
|
||||||
var stats = new ImportStats();
|
var previewItems = new List<TransactionPreview>();
|
||||||
var addedInThisBatch = new HashSet<TransactionKey>();
|
var addedInThisBatch = new HashSet<TransactionKey>();
|
||||||
|
|
||||||
using var reader = new StreamReader(csvStream);
|
using var reader = new StreamReader(csvStream);
|
||||||
@@ -121,34 +271,47 @@ namespace MoneyMap.Pages
|
|||||||
while (csv.Read())
|
while (csv.Read())
|
||||||
{
|
{
|
||||||
var row = csv.GetRecord<TransactionCsvRow>();
|
var row = csv.GetRecord<TransactionCsvRow>();
|
||||||
stats.Total++;
|
|
||||||
|
|
||||||
var cardResolution = await _cardResolver.ResolveCardAsync(row.Memo, context);
|
var paymentResolution = await _cardResolver.ResolvePaymentAsync(row.Memo, context);
|
||||||
if (!cardResolution.IsSuccess)
|
if (!paymentResolution.IsSuccess)
|
||||||
return ImportOperationResult.Failure(cardResolution.ErrorMessage!);
|
return PreviewOperationResult.Failure(paymentResolution.ErrorMessage!);
|
||||||
|
|
||||||
var transaction = MapToTransaction(row, cardResolution.CardId, cardResolution.Last4!);
|
var transaction = MapToTransaction(row, paymentResolution);
|
||||||
var key = new TransactionKey(transaction);
|
var key = new TransactionKey(transaction);
|
||||||
|
|
||||||
// Check both database AND current batch for duplicates
|
bool isDuplicate = addedInThisBatch.Contains(key) || await IsDuplicate(transaction);
|
||||||
if (addedInThisBatch.Contains(key) || await IsDuplicate(transaction))
|
|
||||||
{
|
previewItems.Add(new TransactionPreview
|
||||||
stats.Skipped++;
|
{
|
||||||
continue;
|
Transaction = transaction,
|
||||||
}
|
IsDuplicate = isDuplicate,
|
||||||
|
PaymentMethodLabel = GetPaymentLabel(transaction, context)
|
||||||
|
});
|
||||||
|
|
||||||
_db.Transactions.Add(transaction);
|
|
||||||
addedInThisBatch.Add(key);
|
addedInThisBatch.Add(key);
|
||||||
stats.Inserted++;
|
}
|
||||||
|
|
||||||
|
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();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
var result = new UploadModel.ImportResult(
|
var result = new UploadModel.ImportResult(
|
||||||
stats.Total,
|
transactions.Count,
|
||||||
stats.Inserted,
|
inserted,
|
||||||
stats.Skipped,
|
skipped,
|
||||||
CardIdentifierExtractor.FromFileName(context.FileName)
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
return ImportOperationResult.Success(result);
|
return ImportOperationResult.Success(result);
|
||||||
@@ -165,7 +328,7 @@ namespace MoneyMap.Pages
|
|||||||
t.AccountId == txn.AccountId);
|
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
|
return new Transaction
|
||||||
{
|
{
|
||||||
@@ -175,17 +338,33 @@ namespace MoneyMap.Pages
|
|||||||
Memo = row.Memo?.Trim() ?? "",
|
Memo = row.Memo?.Trim() ?? "",
|
||||||
Amount = row.Amount,
|
Amount = row.Amount,
|
||||||
Category = (row.Category ?? "").Trim(),
|
Category = (row.Category ?? "").Trim(),
|
||||||
Last4 = last4,
|
Last4 = paymentResolution.Last4,
|
||||||
CardId = cardId
|
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 =====
|
// ===== Card Resolution =====
|
||||||
|
|
||||||
public interface ICardResolver
|
public interface ICardResolver
|
||||||
{
|
{
|
||||||
Task<CardResolutionResult> ResolveCardAsync(string? memo, ImportContext context);
|
Task<PaymentResolutionResult> ResolvePaymentAsync(string? memo, ImportContext context);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class CardResolver : ICardResolver
|
public class CardResolver : ICardResolver
|
||||||
@@ -197,54 +376,100 @@ namespace MoneyMap.Pages
|
|||||||
_db = db;
|
_db = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<CardResolutionResult> ResolveCardAsync(string? memo, ImportContext context)
|
public async Task<PaymentResolutionResult> ResolvePaymentAsync(string? memo, ImportContext context)
|
||||||
{
|
{
|
||||||
if (context.CardMode == UploadModel.CardSelectMode.Manual)
|
if (context.PaymentMode == UploadModel.PaymentSelectMode.Card)
|
||||||
return ResolveManually(context);
|
return ResolveCard(context);
|
||||||
|
|
||||||
|
if (context.PaymentMode == UploadModel.PaymentSelectMode.Account)
|
||||||
|
return ResolveAccount(context);
|
||||||
|
|
||||||
return await ResolveAutomaticallyAsync(memo, context);
|
return await ResolveAutomaticallyAsync(memo, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
private CardResolutionResult ResolveManually(ImportContext context)
|
private PaymentResolutionResult ResolveCard(ImportContext context)
|
||||||
{
|
{
|
||||||
if (context.SelectedCardId is null)
|
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);
|
var card = context.AvailableCards.FirstOrDefault(c => c.Id == context.SelectedCardId);
|
||||||
if (card is null)
|
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<CardResolutionResult> ResolveAutomaticallyAsync(string? memo, ImportContext context)
|
private PaymentResolutionResult ResolveAccount(ImportContext context)
|
||||||
{
|
{
|
||||||
var last4 = CardIdentifierExtractor.FromMemo(memo)
|
if (context.SelectedAccountId is null)
|
||||||
?? CardIdentifierExtractor.FromFileName(context.FileName);
|
return PaymentResolutionResult.Failure("Pick an account or switch to Auto/Card mode.");
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(last4))
|
var account = context.AvailableAccounts.FirstOrDefault(a => a.Id == context.SelectedAccountId);
|
||||||
return CardResolutionResult.Failure(
|
if (account is null)
|
||||||
"Couldn't determine card from memo or file name. Choose a card manually.");
|
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<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))
|
||||||
{
|
{
|
||||||
// Auto-create the card
|
var result = TryResolveByLast4(last4FromMemo, context);
|
||||||
card = new Card
|
if (result != null) return result;
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
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;
|
private const int Last4Length = 4;
|
||||||
|
|
||||||
public static string? FromMemo(string? memo)
|
public static string? FromMemo(string? memo)
|
||||||
@@ -260,12 +486,13 @@ namespace MoneyMap.Pages
|
|||||||
if (string.IsNullOrWhiteSpace(memo))
|
if (string.IsNullOrWhiteSpace(memo))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var match = MemoLast4Pattern.Match(memo);
|
// Try to find all matches and return the last one (most likely to be card/account number)
|
||||||
if (!match.Success)
|
var matches = MemoLast4Pattern.Matches(memo);
|
||||||
|
if (matches.Count == 0)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var digits = match.Groups[1].Value;
|
// Return the last match (typically the card number at the end)
|
||||||
return digits.Length >= Last4Length ? digits[^Last4Length..] : null;
|
return matches[^1].Groups[1].Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string? FromFileName(string fileName)
|
public static string? FromFileName(string fileName)
|
||||||
@@ -285,17 +512,19 @@ namespace MoneyMap.Pages
|
|||||||
|
|
||||||
// ===== Data Transfer Objects =====
|
// ===== 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)
|
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 class ImportContext
|
||||||
{
|
{
|
||||||
public required UploadModel.CardSelectMode CardMode { get; init; }
|
public required UploadModel.PaymentSelectMode PaymentMode { get; init; }
|
||||||
public int? SelectedCardId { get; init; }
|
public int? SelectedCardId { get; init; }
|
||||||
|
public int? SelectedAccountId { get; init; }
|
||||||
public required List<Card> AvailableCards { get; init; }
|
public required List<Card> AvailableCards { get; init; }
|
||||||
|
public required List<Account> AvailableAccounts { get; init; }
|
||||||
public required string FileName { get; init; }
|
public required string FileName { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,17 +535,23 @@ namespace MoneyMap.Pages
|
|||||||
public int Skipped { get; set; }
|
public int Skipped { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class CardResolutionResult
|
public class PaymentResolutionResult
|
||||||
{
|
{
|
||||||
public bool IsSuccess { get; init; }
|
public bool IsSuccess { get; init; }
|
||||||
public int? CardId { get; init; }
|
public int? CardId { get; init; }
|
||||||
|
public int? AccountId { get; init; } // Required for all transactions
|
||||||
public string? Last4 { get; init; }
|
public string? Last4 { get; init; }
|
||||||
public string? ErrorMessage { get; init; }
|
public string? ErrorMessage { get; init; }
|
||||||
|
|
||||||
public static CardResolutionResult Success(int? cardId, string last4) =>
|
// When a card is used: accountId is the account the card is linked to (or a default account)
|
||||||
new() { IsSuccess = true, CardId = cardId, Last4 = last4 };
|
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 };
|
new() { IsSuccess = false, ErrorMessage = error };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,4 +567,32 @@ namespace MoneyMap.Pages
|
|||||||
public static ImportOperationResult Failure(string error) =>
|
public static ImportOperationResult Failure(string error) =>
|
||||||
new() { IsSuccess = false, ErrorMessage = 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user