import type { Response } from 'express'; import { z } from 'zod'; import { AppDataSource } from '../config/data-source.js'; import { Budget } from '../entities/Budget.js'; import { Category } from '../entities/Category.js'; import { Expense } from '../entities/Expense.js'; import type { AuthenticatedRequest } from '../types/express.js'; import { processDueRecurringExpenses } from '../services/recurring.service.js'; const budgetSchema = z.object({ month: z.string().regex(/^\d{4}-\d{2}$/), name: z.string().max(120).nullable().optional(), amount: z.coerce.number().positive(), categoryId: z.string().uuid().nullable().optional(), alertThresholds: z.array(z.coerce.number().min(1).max(100)).default([80, 100]), isActive: z.boolean().default(true) }); const budgetRepo = () => AppDataSource.getRepository(Budget); const categoryRepo = () => AppDataSource.getRepository(Category); const expenseRepo = () => AppDataSource.getRepository(Expense); const serializeBudget = (item: Budget, spent: number) => { const amount = Number(item.amount.toFixed(2)); const usagePercent = amount ? Number(((spent / amount) * 100).toFixed(1)) : 0; const alertLevel = (item.alertThresholds ?? [80, 100]).filter((threshold) => usagePercent >= threshold).sort((a, b) => b - a)[0] ?? null; return { id: item.id, month: item.month, name: item.name, amount, spent: Number(spent.toFixed(2)), usagePercent, alertLevel, alertThresholds: item.alertThresholds ?? [80, 100], isActive: item.isActive, category: item.category ? { id: item.category.id, name: item.category.name, color: item.category.color, isSystem: item.category.isSystem, ownerId: item.category.user?.id ?? null } : null, createdAt: item.createdAt, updatedAt: item.updatedAt }; }; const getMonthRange = (month: string) => ({ startDate: `${month}-01`, endDate: `${month}-31` }); export const listBudgets = async (req: AuthenticatedRequest, res: Response) => { await processDueRecurringExpenses(req.user!.id); const month = typeof req.query.month === 'string' && /^\d{4}-\d{2}$/.test(req.query.month) ? req.query.month : `${new Date().getFullYear()}-${`${new Date().getMonth() + 1}`.padStart(2, '0')}`; const budgets = await budgetRepo().find({ where: { user: { id: req.user!.id } }, relations: { category: { user: true }, user: true }, order: { month: 'DESC', createdAt: 'DESC' } }); const monthBudgets = budgets.filter((item) => item.month === month); const { startDate, endDate } = getMonthRange(month); const expenses = await expenseRepo().find({ where: { user: { id: req.user!.id } }, relations: { category: true } }); const scopedExpenses = expenses.filter((item) => item.expenseDate >= startDate && item.expenseDate <= endDate && item.status !== 'REJECTED' && item.status !== 'DRAFT'); const items = monthBudgets.map((item) => { const spent = scopedExpenses .filter((expense) => !item.category || expense.category.id === item.category.id) .reduce((sum, expense) => sum + expense.amount, 0); return serializeBudget(item, spent); }); const alerts = items.filter((item) => item.alertLevel !== null).map((item) => ({ budgetId: item.id, message: `${item.name || item.category?.name || 'Monthly budget'} reached ${item.usagePercent}% of the limit.`, usagePercent: item.usagePercent, level: item.alertLevel })); return res.json({ month, items, summary: { totalBudget: Number(items.reduce((sum, item) => sum + item.amount, 0).toFixed(2)), totalSpent: Number(items.reduce((sum, item) => sum + item.spent, 0).toFixed(2)), alerts } }); }; export const createBudget = async (req: AuthenticatedRequest, res: Response) => { const parsed = budgetSchema.safeParse(req.body); if (!parsed.success) return res.status(400).json({ message: 'Invalid budget payload', issues: parsed.error.issues }); const category = parsed.data.categoryId ? await categoryRepo().findOne({ where: [{ id: parsed.data.categoryId, isSystem: true }, { id: parsed.data.categoryId, user: { id: req.user!.id } }], relations: { user: true } }) : null; if (parsed.data.categoryId && !category) return res.status(404).json({ message: 'Category not found' }); const saved = await budgetRepo().save( budgetRepo().create({ month: parsed.data.month, name: parsed.data.name ?? null, amount: parsed.data.amount, alertThresholds: parsed.data.alertThresholds, isActive: parsed.data.isActive, category, user: { id: req.user!.id } as never }) ); const full = await budgetRepo().findOneOrFail({ where: { id: saved.id }, relations: { category: { user: true }, user: true } }); return res.status(201).json({ item: serializeBudget(full, 0) }); }; export const updateBudget = async (req: AuthenticatedRequest, res: Response) => { const parsed = budgetSchema.safeParse(req.body); if (!parsed.success) return res.status(400).json({ message: 'Invalid budget payload', issues: parsed.error.issues }); const item = await budgetRepo().findOne({ where: { id: String(req.params.id), user: { id: req.user!.id } }, relations: { category: { user: true }, user: true } }); if (!item) return res.status(404).json({ message: 'Budget not found' }); const category = parsed.data.categoryId ? await categoryRepo().findOne({ where: [{ id: parsed.data.categoryId, isSystem: true }, { id: parsed.data.categoryId, user: { id: req.user!.id } }], relations: { user: true } }) : null; if (parsed.data.categoryId && !category) return res.status(404).json({ message: 'Category not found' }); item.month = parsed.data.month; item.name = parsed.data.name ?? null; item.amount = parsed.data.amount; item.alertThresholds = parsed.data.alertThresholds; item.isActive = parsed.data.isActive; item.category = category; await budgetRepo().save(item); return res.json({ item: serializeBudget(item, 0) }); }; export const deleteBudget = async (req: AuthenticatedRequest, res: Response) => { const item = await budgetRepo().findOne({ where: { id: String(req.params.id), user: { id: req.user!.id } } }); if (!item) return res.status(404).json({ message: 'Budget not found' }); await budgetRepo().remove(item); return res.status(204).send(); };