feat(expenses): Add delete expense endpoint and functionality
Implements the ability to delete an expense, including its associated items and shares. Also refactors the expense update logic to correctly handle share updates and adds the corresponding API endpoint and mock DB function.
This commit is contained in:
@@ -1,13 +0,0 @@
|
||||
FROM node:18-alpine
|
||||
WORKDIR /app
|
||||
|
||||
# Set production environment
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install --production
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3001
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@@ -867,7 +867,7 @@ app.post('/api/expenses', authenticateToken, requireAdmin, async (req, res) => {
|
||||
|
||||
// Update Expense
|
||||
app.put('/api/expenses/:id', authenticateToken, requireAdmin, async (req, res) => {
|
||||
const { title, description, startDate, endDate, contractorName, items } = req.body;
|
||||
const { title, description, startDate, endDate, contractorName, items, shares } = req.body;
|
||||
const expenseId = req.params.id;
|
||||
const connection = await pool.getConnection();
|
||||
|
||||
@@ -892,28 +892,59 @@ app.put('/api/expenses/:id', authenticateToken, requireAdmin, async (req, res) =
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Update Shares (Recalculate Due Amount based on stored percentage vs new Total)
|
||||
// We do NOT reset paid amount. We check if new due is covered by paid.
|
||||
|
||||
// This query updates amount_due based on percentage and new total.
|
||||
// Then updates status:
|
||||
// - If paid >= due -> PAID
|
||||
// - If paid > 0 but < due -> PARTIAL
|
||||
// - Else -> UNPAID
|
||||
|
||||
const updateSharesQuery = `
|
||||
UPDATE expense_shares
|
||||
SET
|
||||
amount_due = (percentage * ? / 100),
|
||||
status = CASE
|
||||
WHEN amount_paid >= (percentage * ? / 100) - 0.01 THEN 'PAID'
|
||||
WHEN amount_paid > 0 THEN 'PARTIAL'
|
||||
ELSE 'UNPAID'
|
||||
END
|
||||
WHERE expense_id = ?
|
||||
`;
|
||||
|
||||
await connection.query(updateSharesQuery, [newTotalAmount, newTotalAmount, expenseId]);
|
||||
// 4. Update Shares (Complex Logic: Sync requested shares with DB)
|
||||
// If shares are provided in the update:
|
||||
if (shares && shares.length > 0) {
|
||||
// Get current DB shares to delete removed ones
|
||||
const [currentDbShares] = await connection.query('SELECT family_id FROM expense_shares WHERE expense_id = ?', [expenseId]);
|
||||
const currentFamilyIds = currentDbShares.map(s => s.family_id);
|
||||
const newFamilyIds = shares.map(s => s.familyId);
|
||||
|
||||
// A. Delete shares for families removed from list
|
||||
const toDelete = currentFamilyIds.filter(fid => !newFamilyIds.includes(fid));
|
||||
if (toDelete.length > 0) {
|
||||
// Construct placeholder string (?,?,?)
|
||||
const placeholders = toDelete.map(() => '?').join(',');
|
||||
await connection.query(`DELETE FROM expense_shares WHERE expense_id = ? AND family_id IN (${placeholders})`, [expenseId, ...toDelete]);
|
||||
}
|
||||
|
||||
// B. Upsert (Update existing or Insert new)
|
||||
// We use ON DUPLICATE KEY UPDATE logic manually or loop since we need to respect 'amount_paid'
|
||||
for (const share of shares) {
|
||||
// Check if exists
|
||||
const [existing] = await connection.query('SELECT amount_paid FROM expense_shares WHERE expense_id = ? AND family_id = ?', [expenseId, share.familyId]);
|
||||
|
||||
if (existing.length > 0) {
|
||||
// Update
|
||||
const currentPaid = parseFloat(existing[0].amount_paid);
|
||||
let newStatus = 'UNPAID';
|
||||
if (currentPaid >= share.amountDue - 0.01) newStatus = 'PAID';
|
||||
else if (currentPaid > 0) newStatus = 'PARTIAL';
|
||||
|
||||
await connection.query(
|
||||
'UPDATE expense_shares SET percentage = ?, amount_due = ?, status = ? WHERE expense_id = ? AND family_id = ?',
|
||||
[share.percentage, share.amountDue, newStatus, expenseId, share.familyId]
|
||||
);
|
||||
} else {
|
||||
// Insert
|
||||
await connection.query(
|
||||
'INSERT INTO expense_shares (id, expense_id, family_id, percentage, amount_due, amount_paid, status) VALUES (?, ?, ?, ?, ?, 0, ?)',
|
||||
[uuidv4(), expenseId, share.familyId, share.percentage, share.amountDue, 'UNPAID']
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If no shares provided (empty list), maybe we should clear all?
|
||||
// Or maybe it means "don't touch shares".
|
||||
// Based on frontend logic, empty list means "remove all assignments".
|
||||
// But usually we don't send empty list if we just edited header.
|
||||
// Assuming the frontend sends the full current state of shares.
|
||||
// If explicit empty array is sent, we delete all.
|
||||
// If undefined/null, we do nothing (backward compatibility).
|
||||
if (Array.isArray(shares)) {
|
||||
await connection.query('DELETE FROM expense_shares WHERE expense_id = ?', [expenseId]);
|
||||
}
|
||||
}
|
||||
|
||||
await connection.commit();
|
||||
res.json({ success: true });
|
||||
@@ -927,6 +958,15 @@ app.put('/api/expenses/:id', authenticateToken, requireAdmin, async (req, res) =
|
||||
}
|
||||
});
|
||||
|
||||
// Delete Expense
|
||||
app.delete('/api/expenses/:id', authenticateToken, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
await pool.query('DELETE FROM extraordinary_expenses WHERE id = ?', [req.params.id]);
|
||||
// Foreign keys set to ON DELETE CASCADE should handle children (items, shares, attachments)
|
||||
res.json({ success: true });
|
||||
} catch(e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
app.get('/api/expenses/:id/attachments/:attachmentId', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const [rows] = await pool.query('SELECT * FROM expense_attachments WHERE id = ? AND expense_id = ?', [req.params.attachmentId, req.params.id]);
|
||||
|
||||
Reference in New Issue
Block a user