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:
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user