aboutsummaryrefslogtreecommitdiff
path: root/api/src/modules/dashboard
diff options
context:
space:
mode:
Diffstat (limited to 'api/src/modules/dashboard')
-rw-r--r--api/src/modules/dashboard/dashboard.routes.ts50
-rw-r--r--api/src/modules/dashboard/dashboard.service.ts177
2 files changed, 227 insertions, 0 deletions
diff --git a/api/src/modules/dashboard/dashboard.routes.ts b/api/src/modules/dashboard/dashboard.routes.ts
new file mode 100644
index 0000000..d579ef2
--- /dev/null
+++ b/api/src/modules/dashboard/dashboard.routes.ts
@@ -0,0 +1,50 @@
+import type { FastifyPluginAsync } from 'fastify';
+import { z } from 'zod';
+import * as dashboardService from './dashboard.service';
+
+const dashboardQuerySchema = z.object({
+ month: z.coerce.number().int().min(1).max(12).optional(),
+ year: z.coerce.number().int().min(2020).optional(),
+});
+
+const dashboardParamsSchema = z.object({
+ userId: z.coerce.number().int().positive(),
+});
+
+const dashboardRoutes: FastifyPluginAsync = async (fastify) => {
+ /**
+ * GET /users/:userId/dashboard
+ * Retorna o resumo financeiro completo do mês.
+ *
+ * Query params (opcionais):
+ * ?month=6&year=2025 → mês/ano específico
+ * Sem params → mês/ano atual
+ *
+ * Resposta:
+ * - Dados do usuário e salário
+ * - Lista de gastos recorrentes ativos
+ * - Lista de gastos avulsos do mês
+ * - Saldo disponível e percentual comprometido
+ */
+ fastify.get<{ Params: { userId: string }; Querystring: Record<string, string> }>(
+ '/users/:userId/dashboard',
+ async (request, reply) => {
+ const { userId } = dashboardParamsSchema.parse(request.params);
+ const query = dashboardQuerySchema.parse(request.query);
+
+ const now = new Date();
+ const month = query.month ?? now.getMonth() + 1;
+ const year = query.year ?? now.getFullYear();
+
+ const data = await dashboardService.getDashboard(userId, month, year);
+
+ if (!data) {
+ return reply.status(404).send({ error: 'Usuário não encontrado.' });
+ }
+
+ return reply.send({ data });
+ },
+ );
+};
+
+export default dashboardRoutes;
diff --git a/api/src/modules/dashboard/dashboard.service.ts b/api/src/modules/dashboard/dashboard.service.ts
new file mode 100644
index 0000000..78b1d1b
--- /dev/null
+++ b/api/src/modules/dashboard/dashboard.service.ts
@@ -0,0 +1,177 @@
+import { and, eq, gte, lte, sql } from 'drizzle-orm';
+import { db } from '@/db/client';
+import { incomes, oneTimeExpenses, recurringExpenses, users } from '@/db/schema';
+import { calcularSalarioLiquido } from '@/utils/tax-calculator';
+
+export interface DashboardData {
+ user: {
+ id: number;
+ name: string;
+ gross_salary: number;
+ net_salary_override: number | null;
+ };
+ period: {
+ month: number;
+ year: number;
+ };
+ salary: {
+ gross: number;
+ calculated_net: number;
+ effective_net: number;
+ inss_discount: number;
+ irrf_discount: number;
+ has_override: boolean;
+ };
+ expenses: {
+ recurring_total: number;
+ one_time_total: number;
+ total: number;
+ recurring_list: { id: number; name: string; amount: number; category: string }[];
+ one_time_list: {
+ id: number;
+ name: string;
+ amount: number;
+ category: string;
+ expense_date: string;
+ }[];
+ };
+ balance: {
+ available: number;
+ /** Percentual do salário líquido comprometido com despesas (0-100) */
+ expense_ratio: number;
+ };
+}
+
+export async function getDashboard(
+ userId: number,
+ referenceMonth: number,
+ referenceYear: number,
+): Promise<DashboardData | null> {
+ // 1. Busca o usuário
+ const [user] = await db
+ .select()
+ .from(users)
+ .where(eq(users.id, userId))
+ .limit(1);
+
+ if (!user) return null;
+
+ // 2. Calcula o salário via tabela INSS/IRRF
+ const taxResult = calcularSalarioLiquido(user.gross_salary);
+
+ // 3. Salário efetivo: usa override do usuário ou o calculado
+ const effectiveNet = user.net_salary_override ?? taxResult.salarioLiquidoCalculado;
+
+ // 4. Tenta encontrar um registro de renda para o mês/ano pedido
+ const [incomeRecord] = await db
+ .select()
+ .from(incomes)
+ .where(
+ and(
+ eq(incomes.user_id, userId),
+ eq(incomes.reference_month, referenceMonth),
+ eq(incomes.reference_year, referenceYear),
+ ),
+ )
+ .limit(1);
+
+ // Se existir registro de renda para o mês, usa os valores dele
+ const salaryData = incomeRecord
+ ? {
+ gross: incomeRecord.gross_salary,
+ calculated_net: incomeRecord.calculated_net_salary,
+ effective_net: incomeRecord.effective_net_salary,
+ inss_discount:
+ incomeRecord.gross_salary - incomeRecord.calculated_net_salary - 0, // simplificado
+ irrf_discount: 0,
+ has_override: incomeRecord.effective_net_salary !== incomeRecord.calculated_net_salary,
+ }
+ : {
+ gross: user.gross_salary,
+ calculated_net: taxResult.salarioLiquidoCalculado,
+ effective_net: effectiveNet,
+ inss_discount: taxResult.descontoINSS,
+ irrf_discount: taxResult.descontoIRRF,
+ has_override: user.net_salary_override !== null,
+ };
+
+ // 5. Busca gastos recorrentes ATIVOS e vigentes no mês/ano pedido
+ const refDateStr = `${referenceYear}-${String(referenceMonth).padStart(2, '0')}-01`;
+ const lastDay = new Date(referenceYear, referenceMonth, 0).getDate();
+ const refDateEndStr = `${referenceYear}-${String(referenceMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
+
+ const recurringList = await db
+ .select()
+ .from(recurringExpenses)
+ .where(
+ and(
+ eq(recurringExpenses.user_id, userId),
+ eq(recurringExpenses.is_active, true),
+ lte(recurringExpenses.starts_at, refDateEndStr),
+ // ends_at IS NULL ou ends_at >= início do mês
+ sql`(${recurringExpenses.ends_at} IS NULL OR ${recurringExpenses.ends_at} >= ${refDateStr})`,
+ ),
+ );
+
+ // 6. Busca gastos avulsos do mês/ano pedido
+ const oneTimeList = await db
+ .select()
+ .from(oneTimeExpenses)
+ .where(
+ and(
+ eq(oneTimeExpenses.user_id, userId),
+ gte(oneTimeExpenses.expense_date, refDateStr),
+ lte(oneTimeExpenses.expense_date, refDateEndStr),
+ ),
+ )
+ .orderBy(oneTimeExpenses.expense_date);
+
+ // 7. Calcula totais
+ const recurringTotal = recurringList.reduce((sum, e) => {
+ return sum + (e.type === 'income' ? -e.amount : e.amount);
+ }, 0);
+ const oneTimeTotal = oneTimeList.reduce((sum, e) => {
+ return sum + (e.type === 'income' ? -e.amount : e.amount);
+ }, 0);
+ const totalExpenses = parseFloat((recurringTotal + oneTimeTotal).toFixed(2));
+ const available = parseFloat((salaryData.effective_net - totalExpenses).toFixed(2));
+ const expenseRatio =
+ salaryData.effective_net > 0
+ ? parseFloat(((totalExpenses / salaryData.effective_net) * 100).toFixed(1))
+ : 0;
+
+ return {
+ user: {
+ id: user.id,
+ name: user.name,
+ gross_salary: user.gross_salary,
+ net_salary_override: user.net_salary_override,
+ },
+ period: { month: referenceMonth, year: referenceYear },
+ salary: salaryData,
+ expenses: {
+ recurring_total: parseFloat(recurringTotal.toFixed(2)),
+ one_time_total: parseFloat(oneTimeTotal.toFixed(2)),
+ total: totalExpenses,
+ recurring_list: recurringList.map((e) => ({
+ id: e.id,
+ name: e.name,
+ amount: e.amount,
+ category: e.category,
+ type: e.type,
+ })),
+ one_time_list: oneTimeList.map((e) => ({
+ id: e.id,
+ name: e.name,
+ amount: e.amount,
+ category: e.category,
+ type: e.type,
+ expense_date: e.expense_date,
+ })),
+ },
+ balance: {
+ available,
+ expense_ratio: expenseRatio,
+ },
+ };
+}