2ceb3a7b57
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>
217 lines
8.1 KiB
C#
217 lines
8.1 KiB
C#
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.Text.Json;
|
|
|
|
namespace MoneyMap.Pages
|
|
{
|
|
public class UploadModel : PageModel
|
|
{
|
|
private readonly MoneyMapContext _db;
|
|
private readonly ITransactionImporter _importer;
|
|
private readonly ITransactionCategorizer _categorizer;
|
|
private readonly ITransactionService _transactionService;
|
|
|
|
public UploadModel(MoneyMapContext db, ITransactionImporter importer, ITransactionCategorizer categorizer, ITransactionService transactionService)
|
|
{
|
|
_db = db;
|
|
_importer = importer;
|
|
_categorizer = categorizer;
|
|
_transactionService = transactionService;
|
|
}
|
|
|
|
[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 _transactionService.IsDuplicateAsync(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 bool ValidateInput()
|
|
{
|
|
if (Csv is null || Csv.Length == 0)
|
|
{
|
|
ModelState.AddModelError(nameof(Csv), "Please choose a CSV file.");
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
}
|