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:
606
MoneyMap/Migrations/20251012152604_MakeReceiptTransactionIdNullable.Designer.cs
generated
Normal file
606
MoneyMap/Migrations/20251012152604_MakeReceiptTransactionIdNullable.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
202
MoneyMap/Pages/Receipts.cshtml
Normal file
202
MoneyMap/Pages/Receipts.cshtml
Normal 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>
|
||||
}
|
||||
142
MoneyMap/Pages/Receipts.cshtml.cs
Normal file
142
MoneyMap/Pages/Receipts.cshtml.cs
Normal 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; } = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user