feat(api): add CSV upload import endpoint
Build MoneyMap image / build-and-push (push) Successful in 31s

This commit is contained in:
aj
2026-07-06 21:58:26 -04:00
parent b4211eac30
commit 2356b7d5c9
2 changed files with 277 additions and 0 deletions
@@ -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<OkObjectResult>(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<OkObjectResult>(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"
};
}
}
+127
View File
@@ -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<IActionResult> 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<TransactionPreview>();
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; }
}