diff --git a/MoneyMap.Tests/Controllers/ImportsControllerTests.cs b/MoneyMap.Tests/Controllers/ImportsControllerTests.cs new file mode 100644 index 0000000..3674469 --- /dev/null +++ b/MoneyMap.Tests/Controllers/ImportsControllerTests.cs @@ -0,0 +1,150 @@ +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; +using MoneyMap.Controllers; +using MoneyMap.Models; +using MoneyMap.Services; +using MoneyMap.Tests.TestHelpers; + +namespace MoneyMap.Tests.Controllers; + +public class ImportsControllerTests +{ + [Fact] + public async Task ImportCsv_AutoMode_SkipsDuplicates_AssignsCardAndCategory() + { + using var context = DbContextHelper.CreateInMemoryContext(); + SeedAutoImportData(context); + + var controller = CreateController(context); + var csv = CreateCsvFormFile( + "transactions.csv", + "Date,Transaction,Name,Memo,Amount\n" + + "2025-01-15,DEBIT,AMAZON MKTPL,existing usbank.com.1234,-50.00\n" + + "2025-01-16,DEBIT,AMAZON MKTPL,new purchase usbank.com.1234,-25.00\n"); + + var result = await controller.ImportCsv(new CsvImportRequest + { + Csv = csv, + PaymentMode = MoneyMap.Models.Import.PaymentSelectMode.Auto + }); + + var ok = Assert.IsType(result); + using var json = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value)); + + Assert.Equal(2, json.RootElement.GetProperty("TotalRows").GetInt32()); + Assert.Equal(1, json.RootElement.GetProperty("Inserted").GetInt32()); + Assert.Equal(1, json.RootElement.GetProperty("SkippedDuplicates").GetInt32()); + Assert.Equal(2, json.RootElement.GetProperty("AutoCategorized").GetInt32()); + + Assert.Equal(2, context.Transactions.Count()); + + var imported = context.Transactions.Single(t => t.Memo == "new purchase usbank.com.1234"); + Assert.Equal(1, imported.AccountId); + Assert.Equal(1, imported.CardId); + Assert.Equal("Shopping", imported.Category); + } + + [Fact] + public async Task ImportCsv_AccountMode_ImportsWhenAutoDetectionIsUnavailable() + { + using var context = DbContextHelper.CreateInMemoryContext(); + context.Accounts.Add(new Account + { + Id = 7, + Institution = "Manual Bank", + Last4 = "4321", + Owner = "Test Owner", + AccountType = AccountType.Checking + }); + await context.SaveChangesAsync(); + + var controller = CreateController(context); + var csv = CreateCsvFormFile( + "manual-import.csv", + "Date,Transaction,Name,Memo,Amount\n" + + "2025-02-01,DEBIT,LOCAL STORE,no card info here,-10.00\n"); + + var result = await controller.ImportCsv(new CsvImportRequest + { + Csv = csv, + PaymentMode = MoneyMap.Models.Import.PaymentSelectMode.Account, + SelectedAccountId = 7 + }); + + var ok = Assert.IsType(result); + using var json = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value)); + + Assert.Equal(1, json.RootElement.GetProperty("Inserted").GetInt32()); + Assert.Equal(0, json.RootElement.GetProperty("SkippedDuplicates").GetInt32()); + + var imported = Assert.Single(context.Transactions); + Assert.Equal(7, imported.AccountId); + Assert.Null(imported.CardId); + } + + private static ImportsController CreateController(MoneyMap.Data.MoneyMapContext context) + { + var cardResolver = new CardResolver(context); + var importer = new TransactionImporter(context, cardResolver); + var categorizer = new TransactionCategorizer(context, new MemoryCache(new MemoryCacheOptions())); + + return new ImportsController(context, importer, categorizer); + } + + private static void SeedAutoImportData(MoneyMap.Data.MoneyMapContext context) + { + context.Accounts.Add(new Account + { + Id = 1, + Institution = "Test Bank", + Last4 = "1234", + Owner = "Test Owner", + AccountType = AccountType.Checking + }); + + context.Cards.Add(new Card + { + Id = 1, + Issuer = "Visa", + Last4 = "1234", + Owner = "Test Owner", + AccountId = 1 + }); + + context.CategoryMappings.Add(new CategoryMapping + { + Category = "Shopping", + Pattern = "AMAZON", + Priority = 10 + }); + + context.Transactions.Add(new Transaction + { + Date = new DateTime(2025, 1, 15), + TransactionType = "DEBIT", + Name = "AMAZON MKTPL", + Memo = "existing usbank.com.1234", + Amount = -50.00m, + Category = "Shopping", + AccountId = 1, + CardId = 1, + Last4 = "1234" + }); + + context.SaveChanges(); + } + + private static IFormFile CreateCsvFormFile(string fileName, string content) + { + var bytes = Encoding.UTF8.GetBytes(content); + var stream = new MemoryStream(bytes); + return new FormFile(stream, 0, bytes.Length, "csv", fileName) + { + Headers = new HeaderDictionary(), + ContentType = "text/csv" + }; + } +} diff --git a/MoneyMap/Controllers/ImportsController.cs b/MoneyMap/Controllers/ImportsController.cs new file mode 100644 index 0000000..90b5814 --- /dev/null +++ b/MoneyMap/Controllers/ImportsController.cs @@ -0,0 +1,127 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; +using MoneyMap.Models.Import; +using MoneyMap.Services; + +namespace MoneyMap.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class ImportsController : ControllerBase +{ + private readonly MoneyMapContext _db; + private readonly ITransactionImporter _importer; + private readonly ITransactionCategorizer _categorizer; + + public ImportsController( + MoneyMapContext db, + ITransactionImporter importer, + ITransactionCategorizer categorizer) + { + _db = db; + _importer = importer; + _categorizer = categorizer; + } + + [HttpPost("csv")] + [Consumes("multipart/form-data")] + [RequestFormLimits(MultipartBodyLengthLimit = 50_000_000)] + [RequestSizeLimit(50_000_000)] + public async Task ImportCsv([FromForm] CsvImportRequest request) + { + if (request.Csv is null || request.Csv.Length == 0) + return BadRequest(new { message = "Please choose a CSV file." }); + + var cards = await _db.Cards + .Include(c => c.Account) + .OrderBy(c => c.Owner) + .ThenBy(c => c.Last4) + .ToListAsync(); + + var accounts = await _db.Accounts + .OrderBy(a => a.Institution) + .ThenBy(a => a.Last4) + .ToListAsync(); + + var importContext = new ImportContext + { + PaymentMode = request.PaymentMode, + SelectedCardId = request.SelectedCardId, + SelectedAccountId = request.SelectedAccountId, + AvailableCards = cards, + AvailableAccounts = accounts, + FileName = request.Csv.FileName + }; + + await using var csvStream = request.Csv.OpenReadStream(); + var previewResult = await _importer.PreviewAsync(csvStream, importContext); + + if (!previewResult.IsSuccess) + return BadRequest(new { message = previewResult.ErrorMessage }); + + var previewTransactions = previewResult.Data ?? new List(); + var autoCategorizedCount = 0; + + foreach (var preview in previewTransactions) + { + if (!string.IsNullOrWhiteSpace(preview.Transaction.Category)) + continue; + + var categorizationResult = await _categorizer.CategorizeAsync(preview.Transaction.Name, preview.Transaction.Amount); + if (string.IsNullOrWhiteSpace(categorizationResult.Category)) + continue; + + preview.Transaction.Category = categorizationResult.Category; + preview.Transaction.MerchantId = categorizationResult.MerchantId; + preview.SuggestedCategory = categorizationResult.Category; + autoCategorizedCount++; + } + + var transactionsToImport = previewTransactions + .Where(p => !p.IsDuplicate) + .Select(p => p.Transaction) + .ToList(); + + var duplicateCount = previewTransactions.Count - transactionsToImport.Count; + + if (transactionsToImport.Count > 0) + { + var importResult = await _importer.ImportAsync(transactionsToImport); + if (!importResult.IsSuccess) + return BadRequest(new { message = importResult.ErrorMessage }); + } + + return Ok(new + { + FileName = request.Csv.FileName, + request.PaymentMode, + request.SelectedCardId, + request.SelectedAccountId, + TotalRows = previewTransactions.Count, + Inserted = transactionsToImport.Count, + SkippedDuplicates = duplicateCount, + AutoCategorized = autoCategorizedCount, + Transactions = previewTransactions.Select(p => new + { + p.Transaction.Date, + p.Transaction.Name, + p.Transaction.Memo, + p.Transaction.Amount, + p.Transaction.Category, + p.Transaction.AccountId, + p.Transaction.CardId, + p.IsDuplicate, + p.PaymentMethodLabel + }) + }); + } +} + +public class CsvImportRequest +{ + public IFormFile? Csv { get; set; } + public PaymentSelectMode PaymentMode { get; set; } = PaymentSelectMode.Auto; + public int? SelectedCardId { get; set; } + public int? SelectedAccountId { get; set; } +}