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:
@@ -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">
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -47,9 +58,18 @@ namespace MoneyMap.Pages
|
|||||||
var result = await _receiptManager.UploadUnmappedReceiptAsync(UploadFile);
|
var result = await _receiptManager.UploadUnmappedReceiptAsync(UploadFile);
|
||||||
|
|
||||||
if (result.IsSuccess)
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
if (result.DuplicateWarnings.Any())
|
||||||
|
{
|
||||||
|
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!";
|
Message = "Receipt uploaded successfully!";
|
||||||
IsSuccess = true;
|
IsSuccess = true;
|
||||||
|
}
|
||||||
return RedirectToPage();
|
return RedirectToPage();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -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; } = "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user