feat(extraordinary-expenses): Add module for extraordinary expenses

Introduces a new module to manage and track extraordinary expenses within condominiums. This includes defining expense items, sharing arrangements, and attaching relevant documents.

The module adds new types for `ExpenseItem`, `ExpenseShare`, and `ExtraordinaryExpense`. Mock database functions are updated to support fetching, creating, and managing these expenses. UI components in `Layout.tsx` and `Settings.tsx` are modified to include navigation and feature toggling for extraordinary expenses. Additionally, new routes are added in `App.tsx` for both administrative and user-facing views of these expenses.
This commit is contained in:
2025-12-09 23:00:05 +01:00
parent 048180db75
commit fa12a8de85
13 changed files with 918 additions and 77 deletions

View File

@@ -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"]

View File

@@ -46,7 +46,12 @@ const dbInterface = {
if (DB_CLIENT === 'postgres') {
return {
query: executeQuery,
release: () => {}
release: () => {},
// Mock transaction methods for Postgres simple wrapper
// In a real prod app, you would get a specific client from the pool here
beginTransaction: async () => {},
commit: async () => {},
rollback: async () => {}
};
} else {
return await mysqlPool.getConnection();
@@ -347,6 +352,58 @@ const initDb = async () => {
)
`);
// 11. Extraordinary Expenses Tables
await connection.query(`
CREATE TABLE IF NOT EXISTS extraordinary_expenses (
id VARCHAR(36) PRIMARY KEY,
condo_id VARCHAR(36) NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
start_date ${TIMESTAMP_TYPE},
end_date ${TIMESTAMP_TYPE},
contractor_name VARCHAR(255),
total_amount DECIMAL(15, 2) DEFAULT 0,
created_at ${TIMESTAMP_TYPE} DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (condo_id) REFERENCES condos(id) ON DELETE CASCADE
)
`);
await connection.query(`
CREATE TABLE IF NOT EXISTS expense_items (
id VARCHAR(36) PRIMARY KEY,
expense_id VARCHAR(36) NOT NULL,
description VARCHAR(255),
amount DECIMAL(15, 2) NOT NULL,
FOREIGN KEY (expense_id) REFERENCES extraordinary_expenses(id) ON DELETE CASCADE
)
`);
await connection.query(`
CREATE TABLE IF NOT EXISTS expense_shares (
id VARCHAR(36) PRIMARY KEY,
expense_id VARCHAR(36) NOT NULL,
family_id VARCHAR(36) NOT NULL,
percentage DECIMAL(5, 2) NOT NULL,
amount_due DECIMAL(15, 2) NOT NULL,
amount_paid DECIMAL(15, 2) DEFAULT 0,
status VARCHAR(20) DEFAULT 'UNPAID',
FOREIGN KEY (expense_id) REFERENCES extraordinary_expenses(id) ON DELETE CASCADE,
FOREIGN KEY (family_id) REFERENCES families(id) ON DELETE CASCADE
)
`);
await connection.query(`
CREATE TABLE IF NOT EXISTS expense_attachments (
id VARCHAR(36) PRIMARY KEY,
expense_id VARCHAR(36) NOT NULL,
file_name VARCHAR(255) NOT NULL,
file_type VARCHAR(100),
data ${LONG_TEXT_TYPE},
created_at ${TIMESTAMP_TYPE} DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (expense_id) REFERENCES extraordinary_expenses(id) ON DELETE CASCADE
)
`);
// --- SEEDING ---
const [rows] = await connection.query('SELECT * FROM settings WHERE id = 1');
const defaultFeatures = {
@@ -354,7 +411,8 @@ const initDb = async () => {
tickets: true,
payPal: true,
notices: true,
reports: true
reports: true,
extraordinaryExpenses: true
};
if (rows.length === 0) {

View File

@@ -235,7 +235,7 @@ app.post('/api/condos', authenticateToken, requireAdmin, async (req, res) => {
try {
await pool.query(
'INSERT INTO condos (id, name, address, street_number, city, province, zip_code, notes, default_monthly_quota, paypal_client_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[id, name, address, streetNumber, city, province, zip_code, notes, defaultMonthlyQuota, paypalClientId]
[id, name, address, streetNumber, city, province, zipCode, notes, defaultMonthlyQuota, paypalClientId]
);
res.json({ id, name, address, streetNumber, city, province, zipCode, notes, defaultMonthlyQuota, paypalClientId });
} catch (e) { res.status(500).json({ error: e.message }); }
@@ -850,6 +850,215 @@ app.delete('/api/tickets/:id', authenticateToken, async (req, res) => {
} catch (e) { res.status(500).json({ error: e.message }); }
});
// --- EXTRAORDINARY EXPENSES ---
app.get('/api/expenses', authenticateToken, async (req, res) => {
const { condoId } = req.query;
try {
const [expenses] = await pool.query('SELECT * FROM extraordinary_expenses WHERE condo_id = ? ORDER BY created_at DESC', [condoId]);
res.json(expenses.map(e => ({
id: e.id,
condoId: e.condo_id,
title: e.title,
description: e.description,
startDate: e.start_date,
endDate: e.end_date,
contractorName: e.contractor_name,
totalAmount: parseFloat(e.total_amount),
createdAt: e.created_at
})));
} catch(e) { res.status(500).json({ error: e.message }); }
});
app.get('/api/expenses/:id', authenticateToken, async (req, res) => {
try {
const [expenses] = await pool.query('SELECT * FROM extraordinary_expenses WHERE id = ?', [req.params.id]);
if (expenses.length === 0) return res.status(404).json({ message: 'Expense not found' });
const expense = expenses[0];
// Fetch Items
const [items] = await pool.query('SELECT id, description, amount FROM expense_items WHERE expense_id = ?', [expense.id]);
// Fetch Shares
const [shares] = await pool.query(`
SELECT s.id, s.family_id, f.name as family_name, s.percentage, s.amount_due, s.amount_paid, s.status
FROM expense_shares s
JOIN families f ON s.family_id = f.id
WHERE s.expense_id = ?
`, [expense.id]);
// Fetch Attachments (light)
const [attachments] = await pool.query('SELECT id, file_name, file_type FROM expense_attachments WHERE expense_id = ?', [expense.id]);
res.json({
id: expense.id,
condoId: expense.condo_id,
title: expense.title,
description: expense.description,
startDate: expense.start_date,
endDate: expense.end_date,
contractorName: expense.contractor_name,
totalAmount: parseFloat(expense.total_amount),
createdAt: expense.created_at,
items: items.map(i => ({ id: i.id, description: i.description, amount: parseFloat(i.amount) })),
shares: shares.map(s => ({
id: s.id,
familyId: s.family_id,
familyName: s.family_name,
percentage: parseFloat(s.percentage),
amountDue: parseFloat(s.amount_due),
amountPaid: parseFloat(s.amount_paid),
status: s.status
})),
attachments: attachments.map(a => ({ id: a.id, fileName: a.file_name, fileType: a.file_type }))
});
} catch(e) { res.status(500).json({ error: e.message }); }
});
app.post('/api/expenses', authenticateToken, requireAdmin, async (req, res) => {
const { condoId, title, description, startDate, endDate, contractorName, items, shares, attachments } = req.body;
const expenseId = uuidv4();
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
// Calculate total
const totalAmount = items.reduce((sum, i) => sum + parseFloat(i.amount), 0);
// Insert Expense
await connection.query(
'INSERT INTO extraordinary_expenses (id, condo_id, title, description, start_date, end_date, contractor_name, total_amount) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[expenseId, condoId, title, description, startDate, endDate, contractorName, totalAmount]
);
// Insert Items
for (const item of items) {
await connection.query(
'INSERT INTO expense_items (id, expense_id, description, amount) VALUES (?, ?, ?, ?)',
[uuidv4(), expenseId, item.description, item.amount]
);
}
// Insert Shares
for (const share of shares) {
await connection.query(
'INSERT INTO expense_shares (id, expense_id, family_id, percentage, amount_due, status) VALUES (?, ?, ?, ?, ?, ?)',
[uuidv4(), expenseId, share.familyId, share.percentage, share.amountDue, 'UNPAID']
);
}
// Insert Attachments
if (attachments && attachments.length > 0) {
for (const att of attachments) {
await connection.query(
'INSERT INTO expense_attachments (id, expense_id, file_name, file_type, data) VALUES (?, ?, ?, ?, ?)',
[uuidv4(), expenseId, att.fileName, att.fileType, att.data]
);
}
}
await connection.commit();
res.json({ success: true, id: expenseId });
} catch (e) {
await connection.rollback();
console.error(e);
res.status(500).json({ error: e.message });
} finally {
connection.release();
}
});
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]);
if (rows.length === 0) return res.status(404).json({ message: 'File not found' });
const file = rows[0];
res.json({ id: file.id, fileName: file.file_name, fileType: file.file_type, data: file.data });
} catch(e) { res.status(500).json({ error: e.message }); }
});
app.post('/api/expenses/:id/pay', authenticateToken, async (req, res) => {
const { amount, notes } = req.body;
const expenseId = req.params.id;
const userId = req.user.id;
const connection = await pool.getConnection();
try {
// Find user's family
const [users] = await connection.query('SELECT family_id FROM users WHERE id = ?', [userId]);
if (users.length === 0 || !users[0].family_id) return res.status(400).json({ message: 'User has no family assigned' });
const familyId = users[0].family_id;
// Find share
const [shares] = await connection.query('SELECT * FROM expense_shares WHERE expense_id = ? AND family_id = ?', [expenseId, familyId]);
if (shares.length === 0) return res.status(404).json({ message: 'No share found for this expense' });
const share = shares[0];
const newPaid = parseFloat(share.amount_paid) + parseFloat(amount);
const due = parseFloat(share.amount_due);
let status = 'PARTIAL';
if (newPaid >= due - 0.01) status = 'PAID'; // Tolerance for float
await connection.beginTransaction();
// Update Share
await connection.query('UPDATE expense_shares SET amount_paid = ?, status = ? WHERE id = ?', [newPaid, status, share.id]);
// Also record in global payments for visibility in reports (optional but requested to track family payments)
// We use a special month/year or notes to distinguish
await connection.query(
'INSERT INTO payments (id, family_id, amount, date_paid, for_month, for_year, notes) VALUES (?, ?, ?, NOW(), 13, YEAR(NOW()), ?)',
[uuidv4(), familyId, amount, `Spesa Straordinaria: ${notes || 'PayPal'}`]
);
await connection.commit();
res.json({ success: true });
} catch (e) {
await connection.rollback();
res.status(500).json({ error: e.message });
} finally {
connection.release();
}
});
// Get User's Expenses
app.get('/api/my-expenses', authenticateToken, async (req, res) => {
const userId = req.user.id;
const { condoId } = req.query; // Optional filter if user belongs to multiple condos (unlikely in current logic but safe)
try {
const [users] = await pool.query('SELECT family_id FROM users WHERE id = ?', [userId]);
if (!users[0]?.family_id) return res.json([]);
const familyId = users[0].family_id;
const [rows] = await pool.query(`
SELECT e.id, e.title, e.total_amount, e.start_date, e.end_date, s.amount_due, s.amount_paid, s.status, s.percentage
FROM expense_shares s
JOIN extraordinary_expenses e ON s.expense_id = e.id
WHERE s.family_id = ? AND e.condo_id = ?
ORDER BY e.created_at DESC
`, [familyId, condoId]); // Ensure we only get expenses for the active condo context if needed
res.json(rows.map(r => ({
id: r.id,
title: r.title,
totalAmount: parseFloat(r.total_amount),
startDate: r.start_date,
endDate: r.end_date,
myShare: {
percentage: parseFloat(r.percentage),
amountDue: parseFloat(r.amount_due),
amountPaid: parseFloat(r.amount_paid),
status: r.status
}
})));
} catch (e) { res.status(500).json({ error: e.message }); }
});
initDb().then(() => {
app.listen(PORT, () => {