zmiany
This commit is contained in:
159
api/src/controllers/budget.controller.ts
Normal file
159
api/src/controllers/budget.controller.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
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();
|
||||
};
|
||||
Reference in New Issue
Block a user