diff options
| author | Lucas Faria Mendes <lucas.fariamo08@gmail.com> | 2026-08-16 23:17:42 +0000 |
|---|---|---|
| committer | Lucas Faria Mendes <lucas.fariamo08@gmail.com> | 2026-08-16 23:17:42 +0000 |
| commit | 375482e654a01615683867e732ac9e458c7cbe74 (patch) | |
| tree | 12d1b31b8deb6bf8590bcdbc2cfb52dcc553e006 /api/src/modules/dashboard | |
| parent | 2c2dfba45357bbd205a59145ae55a2779c911ae0 (diff) | |
| download | yaba-master.tar.gz yaba-master.zip | |
Signed-off-by: Lucas Faria Mendes <lucas.fariamo08@gmail.com>
Diffstat (limited to 'api/src/modules/dashboard')
| -rw-r--r-- | api/src/modules/dashboard/dashboard.routes.ts | 50 | ||||
| -rw-r--r-- | api/src/modules/dashboard/dashboard.service.ts | 177 |
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, + }, + }; +} |