160 lines
6.3 KiB
TypeScript
160 lines
6.3 KiB
TypeScript
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();
|
|
};
|