From 9dc1a9064d48f3b925855fa66054975071a9f9cc Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 20 Apr 2026 20:35:06 -0400 Subject: [PATCH] feat(api): add ReceiptsController with list, detail, image, and text endpoints Co-Authored-By: Claude Opus 4.6 --- MoneyMap/Controllers/ReceiptsController.cs | 197 +++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 MoneyMap/Controllers/ReceiptsController.cs diff --git a/MoneyMap/Controllers/ReceiptsController.cs b/MoneyMap/Controllers/ReceiptsController.cs new file mode 100644 index 0000000..73d64b8 --- /dev/null +++ b/MoneyMap/Controllers/ReceiptsController.cs @@ -0,0 +1,197 @@ +using ImageMagick; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; +using MoneyMap.Models; +using MoneyMap.Services; + +namespace MoneyMap.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class ReceiptsController : ControllerBase +{ + private readonly MoneyMapContext _db; + private readonly IReceiptStorageOptions _storageOptions; + + public ReceiptsController(MoneyMapContext db, IReceiptStorageOptions storageOptions) + { + _db = db; + _storageOptions = storageOptions; + } + + [HttpGet] + public async Task List( + [FromQuery] long? transactionId = null, + [FromQuery] string? parseStatus = null, + [FromQuery] int? limit = null) + { + var q = _db.Receipts + .Include(r => r.Transaction) + .AsQueryable(); + + if (transactionId.HasValue) + q = q.Where(r => r.TransactionId == transactionId.Value); + + if (!string.IsNullOrWhiteSpace(parseStatus) && Enum.TryParse(parseStatus, true, out var status)) + q = q.Where(r => r.ParseStatus == status); + + var results = await q + .OrderByDescending(r => r.UploadedAtUtc) + .Take(limit ?? 50) + .Select(r => new + { + r.Id, + r.FileName, + ParseStatus = r.ParseStatus.ToString(), + r.Merchant, + r.Total, + r.ReceiptDate, + r.UploadedAtUtc, + TransactionId = r.TransactionId, + TransactionName = r.Transaction != null ? r.Transaction.Name : null + }) + .ToListAsync(); + + return Ok(new { Count = results.Count, Receipts = results }); + } + + [HttpGet("{id}")] + public async Task GetDetails(long id) + { + var receipt = await _db.Receipts + .Include(r => r.LineItems) + .Include(r => r.Transaction) + .Include(r => r.ParseLogs) + .FirstOrDefaultAsync(r => r.Id == id); + + if (receipt == null) + return NotFound(new { message = "Receipt not found" }); + + var result = new + { + receipt.Id, + receipt.FileName, + receipt.ContentType, + receipt.FileSizeBytes, + receipt.UploadedAtUtc, + ParseStatus = receipt.ParseStatus.ToString(), + ParsedData = new + { + receipt.Merchant, + receipt.ReceiptDate, + receipt.DueDate, + receipt.Subtotal, + receipt.Tax, + receipt.Total, + receipt.Currency + }, + LinkedTransaction = receipt.Transaction != null ? new + { + receipt.Transaction.Id, + receipt.Transaction.Name, + receipt.Transaction.Date, + receipt.Transaction.Amount, + receipt.Transaction.Category + } : null, + LineItems = receipt.LineItems.OrderBy(li => li.LineNumber).Select(li => new + { + li.LineNumber, + li.Description, + li.Quantity, + li.UnitPrice, + li.LineTotal, + li.Category + }).ToList(), + ParseHistory = receipt.ParseLogs.OrderByDescending(pl => pl.StartedAtUtc).Select(pl => new + { + pl.Provider, + pl.Model, + pl.Success, + pl.Confidence, + pl.Error, + pl.StartedAtUtc + }).ToList() + }; + + return Ok(result); + } + + [HttpGet("{id}/image")] + public async Task GetImage(long id) + { + var receipt = await _db.Receipts.FindAsync(id); + if (receipt == null) + return NotFound(new { message = "Receipt not found" }); + + var basePath = Path.GetFullPath(_storageOptions.ReceiptsBasePath); + var fullPath = Path.GetFullPath(Path.Combine(basePath, receipt.StoragePath)); + + if (!fullPath.StartsWith(basePath, StringComparison.OrdinalIgnoreCase)) + return BadRequest(new { message = "Invalid receipt path" }); + + if (!System.IO.File.Exists(fullPath)) + return NotFound(new { message = "Receipt file not found on disk" }); + + byte[] imageBytes; + string mimeType; + + if (receipt.ContentType == "application/pdf") + { + var settings = new MagickReadSettings { Density = new Density(220) }; + using var image = new MagickImage(fullPath + "[0]", settings); + image.Format = MagickFormat.Png; + image.BackgroundColor = MagickColors.White; + image.Alpha(AlphaOption.Remove); + imageBytes = image.ToByteArray(); + mimeType = "image/png"; + } + else + { + imageBytes = await System.IO.File.ReadAllBytesAsync(fullPath); + mimeType = receipt.ContentType; + } + + var base64 = Convert.ToBase64String(imageBytes); + return Ok(new { MimeType = mimeType, Data = base64, SizeBytes = imageBytes.Length }); + } + + [HttpGet("{id}/text")] + public async Task GetText(long id) + { + var receipt = await _db.Receipts + .Include(r => r.LineItems) + .Include(r => r.Transaction) + .FirstOrDefaultAsync(r => r.Id == id); + + if (receipt == null) + return NotFound(new { message = "Receipt not found" }); + + if (receipt.ParseStatus != ReceiptParseStatus.Completed) + return Ok(new { Message = "Receipt has not been parsed yet", ParseStatus = receipt.ParseStatus.ToString() }); + + var result = new + { + receipt.Id, + receipt.Merchant, + receipt.ReceiptDate, + receipt.DueDate, + receipt.Subtotal, + receipt.Tax, + receipt.Total, + receipt.Currency, + LinkedTransaction = receipt.Transaction != null ? new { receipt.Transaction.Id, receipt.Transaction.Name, receipt.Transaction.Category, receipt.Transaction.Amount } : null, + LineItems = receipt.LineItems.OrderBy(li => li.LineNumber).Select(li => new + { + li.LineNumber, + li.Description, + li.Quantity, + li.UnitPrice, + li.LineTotal, + li.Category + }).ToList() + }; + + return Ok(result); + } +}