feat(api): add TransactionsController with search, detail, category, and summary endpoints

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-20 20:33:02 -04:00
parent ccedea6e67
commit e773a0f218
@@ -0,0 +1,271 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Services;
namespace MoneyMap.Controllers;
[ApiController]
[Route("api/[controller]")]
public class TransactionsController : ControllerBase
{
private readonly MoneyMapContext _db;
private readonly IMerchantService _merchantService;
public TransactionsController(MoneyMapContext db, IMerchantService merchantService)
{
_db = db;
_merchantService = merchantService;
}
[HttpGet]
public async Task<IActionResult> Search(
[FromQuery] string? query = null,
[FromQuery] string? startDate = null,
[FromQuery] string? endDate = null,
[FromQuery] string? category = null,
[FromQuery] string? merchantName = null,
[FromQuery] decimal? minAmount = null,
[FromQuery] decimal? maxAmount = null,
[FromQuery] int? accountId = null,
[FromQuery] int? cardId = null,
[FromQuery] string? type = null,
[FromQuery] bool? uncategorizedOnly = null,
[FromQuery] int? limit = null)
{
var q = _db.Transactions
.Include(t => t.Merchant)
.Include(t => t.Card)
.Include(t => t.Account)
.Include(t => t.Receipts)
.AsQueryable();
if (!string.IsNullOrWhiteSpace(query))
q = q.Where(t => t.Name.Contains(query) || (t.Memo != null && t.Memo.Contains(query)) || (t.Category != null && t.Category.Contains(query)));
if (!string.IsNullOrWhiteSpace(startDate) && DateTime.TryParse(startDate, out var start))
q = q.Where(t => t.Date >= start);
if (!string.IsNullOrWhiteSpace(endDate) && DateTime.TryParse(endDate, out var end))
q = q.Where(t => t.Date <= end);
if (!string.IsNullOrWhiteSpace(category))
q = q.Where(t => t.Category == category);
if (!string.IsNullOrWhiteSpace(merchantName))
q = q.Where(t => t.Merchant != null && t.Merchant.Name.Contains(merchantName));
if (minAmount.HasValue)
q = q.Where(t => Math.Abs(t.Amount) >= minAmount.Value);
if (maxAmount.HasValue)
q = q.Where(t => Math.Abs(t.Amount) <= maxAmount.Value);
if (accountId.HasValue)
q = q.Where(t => t.AccountId == accountId.Value);
if (cardId.HasValue)
q = q.Where(t => t.CardId == cardId.Value);
if (type?.ToLower() == "debit")
q = q.Where(t => t.Amount < 0);
else if (type?.ToLower() == "credit")
q = q.Where(t => t.Amount > 0);
if (uncategorizedOnly == true)
q = q.Where(t => t.Category == null || t.Category == "");
var results = await q
.OrderByDescending(t => t.Date).ThenByDescending(t => t.Id)
.Take(limit ?? 50)
.Select(t => new
{
t.Id,
t.Date,
t.Name,
t.Memo,
t.Amount,
t.Category,
Merchant = t.Merchant != null ? t.Merchant.Name : null,
Account = t.Account!.Institution + " " + t.Account.Last4,
Card = t.Card != null ? t.Card.Issuer + " " + t.Card.Last4 : null,
ReceiptCount = t.Receipts.Count,
t.TransferToAccountId
})
.ToListAsync();
return Ok(new { Count = results.Count, Transactions = results });
}
[HttpGet("{id}")]
public async Task<IActionResult> GetById(long id)
{
var t = await _db.Transactions
.Include(t => t.Merchant)
.Include(t => t.Card)
.Include(t => t.Account)
.Include(t => t.Receipts)
.Where(t => t.Id == id)
.Select(t => new
{
t.Id,
t.Date,
t.Name,
t.Memo,
t.Amount,
t.TransactionType,
t.Category,
Merchant = t.Merchant != null ? t.Merchant.Name : null,
MerchantId = t.MerchantId,
Account = t.Account!.Institution + " " + t.Account.Last4,
AccountId = t.AccountId,
Card = t.Card != null ? t.Card.Issuer + " " + t.Card.Last4 : null,
CardId = t.CardId,
t.Notes,
t.TransferToAccountId,
Receipts = t.Receipts.Select(r => new { r.Id, r.FileName, r.ParseStatus, r.Merchant, r.Total }).ToList()
})
.FirstOrDefaultAsync();
if (t == null)
return NotFound(new { message = "Transaction not found" });
return Ok(t);
}
[HttpPut("{id}/category")]
public async Task<IActionResult> UpdateCategory(long id, [FromBody] UpdateCategoryRequest request)
{
var transactions = await _db.Transactions
.Where(t => request.TransactionIds.Contains(t.Id))
.ToListAsync();
if (!transactions.Any())
return NotFound(new { message = "No transactions found with the provided IDs" });
int? merchantId = null;
if (!string.IsNullOrWhiteSpace(request.MerchantName))
merchantId = await _merchantService.GetOrCreateIdAsync(request.MerchantName);
foreach (var t in transactions)
{
t.Category = request.Category;
if (merchantId.HasValue)
t.MerchantId = merchantId;
}
await _db.SaveChangesAsync();
return Ok(new { Updated = transactions.Count, request.Category, Merchant = request.MerchantName });
}
[HttpPost("bulk-recategorize")]
public async Task<IActionResult> BulkRecategorize([FromBody] BulkRecategorizeRequest request)
{
var q = _db.Transactions
.Where(t => t.Name.Contains(request.NamePattern));
if (!string.IsNullOrWhiteSpace(request.FromCategory))
q = q.Where(t => t.Category == request.FromCategory);
var transactions = await q.ToListAsync();
if (!transactions.Any())
return Ok(new { Message = "No transactions match the pattern", request.NamePattern, request.FromCategory });
if (request.DryRun)
{
var preview = transactions.Take(20).Select(t => new { t.Id, t.Date, t.Name, t.Amount, CurrentCategory = t.Category }).ToList();
return Ok(new { DryRun = true, TotalMatches = transactions.Count, Preview = preview, request.ToCategory });
}
int? merchantId = null;
if (!string.IsNullOrWhiteSpace(request.MerchantName))
merchantId = await _merchantService.GetOrCreateIdAsync(request.MerchantName);
foreach (var t in transactions)
{
t.Category = request.ToCategory;
if (merchantId.HasValue)
t.MerchantId = merchantId;
}
await _db.SaveChangesAsync();
return Ok(new { Applied = true, Updated = transactions.Count, request.ToCategory, Merchant = request.MerchantName });
}
[HttpGet("spending-summary")]
public async Task<IActionResult> SpendingSummary(
[FromQuery] string startDate,
[FromQuery] string endDate,
[FromQuery] int? accountId = null)
{
if (!DateTime.TryParse(startDate, out var start) || !DateTime.TryParse(endDate, out var end))
return BadRequest(new { message = "Invalid date format" });
var q = _db.Transactions
.Where(t => t.Date >= start && t.Date <= end)
.Where(t => t.Amount < 0)
.Where(t => t.TransferToAccountId == null)
.ExcludeTransfers();
if (accountId.HasValue)
q = q.Where(t => t.AccountId == accountId.Value);
var summary = await q
.GroupBy(t => t.Category ?? "Uncategorized")
.Select(g => new { Category = g.Key, Total = g.Sum(t => Math.Abs(t.Amount)), Count = g.Count() })
.OrderByDescending(x => x.Total)
.ToListAsync();
var grandTotal = summary.Sum(x => x.Total);
return Ok(new { Period = $"{startDate} to {endDate}", GrandTotal = grandTotal, Categories = summary });
}
[HttpGet("income-summary")]
public async Task<IActionResult> IncomeSummary(
[FromQuery] string startDate,
[FromQuery] string endDate,
[FromQuery] int? accountId = null)
{
if (!DateTime.TryParse(startDate, out var start) || !DateTime.TryParse(endDate, out var end))
return BadRequest(new { message = "Invalid date format" });
var q = _db.Transactions
.Where(t => t.Date >= start && t.Date <= end)
.Where(t => t.Amount > 0)
.Where(t => t.TransferToAccountId == null)
.ExcludeTransfers();
if (accountId.HasValue)
q = q.Where(t => t.AccountId == accountId.Value);
var summary = await q
.GroupBy(t => t.Name)
.Select(g => new { Source = g.Key, Total = g.Sum(t => t.Amount), Count = g.Count() })
.OrderByDescending(x => x.Total)
.ToListAsync();
var grandTotal = summary.Sum(x => x.Total);
return Ok(new { Period = $"{startDate} to {endDate}", GrandTotal = grandTotal, Sources = summary });
}
}
public class UpdateCategoryRequest
{
public long[] TransactionIds { get; set; } = [];
public string Category { get; set; } = "";
public string? MerchantName { get; set; }
}
public class BulkRecategorizeRequest
{
public string NamePattern { get; set; } = "";
public string ToCategory { get; set; } = "";
public string? FromCategory { get; set; }
public string? MerchantName { get; set; }
public bool DryRun { get; set; } = true;
}