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