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