diff --git a/MoneyMap/Controllers/TransactionsController.cs b/MoneyMap/Controllers/TransactionsController.cs new file mode 100644 index 0000000..5a8a682 --- /dev/null +++ b/MoneyMap/Controllers/TransactionsController.cs @@ -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 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 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 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 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 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 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; +}