diff --git a/MoneyMap/Migrations/20251012152604_MakeReceiptTransactionIdNullable.Designer.cs b/MoneyMap/Migrations/20251012152604_MakeReceiptTransactionIdNullable.Designer.cs new file mode 100644 index 0000000..34d2763 --- /dev/null +++ b/MoneyMap/Migrations/20251012152604_MakeReceiptTransactionIdNullable.Designer.cs @@ -0,0 +1,606 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountType") + .HasColumnType("int"); + + b.Property("Institution") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Issuer") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasDefaultValue("application/octet-stream"); + + b.Property("Currency") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("FileHashSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("Merchant") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ReceiptDate") + .HasColumnType("datetime2"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Subtotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Tax") + .HasColumnType("decimal(18,2)"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("TransactionId") + .HasColumnType("bigint"); + + b.Property("UploadedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("TransactionId", "FileHashSha256") + .IsUnique(); + + b.ToTable("Receipts"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("LineNumber") + .HasColumnType("int"); + + b.Property("LineTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Quantity") + .HasColumnType("decimal(18,4)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("Sku") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Unit") + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("UnitPrice") + .HasColumnType("decimal(18,4)"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "LineNumber"); + + b.ToTable("ReceiptLineItems"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Confidence") + .HasColumnType("decimal(5,4)"); + + b.Property("Error") + .HasColumnType("nvarchar(max)"); + + b.Property("ExtractedTextPath") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProviderJobId") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RawProviderPayloadJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("StartedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Success") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "StartedAtUtc"); + + b.ToTable("ReceiptParseLogs"); + }); + + modelBuilder.Entity("MoneyMap.Models.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CardId") + .HasColumnType("int"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Last4") + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Memo") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("MerchantId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DestinationAccountId") + .HasColumnType("int"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OriginalTransactionId") + .HasColumnType("bigint"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Confidence") + .HasColumnType("decimal(5,4)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("MerchantId") + .HasColumnType("int"); + + b.Property("Pattern") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("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 + } + } +} diff --git a/MoneyMap/Migrations/20251012152604_MakeReceiptTransactionIdNullable.cs b/MoneyMap/Migrations/20251012152604_MakeReceiptTransactionIdNullable.cs new file mode 100644 index 0000000..0d98ee5 --- /dev/null +++ b/MoneyMap/Migrations/20251012152604_MakeReceiptTransactionIdNullable.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MoneyMap.Migrations +{ + /// + public partial class MakeReceiptTransactionIdNullable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/MoneyMap/Models/Receipt.cs b/MoneyMap/Models/Receipt.cs index 3b7b107..3794b86 100644 --- a/MoneyMap/Models/Receipt.cs +++ b/MoneyMap/Models/Receipt.cs @@ -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)] diff --git a/MoneyMap/Pages/Receipts.cshtml b/MoneyMap/Pages/Receipts.cshtml new file mode 100644 index 0000000..02f1fa2 --- /dev/null +++ b/MoneyMap/Pages/Receipts.cshtml @@ -0,0 +1,202 @@ +@page +@model MoneyMap.Pages.ReceiptsModel +@{ + ViewData["Title"] = "Receipts"; +} + +
+

Receipts

+ Back to Dashboard +
+ + +@if (!string.IsNullOrWhiteSpace(Model.Message)) +{ + +} + + +
+
+ Upload Receipt +
+
+
+
+
+ +
Supported formats: JPG, PNG, PDF, GIF, HEIC (Max 10MB)
+
+
+ +
+
+
+
+
+ + +
+
+ All Receipts + - @Model.Receipts.Count total +
+
+ @if (Model.Receipts.Any()) + { +
+ + + + + + + + + + + + @foreach (var r in Model.Receipts) + { + + + + + + + + } + +
UploadedFile NameReceipt InfoMapped TransactionActions
@r.UploadedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm") +
+ @if (r.ContentType.StartsWith("image/")) + { + 🖼️ + } + else if (r.ContentType == "application/pdf") + { + 📄 + } + @r.FileName + (@(r.FileSizeBytes / 1024) KB) +
+
+ @if (!string.IsNullOrWhiteSpace(r.Merchant) || r.ReceiptDate.HasValue || r.Total.HasValue) + { +
+ @if (!string.IsNullOrWhiteSpace(r.Merchant)) + { +
@r.Merchant
+ } + @if (r.ReceiptDate.HasValue) + { +
@r.ReceiptDate.Value.ToString("yyyy-MM-dd")
+ } + @if (r.Total.HasValue) + { +
@r.Total.Value.ToString("C")
+ } +
+ } + else + { + Not parsed + } +
+ @if (r.TransactionId.HasValue) + { +
Mapped
+
+
@r.TransactionName
+
+ @r.TransactionDate?.ToString("yyyy-MM-dd") - @r.TransactionAmount?.ToString("C") +
+
+ } + else + { + Unmapped + } +
+
+ + View + + @if (!r.TransactionId.HasValue) + { + + } +
+ +
+
+
+
+ } + else + { +
+ No receipts uploaded yet. Use the form above to upload your first receipt. +
+ } +
+
+ + +@foreach (var r in Model.Receipts.Where(r => !r.TransactionId.HasValue)) +{ + +} diff --git a/MoneyMap/Pages/Receipts.cshtml.cs b/MoneyMap/Pages/Receipts.cshtml.cs new file mode 100644 index 0000000..4bb81cc --- /dev/null +++ b/MoneyMap/Pages/Receipts.cshtml.cs @@ -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 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 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 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 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; } = ""; + } + } +} diff --git a/MoneyMap/Pages/Shared/_Layout.cshtml b/MoneyMap/Pages/Shared/_Layout.cshtml index c4039fd..e796caa 100644 --- a/MoneyMap/Pages/Shared/_Layout.cshtml +++ b/MoneyMap/Pages/Shared/_Layout.cshtml @@ -25,6 +25,9 @@ + diff --git a/MoneyMap/Pages/Transactions.cshtml.cs b/MoneyMap/Pages/Transactions.cshtml.cs index 8424a79..a7a5524 100644 --- a/MoneyMap/Pages/Transactions.cshtml.cs +++ b/MoneyMap/Pages/Transactions.cshtml.cs @@ -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); diff --git a/MoneyMap/Services/ReceiptManager.cs b/MoneyMap/Services/ReceiptManager.cs index fceb320..73d0c7b 100644 --- a/MoneyMap/Services/ReceiptManager.cs +++ b/MoneyMap/Services/ReceiptManager.cs @@ -15,7 +15,9 @@ namespace MoneyMap.Services public interface IReceiptManager { Task UploadReceiptAsync(long transactionId, IFormFile file); + Task UploadUnmappedReceiptAsync(IFormFile file); Task DeleteReceiptAsync(long receiptId); + Task MapReceiptToTransactionAsync(long receiptId, long transactionId); string GetReceiptPhysicalPath(Receipt receipt); Task GetReceiptAsync(long receiptId); } @@ -43,6 +45,21 @@ namespace MoneyMap.Services } public async Task 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 UploadUnmappedReceiptAsync(IFormFile file) + { + return await UploadReceiptInternalAsync(file, null); + } + + private async Task 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) - var existingReceipt = await _db.Receipts - .FirstOrDefaultAsync(r => r.TransactionId == transactionId && r.FileHashSha256 == fileHash); + // 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."); + 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 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))