Add Receipts page for managing unmapped receipts

Added functionality to upload and manage receipts without initially
associating them to a transaction, with the ability to map them later.

Changes:
- Modified Receipt model to make TransactionId nullable
- Updated ReceiptManager service with new methods:
  - UploadUnmappedReceiptAsync: Upload receipts without a transaction
  - MapReceiptToTransactionAsync: Map an existing receipt to a transaction
- Created Receipts page (Receipts.cshtml) with:
  - Upload form for new receipts
  - List view of all receipts (mapped and unmapped)
  - Status badges (Mapped/Unmapped)
  - Map to Transaction modal dialog
  - Delete receipt functionality
- Added "Receipts" link to navigation menu
- Fixed Transactions page receipt count query for nullable TransactionId
- Created migration: MakeReceiptTransactionIdNullable

This enables workflows where receipts are uploaded first and matched
to transactions later, useful for batch receipt processing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
AJ
2025-10-12 11:28:33 -04:00
parent eb6d83f589
commit 8eb07c43e0
8 changed files with 1029 additions and 16 deletions

View File

@@ -0,0 +1,606 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using MoneyMap.Data;
#nullable disable
namespace MoneyMap.Migrations
{
[DbContext(typeof(MoneyMapContext))]
[Migration("20251012152604_MakeReceiptTransactionIdNullable")]
partial class MakeReceiptTransactionIdNullable
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("MoneyMap.Models.Account", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("AccountType")
.HasColumnType("int");
b.Property<string>("Institution")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Last4")
.IsRequired()
.HasMaxLength(4)
.HasColumnType("nvarchar(4)");
b.Property<string>("Nickname")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Owner")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.HasKey("Id");
b.HasIndex("Institution", "Last4", "Owner");
b.ToTable("Accounts");
});
modelBuilder.Entity("MoneyMap.Models.Card", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int?>("AccountId")
.HasColumnType("int");
b.Property<string>("Issuer")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Last4")
.IsRequired()
.HasMaxLength(4)
.HasColumnType("nvarchar(4)");
b.Property<string>("Nickname")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Owner")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.HasKey("Id");
b.HasIndex("AccountId");
b.HasIndex("Issuer", "Last4", "Owner");
b.ToTable("Cards");
});
modelBuilder.Entity("MoneyMap.Models.Merchant", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Merchants");
});
modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("ContentType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)")
.HasDefaultValue("application/octet-stream");
b.Property<string>("Currency")
.HasMaxLength(8)
.HasColumnType("nvarchar(8)");
b.Property<string>("FileHashSha256")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(260)
.HasColumnType("nvarchar(260)");
b.Property<long>("FileSizeBytes")
.HasColumnType("bigint");
b.Property<string>("Merchant")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTime?>("ReceiptDate")
.HasColumnType("datetime2");
b.Property<string>("StoragePath")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<decimal?>("Subtotal")
.HasColumnType("decimal(18,2)");
b.Property<decimal?>("Tax")
.HasColumnType("decimal(18,2)");
b.Property<decimal?>("Total")
.HasColumnType("decimal(18,2)");
b.Property<long>("TransactionId")
.HasColumnType("bigint");
b.Property<DateTime>("UploadedAtUtc")
.HasColumnType("datetime2");
b.HasKey("Id");
b.HasIndex("TransactionId", "FileHashSha256")
.IsUnique();
b.ToTable("Receipts");
});
modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("Category")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("nvarchar(300)");
b.Property<int>("LineNumber")
.HasColumnType("int");
b.Property<decimal?>("LineTotal")
.HasColumnType("decimal(18,2)");
b.Property<decimal?>("Quantity")
.HasColumnType("decimal(18,4)");
b.Property<long>("ReceiptId")
.HasColumnType("bigint");
b.Property<string>("Sku")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Unit")
.HasMaxLength(16)
.HasColumnType("nvarchar(16)");
b.Property<decimal?>("UnitPrice")
.HasColumnType("decimal(18,4)");
b.HasKey("Id");
b.HasIndex("ReceiptId", "LineNumber");
b.ToTable("ReceiptLineItems");
});
modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTime?>("CompletedAtUtc")
.HasColumnType("datetime2");
b.Property<decimal?>("Confidence")
.HasColumnType("decimal(5,4)");
b.Property<string>("Error")
.HasColumnType("nvarchar(max)");
b.Property<string>("ExtractedTextPath")
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<string>("Model")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Provider")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("ProviderJobId")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("RawProviderPayloadJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<long>("ReceiptId")
.HasColumnType("bigint");
b.Property<DateTime>("StartedAtUtc")
.HasColumnType("datetime2");
b.Property<bool>("Success")
.HasColumnType("bit");
b.HasKey("Id");
b.HasIndex("ReceiptId", "StartedAtUtc");
b.ToTable("ReceiptParseLogs");
});
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<int>("AccountId")
.HasColumnType("int");
b.Property<decimal>("Amount")
.HasColumnType("decimal(18,2)");
b.Property<int?>("CardId")
.HasColumnType("int");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTime>("Date")
.HasColumnType("datetime2");
b.Property<string>("Last4")
.HasMaxLength(4)
.HasColumnType("nvarchar(4)");
b.Property<string>("Memo")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)")
.HasDefaultValue("");
b.Property<int?>("MerchantId")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("Notes")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("TransactionType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<int?>("TransferToAccountId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("AccountId");
b.HasIndex("Amount");
b.HasIndex("CardId");
b.HasIndex("Category");
b.HasIndex("Date");
b.HasIndex("MerchantId");
b.HasIndex("TransferToAccountId");
b.HasIndex("Date", "Amount", "Name", "Memo", "AccountId", "CardId")
.IsUnique()
.HasFilter("[CardId] IS NOT NULL");
b.ToTable("Transactions");
});
modelBuilder.Entity("MoneyMap.Models.Transfer", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<decimal>("Amount")
.HasColumnType("decimal(18,2)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<DateTime>("Date")
.HasColumnType("datetime2");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<int?>("DestinationAccountId")
.HasColumnType("int");
b.Property<string>("Notes")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<long?>("OriginalTransactionId")
.HasColumnType("bigint");
b.Property<int?>("SourceAccountId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("Date");
b.HasIndex("DestinationAccountId");
b.HasIndex("OriginalTransactionId");
b.HasIndex("SourceAccountId");
b.ToTable("Transfers");
});
modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<decimal?>("Confidence")
.HasColumnType("decimal(5,4)");
b.Property<DateTime?>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<int?>("MerchantId")
.HasColumnType("int");
b.Property<string>("Pattern")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<int>("Priority")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("MerchantId");
b.ToTable("CategoryMappings");
});
modelBuilder.Entity("MoneyMap.Models.Card", b =>
{
b.HasOne("MoneyMap.Models.Account", "Account")
.WithMany("Cards")
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Account");
});
modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
{
b.HasOne("MoneyMap.Models.Transaction", "Transaction")
.WithMany("Receipts")
.HasForeignKey("TransactionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Transaction");
});
modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b =>
{
b.HasOne("MoneyMap.Models.Receipt", "Receipt")
.WithMany("LineItems")
.HasForeignKey("ReceiptId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Receipt");
});
modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b =>
{
b.HasOne("MoneyMap.Models.Receipt", "Receipt")
.WithMany("ParseLogs")
.HasForeignKey("ReceiptId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Receipt");
});
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
{
b.HasOne("MoneyMap.Models.Account", "Account")
.WithMany("Transactions")
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("MoneyMap.Models.Card", "Card")
.WithMany("Transactions")
.HasForeignKey("CardId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("MoneyMap.Models.Merchant", "Merchant")
.WithMany("Transactions")
.HasForeignKey("MerchantId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("MoneyMap.Models.Account", "TransferToAccount")
.WithMany()
.HasForeignKey("TransferToAccountId");
b.Navigation("Account");
b.Navigation("Card");
b.Navigation("Merchant");
b.Navigation("TransferToAccount");
});
modelBuilder.Entity("MoneyMap.Models.Transfer", b =>
{
b.HasOne("MoneyMap.Models.Account", "DestinationAccount")
.WithMany("DestinationTransfers")
.HasForeignKey("DestinationAccountId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("MoneyMap.Models.Transaction", "OriginalTransaction")
.WithMany()
.HasForeignKey("OriginalTransactionId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("MoneyMap.Models.Account", "SourceAccount")
.WithMany("SourceTransfers")
.HasForeignKey("SourceAccountId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("DestinationAccount");
b.Navigation("OriginalTransaction");
b.Navigation("SourceAccount");
});
modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b =>
{
b.HasOne("MoneyMap.Models.Merchant", "Merchant")
.WithMany("CategoryMappings")
.HasForeignKey("MerchantId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Merchant");
});
modelBuilder.Entity("MoneyMap.Models.Account", b =>
{
b.Navigation("Cards");
b.Navigation("DestinationTransfers");
b.Navigation("SourceTransfers");
b.Navigation("Transactions");
});
modelBuilder.Entity("MoneyMap.Models.Card", b =>
{
b.Navigation("Transactions");
});
modelBuilder.Entity("MoneyMap.Models.Merchant", b =>
{
b.Navigation("CategoryMappings");
b.Navigation("Transactions");
});
modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
{
b.Navigation("LineItems");
b.Navigation("ParseLogs");
});
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
{
b.Navigation("Receipts");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MoneyMap.Migrations
{
/// <inheritdoc />
public partial class MakeReceiptTransactionIdNullable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

View File

@@ -10,9 +10,9 @@ public class Receipt
[Key]
public long Id { get; set; }
// Link to transaction
public long TransactionId { get; set; }
public Transaction Transaction { get; set; } = null!;
// Link to transaction (nullable to support unmapped receipts)
public long? TransactionId { get; set; }
public Transaction? Transaction { get; set; }
// File metadata
[MaxLength(260)]

View File

@@ -0,0 +1,202 @@
@page
@model MoneyMap.Pages.ReceiptsModel
@{
ViewData["Title"] = "Receipts";
}
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>Receipts</h2>
<a asp-page="/Index" class="btn btn-outline-secondary">Back to Dashboard</a>
</div>
<!-- Success/Error Message -->
@if (!string.IsNullOrWhiteSpace(Model.Message))
{
<div class="alert @(Model.IsSuccess ? "alert-success" : "alert-danger") alert-dismissible fade show" role="alert">
@Model.Message
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
<!-- Upload Section -->
<div class="card shadow-sm mb-3">
<div class="card-header">
<strong>Upload Receipt</strong>
</div>
<div class="card-body">
<form method="post" enctype="multipart/form-data" asp-page-handler="Upload">
<div class="row g-3">
<div class="col-md-10">
<input type="file" asp-for="UploadFile" class="form-control" accept=".jpg,.jpeg,.png,.pdf,.gif,.heic" />
<div class="form-text">Supported formats: JPG, PNG, PDF, GIF, HEIC (Max 10MB)</div>
</div>
<div class="col-md-2 d-flex align-items-start">
<button type="submit" class="btn btn-primary w-100">Upload</button>
</div>
</div>
</form>
</div>
</div>
<!-- Receipts List -->
<div class="card shadow-sm">
<div class="card-header">
<strong>All Receipts</strong>
<span class="text-muted">- @Model.Receipts.Count total</span>
</div>
<div class="card-body p-0">
@if (Model.Receipts.Any())
{
<div class="table-responsive">
<table class="table table-hover table-sm mb-0">
<thead>
<tr>
<th style="width: 110px;">Uploaded</th>
<th>File Name</th>
<th style="width: 140px;">Receipt Info</th>
<th>Mapped Transaction</th>
<th style="width: 180px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var r in Model.Receipts)
{
<tr>
<td>@r.UploadedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm")</td>
<td>
<div class="d-flex align-items-center gap-2">
@if (r.ContentType.StartsWith("image/"))
{
<span title="Image">🖼️</span>
}
else if (r.ContentType == "application/pdf")
{
<span title="PDF">📄</span>
}
<span>@r.FileName</span>
<small class="text-muted">(@(r.FileSizeBytes / 1024) KB)</small>
</div>
</td>
<td class="small">
@if (!string.IsNullOrWhiteSpace(r.Merchant) || r.ReceiptDate.HasValue || r.Total.HasValue)
{
<div>
@if (!string.IsNullOrWhiteSpace(r.Merchant))
{
<div><strong>@r.Merchant</strong></div>
}
@if (r.ReceiptDate.HasValue)
{
<div>@r.ReceiptDate.Value.ToString("yyyy-MM-dd")</div>
}
@if (r.Total.HasValue)
{
<div>@r.Total.Value.ToString("C")</div>
}
</div>
}
else
{
<span class="text-muted">Not parsed</span>
}
</td>
<td>
@if (r.TransactionId.HasValue)
{
<div class="badge bg-success mb-1">Mapped</div>
<div class="small">
<div>@r.TransactionName</div>
<div class="text-muted">
@r.TransactionDate?.ToString("yyyy-MM-dd") - @r.TransactionAmount?.ToString("C")
</div>
</div>
}
else
{
<span class="badge bg-warning text-dark">Unmapped</span>
}
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<a asp-page="/ViewReceipt" asp-route-id="@r.Id" class="btn btn-outline-primary" title="View">
View
</a>
@if (!r.TransactionId.HasValue)
{
<button type="button"
class="btn btn-outline-success"
data-bs-toggle="modal"
data-bs-target="#mapModal@(r.Id)"
title="Map to Transaction">
Map
</button>
}
<form method="post" asp-page-handler="Delete" asp-route-receiptId="@r.Id" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete this receipt?');">
<button type="submit" class="btn btn-outline-danger btn-sm" title="Delete">
Delete
</button>
</form>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
else
{
<div class="p-3 text-center text-muted">
No receipts uploaded yet. Use the form above to upload your first receipt.
</div>
}
</div>
</div>
<!-- Map to Transaction Modals -->
@foreach (var r in Model.Receipts.Where(r => !r.TransactionId.HasValue))
{
<div class="modal fade" id="mapModal@(r.Id)" tabindex="-1" aria-labelledby="mapModalLabel@(r.Id)" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="mapModalLabel@(r.Id)">Map Receipt to Transaction</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="post" asp-page-handler="MapToTransaction" asp-route-receiptId="@r.Id">
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Receipt: @r.FileName</label>
@if (!string.IsNullOrWhiteSpace(r.Merchant) || r.ReceiptDate.HasValue || r.Total.HasValue)
{
<div class="small text-muted">
@if (!string.IsNullOrWhiteSpace(r.Merchant))
{
<div>Merchant: @r.Merchant</div>
}
@if (r.ReceiptDate.HasValue)
{
<div>Date: @r.ReceiptDate.Value.ToString("yyyy-MM-dd")</div>
}
@if (r.Total.HasValue)
{
<div>Total: @r.Total.Value.ToString("C")</div>
}
</div>
}
</div>
<div class="mb-3">
<label for="transactionId@(r.Id)" class="form-label">Transaction ID</label>
<input type="number" class="form-control" id="transactionId@(r.Id)" name="transactionId" required />
<div class="form-text">Enter the transaction ID to map this receipt to. You can find transaction IDs on the Transactions page.</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Map Receipt</button>
</div>
</form>
</div>
</div>
</div>
}

View File

@@ -0,0 +1,142 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Models;
using MoneyMap.Services;
namespace MoneyMap.Pages
{
public class ReceiptsModel : PageModel
{
private readonly MoneyMapContext _db;
private readonly IReceiptManager _receiptManager;
public ReceiptsModel(MoneyMapContext db, IReceiptManager receiptManager)
{
_db = db;
_receiptManager = receiptManager;
}
public List<ReceiptRow> Receipts { get; set; } = new();
[BindProperty]
public IFormFile? UploadFile { get; set; }
[TempData]
public string? Message { get; set; }
[TempData]
public bool IsSuccess { get; set; }
public async Task OnGetAsync()
{
await LoadReceiptsAsync();
}
public async Task<IActionResult> OnPostUploadAsync()
{
if (UploadFile == null)
{
Message = "Please select a file to upload.";
IsSuccess = false;
await LoadReceiptsAsync();
return Page();
}
var result = await _receiptManager.UploadUnmappedReceiptAsync(UploadFile);
if (result.IsSuccess)
{
Message = "Receipt uploaded successfully!";
IsSuccess = true;
return RedirectToPage();
}
else
{
Message = result.ErrorMessage ?? "Failed to upload receipt.";
IsSuccess = false;
await LoadReceiptsAsync();
return Page();
}
}
public async Task<IActionResult> OnPostDeleteAsync(long receiptId)
{
var success = await _receiptManager.DeleteReceiptAsync(receiptId);
if (success)
{
Message = "Receipt deleted successfully.";
IsSuccess = true;
}
else
{
Message = "Failed to delete receipt.";
IsSuccess = false;
}
return RedirectToPage();
}
public async Task<IActionResult> OnPostMapToTransactionAsync(long receiptId, long transactionId)
{
var success = await _receiptManager.MapReceiptToTransactionAsync(receiptId, transactionId);
if (success)
{
Message = "Receipt mapped to transaction successfully.";
IsSuccess = true;
}
else
{
Message = "Failed to map receipt to transaction.";
IsSuccess = false;
}
return RedirectToPage();
}
private async Task LoadReceiptsAsync()
{
var receipts = await _db.Receipts
.Include(r => r.Transaction)
.OrderByDescending(r => r.UploadedAtUtc)
.ToListAsync();
Receipts = receipts.Select(r => new ReceiptRow
{
Id = r.Id,
FileName = r.FileName,
ContentType = r.ContentType,
FileSizeBytes = r.FileSizeBytes,
UploadedAtUtc = r.UploadedAtUtc,
TransactionId = r.TransactionId,
TransactionName = r.Transaction?.Name,
TransactionDate = r.Transaction?.Date,
TransactionAmount = r.Transaction?.Amount,
Merchant = r.Merchant,
ReceiptDate = r.ReceiptDate,
Total = r.Total,
StoragePath = r.StoragePath
}).ToList();
}
public class ReceiptRow
{
public long Id { get; set; }
public string FileName { get; set; } = "";
public string ContentType { get; set; } = "";
public long FileSizeBytes { get; set; }
public DateTime UploadedAtUtc { get; set; }
public long? TransactionId { get; set; }
public string? TransactionName { get; set; }
public DateTime? TransactionDate { get; set; }
public decimal? TransactionAmount { get; set; }
public string? Merchant { get; set; }
public DateTime? ReceiptDate { get; set; }
public decimal? Total { get; set; }
public string StoragePath { get; set; } = "";
}
}
}

View File

@@ -25,6 +25,9 @@
<li class="nav-item">
<a class="nav-link text-dark" asp-page="/Transactions">Transactions</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-page="/Receipts">Receipts</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-page="/Accounts">Accounts</a>
</li>

View File

@@ -129,9 +129,9 @@ namespace MoneyMap.Pages
// Get receipt counts for the current page only
var transactionIds = transactions.Select(t => t.Id).ToList();
var receiptCounts = await _db.Receipts
.Where(r => transactionIds.Contains(r.TransactionId))
.Where(r => r.TransactionId != null && transactionIds.Contains(r.TransactionId.Value))
.GroupBy(r => r.TransactionId)
.Select(g => new { TransactionId = g.Key, Count = g.Count() })
.Select(g => new { TransactionId = g.Key!.Value, Count = g.Count() })
.ToListAsync();
var receiptCountDict = receiptCounts.ToDictionary(x => x.TransactionId, x => x.Count);

View File

@@ -15,7 +15,9 @@ namespace MoneyMap.Services
public interface IReceiptManager
{
Task<ReceiptUploadResult> UploadReceiptAsync(long transactionId, IFormFile file);
Task<ReceiptUploadResult> UploadUnmappedReceiptAsync(IFormFile file);
Task<bool> DeleteReceiptAsync(long receiptId);
Task<bool> MapReceiptToTransactionAsync(long receiptId, long transactionId);
string GetReceiptPhysicalPath(Receipt receipt);
Task<Receipt?> GetReceiptAsync(long receiptId);
}
@@ -43,6 +45,21 @@ namespace MoneyMap.Services
}
public async Task<ReceiptUploadResult> UploadReceiptAsync(long transactionId, IFormFile file)
{
// Verify transaction exists
var transaction = await _db.Transactions.FindAsync(transactionId);
if (transaction == null)
return ReceiptUploadResult.Failure("Transaction not found.");
return await UploadReceiptInternalAsync(file, transactionId);
}
public async Task<ReceiptUploadResult> UploadUnmappedReceiptAsync(IFormFile file)
{
return await UploadReceiptInternalAsync(file, null);
}
private async Task<ReceiptUploadResult> UploadReceiptInternalAsync(IFormFile file, long? transactionId)
{
// Validate file
if (file == null || file.Length == 0)
@@ -55,11 +72,6 @@ namespace MoneyMap.Services
if (!AllowedExtensions.Contains(extension))
return ReceiptUploadResult.Failure($"File type {extension} not allowed. Use: {string.Join(", ", AllowedExtensions)}");
// Verify transaction exists
var transaction = await _db.Transactions.FindAsync(transactionId);
if (transaction == null)
return ReceiptUploadResult.Failure("Transaction not found.");
// Create receipts directory if it doesn't exist
var receiptsBasePath = GetReceiptsBasePath();
if (!Directory.Exists(receiptsBasePath))
@@ -74,15 +86,18 @@ namespace MoneyMap.Services
fileHash = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
}
// Check for duplicate (same transaction + same hash)
// Check for duplicate (same transaction + same hash, or same hash if unmapped)
if (transactionId.HasValue)
{
var existingReceipt = await _db.Receipts
.FirstOrDefaultAsync(r => r.TransactionId == transactionId && r.FileHashSha256 == fileHash);
if (existingReceipt != null)
return ReceiptUploadResult.Failure("This receipt has already been uploaded for this transaction.");
}
// Generate unique filename
var storedFileName = $"{transactionId}_{Guid.NewGuid()}{extension}";
var storedFileName = $"{transactionId?.ToString() ?? "unmapped"}_{Guid.NewGuid()}{extension}";
var filePath = Path.Combine(receiptsBasePath, storedFileName);
// Save file
@@ -113,6 +128,29 @@ namespace MoneyMap.Services
return ReceiptUploadResult.Success(receipt);
}
public async Task<bool> MapReceiptToTransactionAsync(long receiptId, long transactionId)
{
var receipt = await _db.Receipts.FindAsync(receiptId);
if (receipt == null)
return false;
var transaction = await _db.Transactions.FindAsync(transactionId);
if (transaction == null)
return false;
// Check if this receipt is already mapped to another transaction
if (receipt.TransactionId.HasValue && receipt.TransactionId.Value != transactionId)
{
// Could return a more specific error, but for now just return false
return false;
}
receipt.TransactionId = transactionId;
await _db.SaveChangesAsync();
return true;
}
private static string SanitizeFileName(string fileName)
{
if (string.IsNullOrWhiteSpace(fileName))