diff options
Diffstat (limited to 'api/src/modules/recurring-expenses')
3 files changed, 272 insertions, 0 deletions
diff --git a/api/src/modules/recurring-expenses/recurring-expenses.routes.ts b/api/src/modules/recurring-expenses/recurring-expenses.routes.ts new file mode 100644 index 0000000..73594e8 --- /dev/null +++ b/api/src/modules/recurring-expenses/recurring-expenses.routes.ts @@ -0,0 +1,108 @@ +import type { FastifyPluginAsync } from 'fastify'; +import { ZodError } from 'zod'; +import { + createRecurringExpenseSchema, + recurringExpenseParamsSchema, + recurringExpenseUserParamsSchema, + updateRecurringExpenseSchema, +} from './recurring-expenses.schema'; +import * as recurringService from './recurring-expenses.service'; + +const recurringExpensesRoutes: FastifyPluginAsync = async (fastify) => { + /** GET /users/:userId/recurring-expenses — Lista todos os gastos recorrentes */ + fastify.get<{ Params: { userId: string } }>( + '/users/:userId/recurring-expenses', + async (request, reply) => { + const { userId } = recurringExpenseUserParamsSchema.parse(request.params); + const result = await recurringService.findRecurringExpensesByUser(userId); + return reply.send({ data: result }); + }, + ); + + /** GET /users/:userId/recurring-expenses/:id — Busca um gasto recorrente específico */ + fastify.get<{ Params: { userId: string; id: string } }>( + '/users/:userId/recurring-expenses/:id', + async (request, reply) => { + const { userId, id } = recurringExpenseParamsSchema.parse(request.params); + const expense = await recurringService.findRecurringExpenseById(userId, id); + + if (!expense) { + return reply.status(404).send({ error: 'Gasto recorrente não encontrado.' }); + } + return reply.send({ data: expense }); + }, + ); + + /** POST /users/:userId/recurring-expenses — Cadastra um novo gasto fixo */ + fastify.post<{ Params: { userId: string } }>( + '/users/:userId/recurring-expenses', + async (request, reply) => { + try { + const { userId } = recurringExpenseUserParamsSchema.parse(request.params); + const body = createRecurringExpenseSchema.parse(request.body); + const expense = await recurringService.createRecurringExpense(userId, body); + return reply.status(201).send({ data: expense }); + } catch (err) { + if (err instanceof ZodError) { + return reply.status(400).send({ error: 'Dados inválidos.', details: err.errors }); + } + throw err; + } + }, + ); + + /** PUT /users/:userId/recurring-expenses/:id — Atualiza um gasto recorrente */ + fastify.put<{ Params: { userId: string; id: string } }>( + '/users/:userId/recurring-expenses/:id', + async (request, reply) => { + try { + const { userId, id } = recurringExpenseParamsSchema.parse(request.params); + const body = updateRecurringExpenseSchema.parse(request.body); + const expense = await recurringService.updateRecurringExpense(userId, id, body); + + if (!expense) { + return reply.status(404).send({ error: 'Gasto recorrente não encontrado.' }); + } + return reply.send({ data: expense }); + } catch (err) { + if (err instanceof ZodError) { + return reply.status(400).send({ error: 'Dados inválidos.', details: err.errors }); + } + throw err; + } + }, + ); + + /** + * PATCH /users/:userId/recurring-expenses/:id/toggle + * Ativa ou desativa um gasto recorrente sem precisar deletar. + */ + fastify.patch<{ Params: { userId: string; id: string } }>( + '/users/:userId/recurring-expenses/:id/toggle', + async (request, reply) => { + const { userId, id } = recurringExpenseParamsSchema.parse(request.params); + const expense = await recurringService.toggleRecurringExpense(userId, id); + + if (!expense) { + return reply.status(404).send({ error: 'Gasto recorrente não encontrado.' }); + } + return reply.send({ data: expense }); + }, + ); + + /** DELETE /users/:userId/recurring-expenses/:id — Remove um gasto recorrente */ + fastify.delete<{ Params: { userId: string; id: string } }>( + '/users/:userId/recurring-expenses/:id', + async (request, reply) => { + const { userId, id } = recurringExpenseParamsSchema.parse(request.params); + const deleted = await recurringService.deleteRecurringExpense(userId, id); + + if (!deleted) { + return reply.status(404).send({ error: 'Gasto recorrente não encontrado.' }); + } + return reply.status(200).send({ data: deleted, message: 'Gasto recorrente removido com sucesso.' }); + }, + ); +}; + +export default recurringExpensesRoutes; diff --git a/api/src/modules/recurring-expenses/recurring-expenses.schema.ts b/api/src/modules/recurring-expenses/recurring-expenses.schema.ts new file mode 100644 index 0000000..8f9d473 --- /dev/null +++ b/api/src/modules/recurring-expenses/recurring-expenses.schema.ts @@ -0,0 +1,49 @@ +import { z } from 'zod'; + +const CATEGORIES = [ + 'moradia', + 'alimentação', + 'transporte', + 'saúde', + 'educação', + 'lazer', + 'serviços', + 'outros', +] as const; + +export const categoryEnum = z.enum(CATEGORIES); +export const typeEnum = z.enum(['expense', 'income']); + +export const createRecurringExpenseSchema = z.object({ + name: z.string().min(1, 'Nome é obrigatório').max(100), + amount: z.number().positive('Valor deve ser positivo'), + category: categoryEnum.default('outros'), + type: typeEnum.default('expense'), + due_day: z.number().int().min(1).max(31).nullable().optional(), + is_active: z.boolean().default(true), + starts_at: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato: YYYY-MM-DD').optional(), + ends_at: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato: YYYY-MM-DD').nullable().optional(), +}); + +export const updateRecurringExpenseSchema = z.object({ + name: z.string().min(1).max(100).optional(), + amount: z.number().positive().optional(), + category: categoryEnum.optional(), + type: typeEnum.optional(), + due_day: z.number().int().min(1).max(31).nullable().optional(), + is_active: z.boolean().optional(), + starts_at: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + ends_at: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(), +}); + +export const recurringExpenseParamsSchema = z.object({ + userId: z.coerce.number().int().positive(), + id: z.coerce.number().int().positive(), +}); + +export const recurringExpenseUserParamsSchema = z.object({ + userId: z.coerce.number().int().positive(), +}); + +export type CreateRecurringExpenseInput = z.infer<typeof createRecurringExpenseSchema>; +export type UpdateRecurringExpenseInput = z.infer<typeof updateRecurringExpenseSchema>; diff --git a/api/src/modules/recurring-expenses/recurring-expenses.service.ts b/api/src/modules/recurring-expenses/recurring-expenses.service.ts new file mode 100644 index 0000000..2bb9720 --- /dev/null +++ b/api/src/modules/recurring-expenses/recurring-expenses.service.ts @@ -0,0 +1,115 @@ +import { and, eq } from 'drizzle-orm'; +import { db } from '@/db/client'; +import { recurringExpenses } from '@/db/schema'; +import type { + CreateRecurringExpenseInput, + UpdateRecurringExpenseInput, +} from './recurring-expenses.schema'; + +export async function findRecurringExpensesByUser(userId: number) { + return db + .select() + .from(recurringExpenses) + .where(eq(recurringExpenses.user_id, userId)) + .orderBy(recurringExpenses.name); +} + +export async function findRecurringExpenseById(userId: number, id: number) { + const result = await db + .select() + .from(recurringExpenses) + .where( + and( + eq(recurringExpenses.user_id, userId), + eq(recurringExpenses.id, id), + ), + ) + .limit(1); + return result[0] ?? null; +} + +export async function createRecurringExpense( + userId: number, + data: CreateRecurringExpenseInput, +) { + const [created] = await db + .insert(recurringExpenses) + .values({ + user_id: userId, + name: data.name, + amount: data.amount, + category: data.category, + type: data.type, + due_day: data.due_day ?? null, + is_active: data.is_active, + starts_at: data.starts_at, + ends_at: data.ends_at ?? null, + }) + .returning(); + return created; +} + +export async function updateRecurringExpense( + userId: number, + id: number, + data: UpdateRecurringExpenseInput, +) { + const now = new Date().toISOString(); + + const [updated] = await db + .update(recurringExpenses) + .set({ + ...(data.name !== undefined && { name: data.name }), + ...(data.amount !== undefined && { amount: data.amount }), + ...(data.category !== undefined && { category: data.category }), + ...(data.type !== undefined && { type: data.type }), + ...(data.due_day !== undefined && { due_day: data.due_day }), + ...(data.is_active !== undefined && { is_active: data.is_active }), + ...(data.starts_at !== undefined && { starts_at: data.starts_at }), + ...(data.ends_at !== undefined && { ends_at: data.ends_at }), + updated_at: now, + }) + .where( + and( + eq(recurringExpenses.user_id, userId), + eq(recurringExpenses.id, id), + ), + ) + .returning(); + + return updated ?? null; +} + +export async function toggleRecurringExpense(userId: number, id: number) { + const expense = await findRecurringExpenseById(userId, id); + if (!expense) return null; + + const [updated] = await db + .update(recurringExpenses) + .set({ + is_active: !expense.is_active, + updated_at: new Date().toISOString(), + }) + .where( + and( + eq(recurringExpenses.user_id, userId), + eq(recurringExpenses.id, id), + ), + ) + .returning(); + + return updated ?? null; +} + +export async function deleteRecurringExpense(userId: number, id: number) { + const [deleted] = await db + .delete(recurringExpenses) + .where( + and( + eq(recurringExpenses.user_id, userId), + eq(recurringExpenses.id, id), + ), + ) + .returning(); + return deleted ?? null; +} |