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 +