From 99068d671033a87e8044e81e6c061db0aeefd81e Mon Sep 17 00:00:00 2001
From: dlawler489 <104159223@student.swin.edu.au>
Date: Tue, 5 May 2026 13:40:28 +1000
Subject: [PATCH] Fix Australia Post CSV duplicate imports with comprehensive
duplicate prevention
Multi-layer duplicate prevention system:
- Enhanced frontend duplicate detection with tracking number, amount, and date comparison
- Added MongoDB compound index to prevent database-level duplicates
- Improved backend error handling for duplicate key violations
- Added cleanup endpoint to remove existing duplicates
- Enhanced user feedback for import operations
Frontend changes:
- Stricter duplicate detection comparing tracking number, vendor, amount, and date
- Better error handling and user feedback for duplicate scenarios
- Added 'Clean Duplicates' button to remove existing duplicates
Backend changes:
- Database compound index on reference, vendor, userId, amount, date
- Enhanced error responses with duplicate detection flags
- New POST /expenses/cleanup-duplicates endpoint
- Improved duplicate key error handling
This should eliminate the double Australia Post expense entries.
---
client/src/pages/DataImport.tsx | 31 +++++++++---
client/src/pages/Expenses.tsx | 35 ++++++++++++++
server/src/models/Expense.ts | 15 ++++++
server/src/routes/expenses.ts | 84 ++++++++++++++++++++++++++++++++-
4 files changed, 157 insertions(+), 8 deletions(-)
diff --git a/client/src/pages/DataImport.tsx b/client/src/pages/DataImport.tsx
index 0b3aecc..e9a2156 100644
--- a/client/src/pages/DataImport.tsx
+++ b/client/src/pages/DataImport.tsx
@@ -146,12 +146,14 @@ export default function DataImport() {
}
});
- // Create shipping expenses (check for duplicates by tracking number)
+ // Create shipping expenses (check for duplicates by tracking number, date, and amount)
shippingRecords.forEach(shipping => {
if (shipping.totalCost > 0) {
const isDuplicate = existingExpenses.some((expense: any) =>
expense.reference === shipping.trackingNumber &&
- expense.vendor === 'Australia Post'
+ expense.vendor === 'Australia Post' &&
+ Math.abs(expense.amount - shipping.totalCost) < 0.01 && // Compare amounts (allow for floating point precision)
+ new Date(expense.date).toDateString() === new Date(shipping.date).toDateString() // Compare dates
);
if (!isDuplicate) {
@@ -164,6 +166,8 @@ export default function DataImport() {
vendor: 'Australia Post',
reference: shipping.trackingNumber
});
+ } else {
+ console.log(`Skipping duplicate shipping expense: ${shipping.trackingNumber} - $${shipping.totalCost} on ${shipping.date}`);
}
}
});
@@ -172,22 +176,37 @@ export default function DataImport() {
if (expensesToCreate.length > 0) {
const savedExpenses = [];
let created = 0;
+ let skippedDuplicates = 0;
for (const expense of expensesToCreate) {
try {
const res = await api.post('/expenses', expense);
savedExpenses.push(res.data);
created++;
- } catch (error) {
- console.error('Failed to create expense:', expense, error);
+ } catch (error: any) {
+ // Check if it's a duplicate error (MongoDB duplicate key error code 11000)
+ if (error.response?.status === 400 && error.response?.data?.message?.includes('duplicate')) {
+ console.log(`Skipping duplicate expense at database level:`, expense.description);
+ skippedDuplicates++;
+ } else {
+ console.error('Failed to create expense:', expense, error);
+ }
}
}
if (savedExpenses.length > 0) {
dispatch(addExpenses(savedExpenses));
+ }
+
+ // Provide informative feedback
+ if (created > 0 && skippedDuplicates > 0) {
+ toast.success(`Created ${created} new expenses. Skipped ${skippedDuplicates} duplicates.`);
+ } else if (created > 0) {
toast.success(`Created ${created} expenses from CSV data (Etsy fees + shipping costs)`);
- } else if (expensesToCreate.length === 0) {
- toast.success('All expenses from CSV data already exist - no duplicates created');
+ } else if (skippedDuplicates > 0) {
+ toast.success(`All ${skippedDuplicates} expenses already exist - no duplicates created`);
+ } else {
+ toast.error('No expenses were created. Check console for errors.');
}
} else {
toast.success('No new expenses to create from CSV data');
diff --git a/client/src/pages/Expenses.tsx b/client/src/pages/Expenses.tsx
index 561e401..91bd0b7 100644
--- a/client/src/pages/Expenses.tsx
+++ b/client/src/pages/Expenses.tsx
@@ -285,6 +285,34 @@ const Expenses = () => {
});
};
+ const handleCleanupDuplicates = async () => {
+ if (!confirm('This will remove duplicate Australia Post shipping expenses, keeping only the earliest created record for each tracking number. This action cannot be undone. Continue?')) {
+ return;
+ }
+
+ try {
+ const response = await api.post('/expenses/cleanup-duplicates');
+ const { deletedCount, duplicateGroups, details } = response.data;
+
+ if (deletedCount > 0) {
+ toast.success(`Successfully removed ${deletedCount} duplicate expenses from ${duplicateGroups} tracking numbers`);
+
+ // Refresh expenses from server
+ const updatedResponse = await api.get('/expenses?limit=1000');
+ if (updatedResponse.data.expenses) {
+ dispatch(addExpenses(updatedResponse.data.expenses));
+ }
+
+ console.log('Cleanup details:', details);
+ } else {
+ toast.success('No duplicate Australia Post expenses found');
+ }
+ } catch (error) {
+ console.error('Cleanup failed:', error);
+ toast.error('Failed to cleanup duplicate expenses');
+ }
+ };
+
const filteredExpenses = expenses
.filter(expense => {
const matchesSearch = expense.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
@@ -363,6 +391,13 @@ const Expenses = () => {
Export CSV
+