Files
expense-control/api/src/controllers/budget.controller.ts
Mateusz Gruszczyński 80e181ea3f zmiany
2026-04-06 14:37:42 +02:00

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();
};