Add duplicate detection for receipt uploads

Implemented duplicate detection to warn users when uploading receipts
that may already exist in the system.

Changes:
- Extended ReceiptUploadResult to include DuplicateWarning list
- Added CheckForDuplicatesAsync method to ReceiptManager that checks:
  1. Identical file content (same SHA256 hash)
  2. Same file name and size (potential duplicate saved/edited)
- Updated upload flow to check for duplicates before saving
- Enhanced Receipts page to:
  - Display warning alert when duplicates are detected
  - Show details of each potential duplicate (receipt ID, filename,
    upload date, mapped transaction, and reason)
  - Provide quick link to view the duplicate receipt
  - Use TempData to persist warnings across redirect

Duplicate detection runs on every upload and provides actionable
information to help users avoid uploading the same receipt multiple
times. The check uses hash-based detection for exact duplicates and
name+size matching for potential duplicates.

🤖 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 12:58:39 -04:00
parent c306ced9f0
commit c0a6b1690f
3 changed files with 121 additions and 6 deletions

View File

@@ -18,6 +18,39 @@
</div> </div>
} }
<!-- Duplicate Warnings -->
@if (Model.DuplicateWarnings.Any())
{
<div class="alert alert-warning alert-dismissible fade show" role="alert">
<h5 class="alert-heading">⚠️ Potential Duplicates Detected</h5>
<p>The following receipt(s) may be duplicates of the one you just uploaded:</p>
<ul class="mb-0">
@foreach (var warning in Model.DuplicateWarnings)
{
<li>
<strong>Receipt #@warning.ReceiptId</strong> - @warning.FileName
<br />
<small class="text-muted">
Uploaded: @warning.UploadedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm") |
Reason: @warning.Reason
@if (warning.TransactionId.HasValue)
{
<span> | Mapped to: @warning.TransactionName (Transaction #@warning.TransactionId)</span>
}
else
{
<span> | Status: Unmapped</span>
}
</small>
<br />
<a asp-page="/ViewReceipt" asp-route-id="@warning.ReceiptId" class="btn btn-sm btn-outline-primary mt-1">View Receipt #@warning.ReceiptId</a>
</li>
}
</ul>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
<!-- Upload Section --> <!-- Upload Section -->
<div class="card shadow-sm mb-3"> <div class="card shadow-sm mb-3">
<div class="card-header"> <div class="card-header">

View File

@@ -29,9 +29,20 @@ namespace MoneyMap.Pages
[TempData] [TempData]
public bool IsSuccess { get; set; } public bool IsSuccess { get; set; }
[TempData]
public string? DuplicateWarningsJson { get; set; }
public List<DuplicateWarning> DuplicateWarnings { get; set; } = new();
public async Task OnGetAsync() public async Task OnGetAsync()
{ {
await LoadReceiptsAsync(); await LoadReceiptsAsync();
// Deserialize duplicate warnings if present
if (!string.IsNullOrWhiteSpace(DuplicateWarningsJson))
{
DuplicateWarnings = System.Text.Json.JsonSerializer.Deserialize<List<DuplicateWarning>>(DuplicateWarningsJson) ?? new();
}
} }
public async Task<IActionResult> OnPostUploadAsync() public async Task<IActionResult> OnPostUploadAsync()
@@ -48,8 +59,17 @@ namespace MoneyMap.Pages
if (result.IsSuccess) if (result.IsSuccess)
{ {
Message = "Receipt uploaded successfully!"; if (result.DuplicateWarnings.Any())
IsSuccess = true; {
Message = $"Receipt uploaded successfully, but {result.DuplicateWarnings.Count} potential duplicate(s) detected.";
IsSuccess = true;
DuplicateWarningsJson = System.Text.Json.JsonSerializer.Serialize(result.DuplicateWarnings);
}
else
{
Message = "Receipt uploaded successfully!";
IsSuccess = true;
}
return RedirectToPage(); return RedirectToPage();
} }
else else

View File

@@ -86,7 +86,7 @@ namespace MoneyMap.Services
fileHash = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant(); fileHash = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
} }
// Check for duplicate (same transaction + same hash, or same hash if unmapped) // Check for exact duplicate (same transaction + same hash)
if (transactionId.HasValue) if (transactionId.HasValue)
{ {
var existingReceipt = await _db.Receipts var existingReceipt = await _db.Receipts
@@ -96,6 +96,9 @@ namespace MoneyMap.Services
return ReceiptUploadResult.Failure("This receipt has already been uploaded for this transaction."); return ReceiptUploadResult.Failure("This receipt has already been uploaded for this transaction.");
} }
// Check for potential duplicates (same hash, same name+size)
var duplicateWarnings = await CheckForDuplicatesAsync(fileHash, file.FileName, file.Length);
// Generate unique filename // Generate unique filename
var storedFileName = $"{transactionId?.ToString() ?? "unmapped"}_{Guid.NewGuid()}{extension}"; var storedFileName = $"{transactionId?.ToString() ?? "unmapped"}_{Guid.NewGuid()}{extension}";
var filePath = Path.Combine(receiptsBasePath, storedFileName); var filePath = Path.Combine(receiptsBasePath, storedFileName);
@@ -125,7 +128,55 @@ namespace MoneyMap.Services
_db.Receipts.Add(receipt); _db.Receipts.Add(receipt);
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
return ReceiptUploadResult.Success(receipt); return ReceiptUploadResult.Success(receipt, duplicateWarnings);
}
private async Task<List<DuplicateWarning>> CheckForDuplicatesAsync(string fileHash, string fileName, long fileSize)
{
var warnings = new List<DuplicateWarning>();
// Check for receipts with same hash
var hashMatches = await _db.Receipts
.Include(r => r.Transaction)
.Where(r => r.FileHashSha256 == fileHash)
.ToListAsync();
foreach (var match in hashMatches)
{
warnings.Add(new DuplicateWarning
{
ReceiptId = match.Id,
FileName = match.FileName,
UploadedAtUtc = match.UploadedAtUtc,
TransactionId = match.TransactionId,
TransactionName = match.Transaction?.Name,
Reason = "Identical file content (same hash)"
});
}
// Check for receipts with same name and size (but different hash - might be resaved/edited)
if (!warnings.Any())
{
var nameAndSizeMatches = await _db.Receipts
.Include(r => r.Transaction)
.Where(r => r.FileName == fileName && r.FileSizeBytes == fileSize)
.ToListAsync();
foreach (var match in nameAndSizeMatches)
{
warnings.Add(new DuplicateWarning
{
ReceiptId = match.Id,
FileName = match.FileName,
UploadedAtUtc = match.UploadedAtUtc,
TransactionId = match.TransactionId,
TransactionName = match.Transaction?.Name,
Reason = "Same file name and size"
});
}
}
return warnings;
} }
public async Task<bool> MapReceiptToTransactionAsync(long receiptId, long transactionId) public async Task<bool> MapReceiptToTransactionAsync(long receiptId, long transactionId)
@@ -227,11 +278,22 @@ namespace MoneyMap.Services
public bool IsSuccess { get; init; } public bool IsSuccess { get; init; }
public Receipt? Receipt { get; init; } public Receipt? Receipt { get; init; }
public string? ErrorMessage { get; init; } public string? ErrorMessage { get; init; }
public List<DuplicateWarning> DuplicateWarnings { get; init; } = new();
public static ReceiptUploadResult Success(Receipt receipt) => public static ReceiptUploadResult Success(Receipt receipt, List<DuplicateWarning>? warnings = null) =>
new() { IsSuccess = true, Receipt = receipt }; new() { IsSuccess = true, Receipt = receipt, DuplicateWarnings = warnings ?? new() };
public static ReceiptUploadResult Failure(string error) => public static ReceiptUploadResult Failure(string error) =>
new() { IsSuccess = false, ErrorMessage = error }; new() { IsSuccess = false, ErrorMessage = error };
} }
public class DuplicateWarning
{
public long ReceiptId { get; set; }
public string FileName { get; set; } = "";
public DateTime UploadedAtUtc { get; set; }
public long? TransactionId { get; set; }
public string? TransactionName { get; set; }
public string Reason { get; set; } = "";
}
} }