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.
This commit is contained in:
parent
761fce047a
commit
99068d6710
4 changed files with 157 additions and 8 deletions
|
|
@ -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 => {
|
shippingRecords.forEach(shipping => {
|
||||||
if (shipping.totalCost > 0) {
|
if (shipping.totalCost > 0) {
|
||||||
const isDuplicate = existingExpenses.some((expense: any) =>
|
const isDuplicate = existingExpenses.some((expense: any) =>
|
||||||
expense.reference === shipping.trackingNumber &&
|
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) {
|
if (!isDuplicate) {
|
||||||
|
|
@ -164,6 +166,8 @@ export default function DataImport() {
|
||||||
vendor: 'Australia Post',
|
vendor: 'Australia Post',
|
||||||
reference: shipping.trackingNumber
|
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) {
|
if (expensesToCreate.length > 0) {
|
||||||
const savedExpenses = [];
|
const savedExpenses = [];
|
||||||
let created = 0;
|
let created = 0;
|
||||||
|
let skippedDuplicates = 0;
|
||||||
|
|
||||||
for (const expense of expensesToCreate) {
|
for (const expense of expensesToCreate) {
|
||||||
try {
|
try {
|
||||||
const res = await api.post('/expenses', expense);
|
const res = await api.post('/expenses', expense);
|
||||||
savedExpenses.push(res.data);
|
savedExpenses.push(res.data);
|
||||||
created++;
|
created++;
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error('Failed to create expense:', expense, error);
|
// 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) {
|
if (savedExpenses.length > 0) {
|
||||||
dispatch(addExpenses(savedExpenses));
|
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)`);
|
toast.success(`Created ${created} expenses from CSV data (Etsy fees + shipping costs)`);
|
||||||
} else if (expensesToCreate.length === 0) {
|
} else if (skippedDuplicates > 0) {
|
||||||
toast.success('All expenses from CSV data already exist - no duplicates created');
|
toast.success(`All ${skippedDuplicates} expenses already exist - no duplicates created`);
|
||||||
|
} else {
|
||||||
|
toast.error('No expenses were created. Check console for errors.');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.success('No new expenses to create from CSV data');
|
toast.success('No new expenses to create from CSV data');
|
||||||
|
|
|
||||||
|
|
@ -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
|
const filteredExpenses = expenses
|
||||||
.filter(expense => {
|
.filter(expense => {
|
||||||
const matchesSearch = expense.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
const matchesSearch = expense.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
|
@ -363,6 +391,13 @@ const Expenses = () => {
|
||||||
<Download className="w-4 h-4" />
|
<Download className="w-4 h-4" />
|
||||||
Export CSV
|
Export CSV
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCleanupDuplicates}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
Clean Duplicates
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddForm(true)}
|
onClick={() => setShowAddForm(true)}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
|
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
|
||||||
|
|
|
||||||
|
|
@ -35,4 +35,19 @@ ExpenseSchema.pre('save', function (next) {
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Create compound index to prevent duplicate expenses with same reference, vendor, and user
|
||||||
|
ExpenseSchema.index({
|
||||||
|
reference: 1,
|
||||||
|
vendor: 1,
|
||||||
|
userId: 1,
|
||||||
|
amount: 1,
|
||||||
|
date: 1
|
||||||
|
}, {
|
||||||
|
unique: true,
|
||||||
|
partialFilterExpression: {
|
||||||
|
reference: { $exists: true, $ne: null },
|
||||||
|
vendor: { $exists: true, $ne: null }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default mongoose.model<IExpense>('Expense', ExpenseSchema);
|
export default mongoose.model<IExpense>('Expense', ExpenseSchema);
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,20 @@ router.post('/', async (req: AuthRequest, res: Response) => {
|
||||||
const expense = new Expense({ ...req.body, userId: req.userId });
|
const expense = new Expense({ ...req.body, userId: req.userId });
|
||||||
await expense.save();
|
await expense.save();
|
||||||
res.status(201).json(expense);
|
res.status(201).json(expense);
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
res.status(400).json({ message: 'Failed to create expense', error: err });
|
// Handle duplicate key error (MongoDB error code 11000)
|
||||||
|
if (err.code === 11000) {
|
||||||
|
const duplicateField = Object.keys(err.keyPattern || {})[0];
|
||||||
|
res.status(400).json({
|
||||||
|
message: `Expense with this ${duplicateField || 'reference'} already exists (duplicate prevented)`,
|
||||||
|
isDuplicate: true,
|
||||||
|
error: err
|
||||||
|
});
|
||||||
|
} else if (err.name === 'ValidationError') {
|
||||||
|
res.status(400).json({ message: 'Validation failed', error: err.message });
|
||||||
|
} else {
|
||||||
|
res.status(400).json({ message: 'Failed to create expense', error: err });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -56,6 +68,74 @@ router.put('/:id', async (req: AuthRequest, res: Response) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Clean up duplicate Australia Post expenses
|
||||||
|
router.post('/cleanup-duplicates', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
// Find all Australia Post expenses for this user
|
||||||
|
const expenses = await Expense.find({
|
||||||
|
userId: req.userId,
|
||||||
|
vendor: 'Australia Post'
|
||||||
|
}).sort({ dateCreated: 1 }); // Sort by creation date, keep earliest
|
||||||
|
|
||||||
|
// Group by tracking number (reference field)
|
||||||
|
const groupedByTracking = new Map();
|
||||||
|
expenses.forEach(expense => {
|
||||||
|
if (expense.reference) {
|
||||||
|
if (!groupedByTracking.has(expense.reference)) {
|
||||||
|
groupedByTracking.set(expense.reference, []);
|
||||||
|
}
|
||||||
|
groupedByTracking.get(expense.reference).push(expense);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find duplicates and delete all but the first (earliest) one
|
||||||
|
let deletedCount = 0;
|
||||||
|
const duplicateGroups = [];
|
||||||
|
|
||||||
|
for (const [trackingNumber, expenseGroup] of groupedByTracking) {
|
||||||
|
if (expenseGroup.length > 1) {
|
||||||
|
// Keep the first (earliest created) and delete the rest
|
||||||
|
const toKeep = expenseGroup[0];
|
||||||
|
const toDelete = expenseGroup.slice(1);
|
||||||
|
|
||||||
|
duplicateGroups.push({
|
||||||
|
trackingNumber,
|
||||||
|
total: expenseGroup.length,
|
||||||
|
keeping: {
|
||||||
|
id: toKeep._id,
|
||||||
|
date: toKeep.date,
|
||||||
|
amount: toKeep.amount,
|
||||||
|
created: toKeep.dateCreated
|
||||||
|
},
|
||||||
|
deleting: toDelete.map((exp: any) => ({
|
||||||
|
id: exp._id,
|
||||||
|
date: exp.date,
|
||||||
|
amount: exp.amount,
|
||||||
|
created: exp.dateCreated
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete the duplicates
|
||||||
|
await Expense.deleteMany({
|
||||||
|
_id: { $in: toDelete.map((exp: any) => exp._id) },
|
||||||
|
userId: req.userId
|
||||||
|
});
|
||||||
|
|
||||||
|
deletedCount += toDelete.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: `Cleaned up ${deletedCount} duplicate Australia Post expenses`,
|
||||||
|
deletedCount,
|
||||||
|
duplicateGroups: duplicateGroups.length,
|
||||||
|
details: duplicateGroups
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ message: 'Failed to cleanup duplicates', error: err });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.delete('/:id', async (req: AuthRequest, res: Response) => {
|
router.delete('/:id', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const expense = await Expense.findOneAndDelete({ _id: req.params.id, userId: req.userId });
|
const expense = await Expense.findOneAndDelete({ _id: req.params.id, userId: req.userId });
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue