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.
149 lines
4.9 KiB
TypeScript
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;
|