feat(api): add ReceiptsController with list, detail, image, and text endpoints

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-20 20:35:06 -04:00
parent 5b4a673f9d
commit 9dc1a9064d
+197
View File
@@ -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<IActionResult> 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<ReceiptParseStatus>(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<IActionResult> 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<IActionResult> 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<IActionResult> 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);
}
}