Refactor: Extract import services from Upload page
Extract to separate files for better maintainability: - Models/Import/ImportContext.cs - Import context and PaymentSelectMode enum - Models/Import/ImportResults.cs - Import result DTOs and TransactionKey - Models/Import/PaymentResolutionResult.cs - Payment resolution DTO - Services/TransactionImporter.cs - CSV import logic - Services/CardResolver.cs - Payment method resolution Reduces Upload.cshtml.cs from 615 lines to 216 lines. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
28
MoneyMap/Models/Import/ImportContext.cs
Normal file
28
MoneyMap/Models/Import/ImportContext.cs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
namespace MoneyMap.Models.Import
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Context for transaction import operations, containing payment selection mode and available options.
|
||||||
|
/// </summary>
|
||||||
|
public class ImportContext
|
||||||
|
{
|
||||||
|
public required 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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Specifies how to determine the payment method for imported transactions.
|
||||||
|
/// </summary>
|
||||||
|
public enum PaymentSelectMode
|
||||||
|
{
|
||||||
|
/// <summary>Auto-detect from memo or filename.</summary>
|
||||||
|
Auto,
|
||||||
|
/// <summary>Use a specific card for all transactions.</summary>
|
||||||
|
Card,
|
||||||
|
/// <summary>Use a specific account for all transactions.</summary>
|
||||||
|
Account
|
||||||
|
}
|
||||||
|
}
|
||||||
69
MoneyMap/Models/Import/ImportResults.cs
Normal file
69
MoneyMap/Models/Import/ImportResults.cs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
namespace MoneyMap.Models.Import
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Result of an import operation, showing counts of processed transactions.
|
||||||
|
/// </summary>
|
||||||
|
public record ImportResult(int Total, int Inserted, int Skipped, string? Last4FromFile);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wrapper for import operation result with success/failure state.
|
||||||
|
/// </summary>
|
||||||
|
public class ImportOperationResult
|
||||||
|
{
|
||||||
|
public bool IsSuccess { get; init; }
|
||||||
|
public ImportResult? Data { get; init; }
|
||||||
|
public string? ErrorMessage { get; init; }
|
||||||
|
|
||||||
|
public static ImportOperationResult Success(ImportResult data) =>
|
||||||
|
new() { IsSuccess = true, Data = data };
|
||||||
|
|
||||||
|
public static ImportOperationResult Failure(string error) =>
|
||||||
|
new() { IsSuccess = false, ErrorMessage = error };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wrapper for preview operation result with success/failure state.
|
||||||
|
/// </summary>
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Preview of a transaction before import, with duplicate detection info.
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// User's selection for payment method during import confirmation.
|
||||||
|
/// </summary>
|
||||||
|
public class PaymentSelection
|
||||||
|
{
|
||||||
|
public int? AccountId { get; set; }
|
||||||
|
public int? CardId { get; set; }
|
||||||
|
public string? Category { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Key for detecting duplicate transactions.
|
||||||
|
/// </summary>
|
||||||
|
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) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
32
MoneyMap/Models/Import/PaymentResolutionResult.cs
Normal file
32
MoneyMap/Models/Import/PaymentResolutionResult.cs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
namespace MoneyMap.Models.Import
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Result of resolving a payment method (card or account) for a transaction.
|
||||||
|
/// </summary>
|
||||||
|
public class PaymentResolutionResult
|
||||||
|
{
|
||||||
|
public bool IsSuccess { get; init; }
|
||||||
|
public int? CardId { get; init; }
|
||||||
|
public int? AccountId { get; init; }
|
||||||
|
public string? Last4 { get; init; }
|
||||||
|
public string? ErrorMessage { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a successful result when a card is used.
|
||||||
|
/// </summary>
|
||||||
|
public static PaymentResolutionResult SuccessCard(int cardId, int accountId, string last4) =>
|
||||||
|
new() { IsSuccess = true, CardId = cardId, AccountId = accountId, Last4 = last4 };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a successful result when a direct account transaction (no card).
|
||||||
|
/// </summary>
|
||||||
|
public static PaymentResolutionResult SuccessAccount(int accountId, string last4) =>
|
||||||
|
new() { IsSuccess = true, AccountId = accountId, Last4 = last4 };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a failure result with error message.
|
||||||
|
/// </summary>
|
||||||
|
public static PaymentResolutionResult Failure(string error) =>
|
||||||
|
new() { IsSuccess = false, ErrorMessage = error };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
using CsvHelper;
|
|
||||||
using CsvHelper.Configuration;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -7,9 +5,7 @@ using MoneyMap.Data;
|
|||||||
using MoneyMap.Models;
|
using MoneyMap.Models;
|
||||||
using MoneyMap.Models.Import;
|
using MoneyMap.Models.Import;
|
||||||
using MoneyMap.Services;
|
using MoneyMap.Services;
|
||||||
using System.Globalization;
|
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
|
|
||||||
namespace MoneyMap.Pages
|
namespace MoneyMap.Pages
|
||||||
{
|
{
|
||||||
@@ -216,400 +212,5 @@ namespace MoneyMap.Pages
|
|||||||
}
|
}
|
||||||
return true;
|
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 UploadModel.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 // 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
158
MoneyMap/Services/CardResolver.cs
Normal file
158
MoneyMap/Services/CardResolver.cs
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
using MoneyMap.Data;
|
||||||
|
using MoneyMap.Models.Import;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace MoneyMap.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Service for resolving payment methods (cards/accounts) for transactions.
|
||||||
|
/// </summary>
|
||||||
|
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 == PaymentSelectMode.Card)
|
||||||
|
return ResolveCard(context);
|
||||||
|
|
||||||
|
if (context.PaymentMode == 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Utility class for extracting card/account identifiers from memos and filenames.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
167
MoneyMap/Services/TransactionImporter.cs
Normal file
167
MoneyMap/Services/TransactionImporter.cs
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user