etsy-finance-tracker/server/src/routes/expenses.ts
dlawler489 99068d6710 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.
2026-05-05 13:40:28 +10:00

149 lines
4.9 KiB
TypeScript

import { Router, Response } from 'express';
import Expense from '../models/Expense';
import { AuthRequest } from '../middleware/authenticate';
const router = Router();
router.get('/', async (req: AuthRequest, res: Response) => {
try {
const { page = 1, limit = 20, category } = req.query;
const filter: any = { userId: req.userId };
if (category) filter.category = category;
const expenses = await Expense.find(filter)
.sort({ date: -1 })
.limit(Number(limit))
.skip((Number(page) - 1) * Number(limit));
const total = await Expense.countDocuments(filter);
res.json({ expenses, total, page: Number(page), limit: Number(limit) });
} catch (err) {
res.status(500).json({ message: 'Failed to fetch expenses', error: err });
}
});
router.post('/', async (req: AuthRequest, res: Response) => {
try {
const expense = new Expense({ ...req.body, userId: req.userId });
await expense.save();
res.status(201).json(expense);
} catch (err: any) {
// 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 });
}
}
});
router.get('/:id', async (req: AuthRequest, res: Response) => {
try {
const expense = await Expense.findOne({ _id: req.params.id, userId: req.userId });
if (!expense) return res.status(404).json({ message: 'Expense not found' });
res.json(expense);
} catch (err) {
res.status(500).json({ message: 'Failed to fetch expense', error: err });
}
});
router.put('/:id', async (req: AuthRequest, res: Response) => {
try {
const expense = await Expense.findOneAndUpdate(
{ _id: req.params.id, userId: req.userId },
req.body,
{ new: true, runValidators: true }
);
if (!expense) return res.status(404).json({ message: 'Expense not found' });
res.json(expense);
} catch (err) {
res.status(400).json({ message: 'Failed to update expense', error: err });
}
});
// 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) => {
try {
const expense = await Expense.findOneAndDelete({ _id: req.params.id, userId: req.userId });
if (!expense) return res.status(404).json({ message: 'Expense not found' });
res.json({ message: 'Expense deleted' });
} catch (err) {
res.status(500).json({ message: 'Failed to delete expense', error: err });
}
});
export default router;