From acf6a499af31a53a9eeafcaff925fb3ae122aab3 Mon Sep 17 00:00:00 2001 From: devjaes Date: Fri, 10 Oct 2025 23:08:19 -0500 Subject: [PATCH 1/4] refactor: [devjaes] update transaction date validation and enhance transaction repository methods - Changed date validation in transaction filters from datetime to date. - Updated transaction repository methods to include category names in category totals. - Improved date handling in transaction queries to ensure correct filtering by date range. --- .../application/dtos/transaction.dto.ts | 4 +- .../services/transactions.service.ts | 791 +++++------ .../transactions/domain/entities/ITrends.ts | 6 + .../ports/transaction-repository.port.ts | 1 + .../adapters/transaction.repository.ts | 1184 +++++++++-------- src/shared/utils/date.utils.ts | 22 + 6 files changed, 1032 insertions(+), 976 deletions(-) create mode 100644 src/shared/utils/date.utils.ts diff --git a/src/features/transactions/application/dtos/transaction.dto.ts b/src/features/transactions/application/dtos/transaction.dto.ts index 48b1abc..728f8b3 100644 --- a/src/features/transactions/application/dtos/transaction.dto.ts +++ b/src/features/transactions/application/dtos/transaction.dto.ts @@ -58,8 +58,8 @@ export const updateTransactionSchema = transactionBaseSchema }); export const transactionFiltersSchema = z.object({ - startDate: z.string().datetime().optional(), - endDate: z.string().datetime().optional(), + startDate: z.string().date().optional(), + endDate: z.string().date().optional(), type: z.enum(["INCOME", "EXPENSE"]).optional(), category_id: z.coerce.number().int().positive().optional(), payment_method_id: z.coerce.number().int().positive().optional(), diff --git a/src/features/transactions/application/services/transactions.service.ts b/src/features/transactions/application/services/transactions.service.ts index d7401d4..0eb2ffe 100644 --- a/src/features/transactions/application/services/transactions.service.ts +++ b/src/features/transactions/application/services/transactions.service.ts @@ -4,402 +4,405 @@ import { createHandler } from "@/core/infrastructure/lib/handler.wrapper,"; import { TransactionApiAdapter } from "@/transactions/infrastructure/adapters/transaction-api.adapter"; import * as HttpStatusCodes from "stoker/http-status-codes"; import { - CreateRoute, - DeleteRoute, - FilterTransactionsRoute, - GetByIdRoute, - GetCategoryTotalsRoute, - GetMonthlyBalanceRoute, - GetMonthlyTrendsRoute, - ListByUserRoute, - ListRoute, - UpdateRoute, + CreateRoute, + DeleteRoute, + FilterTransactionsRoute, + GetByIdRoute, + GetCategoryTotalsRoute, + GetMonthlyBalanceRoute, + GetMonthlyTrendsRoute, + ListByUserRoute, + ListRoute, + UpdateRoute, } from "@/transactions/infrastructure/controllers/transaction.routes"; import { TransactionUtilsService } from "./transactions-utils.service"; export class TransactionService implements ITransactionService { - private static instance: TransactionService; - - constructor( - private readonly transactionRepository: ITransactionRepository, - private readonly transactionUtils: TransactionUtilsService - ) {} - - public static getInstance( - transactionRepository: ITransactionRepository, - transactionUtils: TransactionUtilsService - ): TransactionService { - if (!TransactionService.instance) { - TransactionService.instance = new TransactionService( - transactionRepository, - transactionUtils - ); - } - return TransactionService.instance; - } - - getAll = createHandler(async (c) => { - const transactions = await this.transactionRepository.findAll(); - return c.json( - { - success: true, - data: TransactionApiAdapter.toApiResponseList(transactions), - message: "Transactions retrieved successfully", - }, - HttpStatusCodes.OK - ); - }); - - getById = createHandler(async (c) => { - const id = c.req.param("id"); - const transaction = await this.transactionRepository.findById(Number(id)); - - if (!transaction) { - return c.json( - { - success: false, - data: null, - message: "Transaction not found", - }, - HttpStatusCodes.NOT_FOUND - ); - } - - return c.json( - { - success: true, - data: TransactionApiAdapter.toApiResponse(transaction), - message: "Transaction retrieved successfully", - }, - HttpStatusCodes.OK - ); - }); - - getByUserId = createHandler(async (c) => { - const userId = c.req.param("userId"); - - const userValidation = await this.transactionUtils.validateUser( - Number(userId) - ); - if (!userValidation.isValid) { - return c.json( - { - success: false, - data: null, - message: "User not found", - }, - HttpStatusCodes.NOT_FOUND - ); - } - - const transactions = await this.transactionRepository.findByUserId( - Number(userId) - ); - return c.json( - { - success: true, - data: TransactionApiAdapter.toApiResponseList(transactions), - message: "User transactions retrieved successfully", - }, - HttpStatusCodes.OK - ); - }); - - getFiltered = createHandler(async (c) => { - const userId = c.req.param("userId"); - const filters = c.req.valid("query"); - - const userValidation = await this.transactionUtils.validateUser( - Number(userId) - ); - if (!userValidation.isValid) { - return c.json( - { - success: false, - data: null, - message: "User not found", - }, - HttpStatusCodes.NOT_FOUND - ); - } - - const transactions = await this.transactionRepository.findByFilters( - Number(userId), - filters - ); - - return c.json( - { - success: true, - data: TransactionApiAdapter.toApiResponseList(transactions), - message: "Filtered transactions retrieved successfully", - }, - HttpStatusCodes.OK - ); - }); - - create = createHandler(async (c) => { - const data = c.req.valid("json"); - - // Validar que el usuario existe - const userValidation = await this.transactionUtils.validateUser( - data.user_id - ); - if (!userValidation.isValid) { - return c.json( - { - success: false, - data: null, - message: "User not found", - }, - HttpStatusCodes.NOT_FOUND - ); - } - - // Si se proporciona un método de pago, validar que existe y pertenece al usuario - if (data.payment_method_id) { - const paymentMethodValidation = - await this.transactionUtils.validatePaymentMethod( - data.payment_method_id, - data.user_id - ); - if (!paymentMethodValidation.isValid) { - return c.json( - { - success: false, - data: null, - message: - paymentMethodValidation.message || "Invalid payment method", - }, - HttpStatusCodes.BAD_REQUEST - ); - } - } - - const transaction = await this.transactionRepository.create({ - userId: data.user_id, - amount: data.amount, - type: data.type, - categoryId: data.category_id || null, - description: data.description, - paymentMethodId: data.payment_method_id, - scheduledTransactionId: data.scheduled_transaction_id, - debtId: data.debt_id, - }); - - return c.json( - { - success: true, - data: TransactionApiAdapter.toApiResponse(transaction), - message: "Transaction created successfully", - }, - HttpStatusCodes.CREATED - ); - }); - - update = createHandler(async (c) => { - const id = c.req.param("id"); - const data = c.req.valid("json"); - - console.log(data); - - const transaction = await this.transactionRepository.findById(Number(id)); - if (!transaction) { - return c.json( - { - success: false, - data: null, - message: "Transaction not found", - }, - HttpStatusCodes.NOT_FOUND - ); - } - - if (data.payment_method_id !== undefined) { - if (data.payment_method_id !== null) { - const paymentMethodValidation = - await this.transactionUtils.validatePaymentMethod( - data.payment_method_id, - transaction.userId - ); - if (!paymentMethodValidation.isValid) { - return c.json( - { - success: false, - data: null, - message: - paymentMethodValidation.message || "Invalid payment method", - }, - HttpStatusCodes.BAD_REQUEST - ); - } - } - } - - const updatedTransaction = await this.transactionRepository.update( - Number(id), - { - amount: data.amount, - type: data.type, - categoryId: data.category_id, - description: data.description, - paymentMethodId: data.payment_method_id, - scheduledTransactionId: data.scheduled_transaction_id, - debtId: data.debt_id, - contributionId: data.contribution_id, - } - ); - - return c.json( - { - success: true, - data: TransactionApiAdapter.toApiResponse(updatedTransaction), - message: "Transaction updated successfully", - }, - HttpStatusCodes.OK - ); - }); - - delete = createHandler(async (c) => { - const id = c.req.param("id"); - const transaction = await this.transactionRepository.findById(Number(id)); - - if (!transaction) { - return c.json( - { - success: false, - data: null, - message: "Transaction not found", - }, - HttpStatusCodes.NOT_FOUND - ); - } - - const deleted = await this.transactionRepository.delete(Number(id)); - return c.json( - { - success: true, - data: { deleted }, - message: "Transaction deleted successfully", - }, - HttpStatusCodes.OK - ); - }); - - getMonthlyBalance = createHandler(async (c) => { - const userId = c.req.param("userId"); - const { month } = c.req.valid("query"); - - const userValidation = await this.transactionUtils.validateUser( - Number(userId) - ); - if (!userValidation.isValid) { - return c.json( - { - success: false, - data: null, - message: "User not found", - }, - HttpStatusCodes.NOT_FOUND - ); - } - - const balance = await this.transactionRepository.getMonthlyBalance( - Number(userId), - new Date(month) - ); - - return c.json( - { - success: true, - data: balance, - message: "Monthly balance retrieved successfully", - }, - HttpStatusCodes.OK - ); - }); - - getCategoryTotals = createHandler(async (c) => { - const userId = c.req.param("userId"); - const { startDate, endDate } = c.req.valid("query"); - - const userValidation = await this.transactionUtils.validateUser( - Number(userId) - ); - if (!userValidation.isValid) { - return c.json( - { - success: false, - data: null, - message: "User not found", - }, - HttpStatusCodes.NOT_FOUND - ); - } - - const totals = await this.transactionRepository.getCategoryTotals( - Number(userId), - new Date(startDate), - new Date(endDate) - ); - - return c.json( - { - success: true, - data: totals.map((t) => ({ - category: t.categoryId.toString(), - total: t.total, - })), - message: "Category totals retrieved successfully", - }, - HttpStatusCodes.OK - ); - }); - - getMonthlyTrends = createHandler(async (c) => { - const userId = c.req.param("userId"); - - const userValidation = await this.transactionUtils.validateUser( - Number(userId) - ); - if (!userValidation.isValid) { - return c.json( - { - success: false, - data: null, - message: "User not found", - }, - HttpStatusCodes.NOT_FOUND - ); - } - - try { - const trends = await this.transactionRepository.getMonthlyTrends( - Number(userId) - ); - - return c.json( - { - success: true, - data: trends.map((t) => ({ - month: t.month, - income: t.income, - expense: t.expense, - })), - message: "Monthly trends retrieved successfully", - }, - HttpStatusCodes.OK - ); - } catch (error) { - console.error("Error getting monthly trends:", error); - return c.json( - { - success: false, - data: null, - message: "Error retrieving monthly trends", - }, - HttpStatusCodes.INTERNAL_SERVER_ERROR - ); - } - }); + private static instance: TransactionService; + + constructor( + private readonly transactionRepository: ITransactionRepository, + private readonly transactionUtils: TransactionUtilsService + ) {} + + public static getInstance( + transactionRepository: ITransactionRepository, + transactionUtils: TransactionUtilsService + ): TransactionService { + if (!TransactionService.instance) { + TransactionService.instance = new TransactionService( + transactionRepository, + transactionUtils + ); + } + return TransactionService.instance; + } + + getAll = createHandler(async (c) => { + const transactions = await this.transactionRepository.findAll(); + return c.json( + { + success: true, + data: TransactionApiAdapter.toApiResponseList(transactions), + message: "Transactions retrieved successfully", + }, + HttpStatusCodes.OK + ); + }); + + getById = createHandler(async (c) => { + const id = c.req.param("id"); + const transaction = await this.transactionRepository.findById(Number(id)); + + if (!transaction) { + return c.json( + { + success: false, + data: null, + message: "Transaction not found", + }, + HttpStatusCodes.NOT_FOUND + ); + } + + return c.json( + { + success: true, + data: TransactionApiAdapter.toApiResponse(transaction), + message: "Transaction retrieved successfully", + }, + HttpStatusCodes.OK + ); + }); + + getByUserId = createHandler(async (c) => { + const userId = c.req.param("userId"); + + const userValidation = await this.transactionUtils.validateUser( + Number(userId) + ); + if (!userValidation.isValid) { + return c.json( + { + success: false, + data: null, + message: "User not found", + }, + HttpStatusCodes.NOT_FOUND + ); + } + + const transactions = await this.transactionRepository.findByUserId( + Number(userId) + ); + return c.json( + { + success: true, + data: TransactionApiAdapter.toApiResponseList(transactions), + message: "User transactions retrieved successfully", + }, + HttpStatusCodes.OK + ); + }); + + getFiltered = createHandler(async (c) => { + const userId = c.req.param("userId"); + const filters = c.req.valid("query"); + + const userValidation = await this.transactionUtils.validateUser( + Number(userId) + ); + if (!userValidation.isValid) { + return c.json( + { + success: false, + data: null, + message: "User not found", + }, + HttpStatusCodes.NOT_FOUND + ); + } + + const transactions = await this.transactionRepository.findByFilters( + Number(userId), + filters + ); + + return c.json( + { + success: true, + data: TransactionApiAdapter.toApiResponseList(transactions), + message: "Filtered transactions retrieved successfully", + }, + HttpStatusCodes.OK + ); + }); + + create = createHandler(async (c) => { + const data = c.req.valid("json"); + + // Validar que el usuario existe + const userValidation = await this.transactionUtils.validateUser( + data.user_id + ); + if (!userValidation.isValid) { + return c.json( + { + success: false, + data: null, + message: "User not found", + }, + HttpStatusCodes.NOT_FOUND + ); + } + + // Si se proporciona un método de pago, validar que existe y pertenece al usuario + if (data.payment_method_id) { + const paymentMethodValidation = + await this.transactionUtils.validatePaymentMethod( + data.payment_method_id, + data.user_id + ); + if (!paymentMethodValidation.isValid) { + return c.json( + { + success: false, + data: null, + message: + paymentMethodValidation.message || "Invalid payment method", + }, + HttpStatusCodes.BAD_REQUEST + ); + } + } + + const transaction = await this.transactionRepository.create({ + userId: data.user_id, + amount: data.amount, + type: data.type, + categoryId: data.category_id || null, + category: null, + description: data.description, + paymentMethodId: data.payment_method_id, + paymentMethod: null, + scheduledTransactionId: data.scheduled_transaction_id, + debtId: data.debt_id, + }); + + return c.json( + { + success: true, + data: TransactionApiAdapter.toApiResponse(transaction), + message: "Transaction created successfully", + }, + HttpStatusCodes.CREATED + ); + }); + + update = createHandler(async (c) => { + const id = c.req.param("id"); + const data = c.req.valid("json"); + + console.log(data); + + const transaction = await this.transactionRepository.findById(Number(id)); + if (!transaction) { + return c.json( + { + success: false, + data: null, + message: "Transaction not found", + }, + HttpStatusCodes.NOT_FOUND + ); + } + + if (data.payment_method_id !== undefined) { + if (data.payment_method_id !== null) { + const paymentMethodValidation = + await this.transactionUtils.validatePaymentMethod( + data.payment_method_id, + transaction.userId + ); + if (!paymentMethodValidation.isValid) { + return c.json( + { + success: false, + data: null, + message: + paymentMethodValidation.message || "Invalid payment method", + }, + HttpStatusCodes.BAD_REQUEST + ); + } + } + } + + const updatedTransaction = await this.transactionRepository.update( + Number(id), + { + amount: data.amount, + type: data.type, + categoryId: data.category_id, + description: data.description, + paymentMethodId: data.payment_method_id, + scheduledTransactionId: data.scheduled_transaction_id, + debtId: data.debt_id, + contributionId: data.contribution_id, + } + ); + + return c.json( + { + success: true, + data: TransactionApiAdapter.toApiResponse(updatedTransaction), + message: "Transaction updated successfully", + }, + HttpStatusCodes.OK + ); + }); + + delete = createHandler(async (c) => { + const id = c.req.param("id"); + const transaction = await this.transactionRepository.findById(Number(id)); + + if (!transaction) { + return c.json( + { + success: false, + data: null, + message: "Transaction not found", + }, + HttpStatusCodes.NOT_FOUND + ); + } + + const deleted = await this.transactionRepository.delete(Number(id)); + return c.json( + { + success: true, + data: { deleted }, + message: "Transaction deleted successfully", + }, + HttpStatusCodes.OK + ); + }); + + getMonthlyBalance = createHandler(async (c) => { + const userId = c.req.param("userId"); + const { month } = c.req.valid("query"); + + const userValidation = await this.transactionUtils.validateUser( + Number(userId) + ); + if (!userValidation.isValid) { + return c.json( + { + success: false, + data: null, + message: "User not found", + }, + HttpStatusCodes.NOT_FOUND + ); + } + + const balance = await this.transactionRepository.getMonthlyBalance( + Number(userId), + new Date(month) + ); + + return c.json( + { + success: true, + data: balance, + message: "Monthly balance retrieved successfully", + }, + HttpStatusCodes.OK + ); + }); + + getCategoryTotals = createHandler(async (c) => { + const userId = c.req.param("userId"); + const { startDate, endDate } = c.req.valid("query"); + + const userValidation = await this.transactionUtils.validateUser( + Number(userId) + ); + if (!userValidation.isValid) { + return c.json( + { + success: false, + data: null, + message: "User not found", + }, + HttpStatusCodes.NOT_FOUND + ); + } + + const totals = await this.transactionRepository.getCategoryTotals( + Number(userId), + new Date(startDate), + new Date(endDate) + ); + + return c.json( + { + success: true, + data: totals.map((t) => ({ + category: t.categoryId.toString(), + categoryName: t.categoryName, + total: t.total, + })), + message: "Category totals retrieved successfully", + }, + HttpStatusCodes.OK + ); + }); + + getMonthlyTrends = createHandler(async (c) => { + const userId = c.req.param("userId"); + + const userValidation = await this.transactionUtils.validateUser( + Number(userId) + ); + if (!userValidation.isValid) { + return c.json( + { + success: false, + data: null, + message: "User not found", + }, + HttpStatusCodes.NOT_FOUND + ); + } + + try { + const trends = await this.transactionRepository.getMonthlyTrends( + Number(userId) + ); + + return c.json( + { + success: true, + data: trends.map((t) => ({ + month: t.month, + income: t.income, + expense: t.expense, + })), + message: "Monthly trends retrieved successfully", + }, + HttpStatusCodes.OK + ); + } catch (error) { + console.error("Error getting monthly trends:", error); + return c.json( + { + success: false, + data: null, + message: "Error retrieving monthly trends", + }, + HttpStatusCodes.INTERNAL_SERVER_ERROR + ); + } + }); } diff --git a/src/features/transactions/domain/entities/ITrends.ts b/src/features/transactions/domain/entities/ITrends.ts index db6a5a2..2d470e0 100644 --- a/src/features/transactions/domain/entities/ITrends.ts +++ b/src/features/transactions/domain/entities/ITrends.ts @@ -9,3 +9,9 @@ export interface MonthlyTrendData { income: number; expense: number; } + +export interface CategoryTotalData { + categoryId: number; + categoryName: string | null; + total: number; +} diff --git a/src/features/transactions/domain/ports/transaction-repository.port.ts b/src/features/transactions/domain/ports/transaction-repository.port.ts index 43ac222..aee5d66 100644 --- a/src/features/transactions/domain/ports/transaction-repository.port.ts +++ b/src/features/transactions/domain/ports/transaction-repository.port.ts @@ -28,6 +28,7 @@ export interface ITransactionRepository { ): Promise< Array<{ categoryId: number; + categoryName: string | null; total: number; }> >; diff --git a/src/features/transactions/infrastructure/adapters/transaction.repository.ts b/src/features/transactions/infrastructure/adapters/transaction.repository.ts index 7e26640..d6bdf0c 100644 --- a/src/features/transactions/infrastructure/adapters/transaction.repository.ts +++ b/src/features/transactions/infrastructure/adapters/transaction.repository.ts @@ -5,586 +5,610 @@ import { ITransactionRepository } from "../../domain/ports/transaction-repositor import { ITransaction } from "../../domain/entities/ITransaction"; import { TransactionFilters } from "../../application/dtos/transaction.dto"; import { MonthlyTrendData } from "@/transactions/domain/entities/ITrends"; +import { setEndOfDay, setStartOfDay } from "@/shared/utils/date.utils"; export class PgTransactionRepository implements ITransactionRepository { - private db = DatabaseConnection.getInstance().db; - private static instance: PgTransactionRepository; - - private constructor() {} - - public static getInstance(): PgTransactionRepository { - if (!PgTransactionRepository.instance) { - PgTransactionRepository.instance = new PgTransactionRepository(); - } - return PgTransactionRepository.instance; - } - - async findAll(): Promise { - const result = await this.db - .select({ - id: transactions.id, - user_id: transactions.user_id, - amount: transactions.amount, - type: transactions.type, - category_id: transactions.category_id, - category: { - id: categories.id, - name: categories.name, - description: categories.description, - }, - description: transactions.description, - payment_method_id: transactions.payment_method_id, - payment_method: { - id: payment_methods.id, - name: payment_methods.name, - type: payment_methods.type, - last_four_digits: payment_methods.last_four_digits, - user_id: payment_methods.user_id, - }, - date: transactions.date, - scheduled_transaction_id: transactions.scheduled_transaction_id, - debt_id: transactions.debt_id, - budget_id: transactions.budget_id, - contribution_id: transactions.contribution_id, - }) - .from(transactions) - .leftJoin(categories, eq(transactions.category_id, categories.id)) - .leftJoin(payment_methods, eq(transactions.payment_method_id, payment_methods.id)); - return result.map(this.mapToEntity); - } - - async findById(id: number): Promise { - const result = await this.db - .select({ - id: transactions.id, - user_id: transactions.user_id, - amount: transactions.amount, - type: transactions.type, - category_id: transactions.category_id, - category: { - id: categories.id, - name: categories.name, - description: categories.description, - }, - description: transactions.description, - payment_method_id: transactions.payment_method_id, - payment_method: { - id: payment_methods.id, - name: payment_methods.name, - type: payment_methods.type, - last_four_digits: payment_methods.last_four_digits, - user_id: payment_methods.user_id, - }, - date: transactions.date, - scheduled_transaction_id: transactions.scheduled_transaction_id, - debt_id: transactions.debt_id, - budget_id: transactions.budget_id, - contribution_id: transactions.contribution_id, - }) - .from(transactions) - .leftJoin(categories, eq(transactions.category_id, categories.id)) - .leftJoin(payment_methods, eq(transactions.payment_method_id, payment_methods.id)) - .where(eq(transactions.id, id)); - return result[0] ? this.mapToEntity(result[0]) : null; - } - - async findByUserId(userId: number): Promise { - const result = await this.db - .select({ - id: transactions.id, - user_id: transactions.user_id, - amount: transactions.amount, - type: transactions.type, - category_id: transactions.category_id, - category: { - id: categories.id, - name: categories.name, - description: categories.description, - }, - description: transactions.description, - payment_method_id: transactions.payment_method_id, - payment_method: { - id: payment_methods.id, - name: payment_methods.name, - type: payment_methods.type, - last_four_digits: payment_methods.last_four_digits, - user_id: payment_methods.user_id, - }, - date: transactions.date, - scheduled_transaction_id: transactions.scheduled_transaction_id, - debt_id: transactions.debt_id, - budget_id: transactions.budget_id, - contribution_id: transactions.contribution_id, - }) - .from(transactions) - .leftJoin(categories, eq(transactions.category_id, categories.id)) - .leftJoin(payment_methods, eq(transactions.payment_method_id, payment_methods.id)) - .where(eq(transactions.user_id, userId)); - return result.map(this.mapToEntity); - } - - async findByUserIdAndDateRange(userId: number, startDate: Date, endDate: Date): Promise { - const result = await this.db - .select() - .from(transactions) - .where( - and( - eq(transactions.user_id, userId), - gte(transactions.date, startDate), - lte(transactions.date, endDate) - ) - ); - return result.map(this.mapToEntity); - } - - async findByFilters( - userId: number, - filters: TransactionFilters - ): Promise { - const conditions = [eq(transactions.user_id, userId)]; - - if (filters.startDate && filters.endDate) { - conditions.push( - between( - transactions.date, - new Date(filters.startDate), - new Date(filters.endDate) - ) - ); - } else if (filters.startDate) { - conditions.push(gte(transactions.date, new Date(filters.startDate))); - } else if (filters.endDate) { - conditions.push(lte(transactions.date, new Date(filters.endDate))); - } - - if (filters.type) { - conditions.push(eq(transactions.type, filters.type)); - } - - if (filters.category_id) { - conditions.push(eq(transactions.category_id, filters.category_id)); - } - - if (filters.payment_method_id) { - conditions.push( - eq(transactions.payment_method_id, filters.payment_method_id) - ); - } - - if (filters.min_amount) { - conditions.push( - gte(transactions.amount, sql`${filters.min_amount}::numeric`) - ); - } - - if (filters.max_amount) { - conditions.push( - lte(transactions.amount, sql`${filters.max_amount}::numeric`) - ); - } - - if (filters.debt_id) { - conditions.push(eq(transactions.debt_id, filters.debt_id)); - } - - if (filters.contribution_id) { - conditions.push(eq(transactions.contribution_id, filters.contribution_id)); - } - - if (filters.budget_id) { - conditions.push(eq(transactions.budget_id, filters.budget_id)); - } - - const result = await this.db - .select({ - id: transactions.id, - user_id: transactions.user_id, - amount: transactions.amount, - type: transactions.type, - category_id: transactions.category_id, - category: { - id: categories.id, - name: categories.name, - description: categories.description, - }, - description: transactions.description, - payment_method_id: transactions.payment_method_id, - payment_method: { - id: payment_methods.id, - name: payment_methods.name, - type: payment_methods.type, - last_four_digits: payment_methods.last_four_digits, - user_id: payment_methods.user_id, - }, - date: transactions.date, - scheduled_transaction_id: transactions.scheduled_transaction_id, - debt_id: transactions.debt_id, - budget_id: transactions.budget_id, - contribution_id: transactions.contribution_id, - }) - .from(transactions) - .leftJoin(categories, eq(transactions.category_id, categories.id)) - .leftJoin(payment_methods, eq(transactions.payment_method_id, payment_methods.id)) - .where(and(...conditions)) - .orderBy(transactions.date); - - return result.map(this.mapToEntity); - } - - async create( - transactionData: Omit - ): Promise { - const result = await this.db - .insert(transactions) - .values({ - user_id: transactionData.userId, - amount: transactionData.amount.toString(), - type: transactionData.type, - category_id: transactionData.categoryId, - description: transactionData.description || null, - payment_method_id: transactionData.paymentMethodId || null, - scheduled_transaction_id: - transactionData.scheduledTransactionId || null, - debt_id: transactionData.debtId || null, - contribution_id: transactionData.contributionId || null, - budget_id: transactionData.budgetId || null, - }) - .returning(); - - return this.mapToEntity(result[0]); - } - - async update( - id: number, - transactionData: Partial - ): Promise { - const updateData: Record = {}; - - if (transactionData.amount !== undefined) { - updateData.amount = transactionData.amount; - } - if (transactionData.type !== undefined) { - updateData.type = transactionData.type; - } - if (transactionData.categoryId !== undefined) { - updateData.category_id = transactionData.categoryId; - } - if (transactionData.description !== undefined) { - updateData.description = transactionData.description; - } - if (transactionData.paymentMethodId !== undefined) { - updateData.payment_method_id = transactionData.paymentMethodId; - } - if (transactionData.scheduledTransactionId !== undefined) { - updateData.scheduled_transaction_id = - transactionData.scheduledTransactionId; - } - if (transactionData.debtId !== undefined) { - updateData.debt_id = transactionData.debtId; - } - - const result = await this.db - .update(transactions) - .set(updateData) - .where(eq(transactions.id, id)) - .returning(); - - return this.mapToEntity(result[0]); - } - - async delete(id: number): Promise { - const result = await this.db - .delete(transactions) - .where(eq(transactions.id, id)) - .returning(); - - return result.length > 0; - } - - async getMonthlyBalance( - userId: number, - month: Date - ): Promise<{ - totalIncome: number; - totalExpense: number; - balance: number; - }> { - const year = month.getUTCFullYear(); - const monthNumber = month.getUTCMonth(); - - const startDate = new Date(Date.UTC(year, monthNumber, 1)); - const endDate = new Date(Date.UTC(year, monthNumber + 1, 0)); - - const result = await this.db - .select({ - type: transactions.type, - total: sql`sum(${transactions.amount})`, - }) - .from(transactions) - .where( - and( - eq(transactions.user_id, userId), - between(transactions.date, startDate, endDate) - ) - ) - .groupBy(transactions.type); - - const totals = { - totalIncome: 0, - totalExpense: 0, - balance: 0, - }; - - result.forEach((row) => { - if (row.type === "INCOME") { - totals.totalIncome = Number(row.total) || 0; - } else { - totals.totalExpense = Number(row.total) || 0; - } - }); - - totals.balance = totals.totalIncome - totals.totalExpense; - return totals; - } - - async getCategoryTotals( - userId: number, - startDate: Date, - endDate: Date - ): Promise> { - const utcStartDate = new Date( - Date.UTC( - startDate.getUTCFullYear(), - startDate.getUTCMonth(), - startDate.getUTCDate() - ) - ); - - const utcEndDate = new Date( - Date.UTC( - endDate.getUTCFullYear(), - endDate.getUTCMonth(), - endDate.getUTCDate(), - 23, - 59, - 59, - 999 - ) - ); - - console.log("UTC Start date:", utcStartDate.toISOString()); - console.log("UTC End date:", utcEndDate.toISOString()); - - const result = await this.db - .select({ - categoryId: transactions.category_id, - total: sql`sum(${transactions.amount})`, - }) - .from(transactions) - .where( - and( - eq(transactions.user_id, userId), - between(transactions.date, utcStartDate, utcEndDate) - ) - ) - .groupBy(transactions.category_id); - - return result.map((row) => ({ - categoryId: row.categoryId || 0, - total: Number(row.total) || 0, - })); - } - private mapToEntity(raw: any): ITransaction { - return { - id: raw.id, - userId: raw.user_id, - amount: Number(raw.amount), - type: raw.type, - categoryId: raw.category_id, - category: raw.category ? { - id: raw.category.id, - name: raw.category.name, - description: raw.category.description, - } : null, - description: raw.description, - paymentMethodId: raw.payment_method_id, - paymentMethod: raw.payment_method ? { - id: raw.payment_method.id, - name: raw.payment_method.name, - type: raw.payment_method.type, - lastFourDigits: raw.payment_method.last_four_digits, - userId: raw.payment_method.user_id, - createdAt: raw.payment_method.created_at, - updatedAt: raw.payment_method.updated_at, - } : null, - date: raw.date, - scheduledTransactionId: raw.scheduled_transaction_id, - debtId: raw.debt_id, - budgetId: raw.budget_id, - contributionId: raw.contribution_id, - createdAt: raw.created_at, - updatedAt: raw.updated_at, - }; - } - - async getMonthlyTrends(userId: number): Promise { - console.log("Getting monthly trends for user:", userId); - - try { - const result = await this.db - .select({ - month: sql`date_trunc('month', ${transactions.date})::date`, - type: transactions.type, - total: sql`COALESCE(sum(${transactions.amount}::numeric), 0)`, - }) - .from(transactions) - .where(eq(transactions.user_id, userId)) - .groupBy( - sql`date_trunc('month', ${transactions.date})::date`, - transactions.type - ) - .orderBy(sql`date_trunc('month', ${transactions.date})::date`); - - console.log("Raw query result:", result); - - if (!result || result.length === 0) { - return []; - } - - const monthlyData: Record = {}; - - result.forEach(({ month, type, total }) => { - const monthKey = month.substring(0, 7); - - if (!monthlyData[monthKey]) { - monthlyData[monthKey] = { - month: monthKey, - income: 0, - expense: 0, - }; - } - - if (type === "INCOME") { - monthlyData[monthKey].income = Number(total) || 0; - } else { - monthlyData[monthKey].expense = Number(total) || 0; - } - }); - - const trends = Object.values(monthlyData).sort( - (a, b) => new Date(a.month).getTime() - new Date(b.month).getTime() - ); - - console.log("Processed trends:", trends); - return trends; - } catch (error) { - console.error("Error in getMonthlyTrends:", error); - throw error; - } - } - - async findByDebtId(debtId: number): Promise { - const result = await this.db - .select({ - id: transactions.id, - user_id: transactions.user_id, - amount: transactions.amount, - type: transactions.type, - category_id: transactions.category_id, - category: { - id: categories.id, - name: categories.name, - description: categories.description, - }, - description: transactions.description, - payment_method_id: transactions.payment_method_id, - payment_method: { - id: payment_methods.id, - name: payment_methods.name, - type: payment_methods.type, - last_four_digits: payment_methods.last_four_digits, - user_id: payment_methods.user_id, - }, - date: transactions.date, - scheduled_transaction_id: transactions.scheduled_transaction_id, - debt_id: transactions.debt_id, - budget_id: transactions.budget_id, - contribution_id: transactions.contribution_id, - }) - .from(transactions) - .leftJoin(categories, eq(transactions.category_id, categories.id)) - .leftJoin(payment_methods, eq(transactions.payment_method_id, payment_methods.id)) - .where(eq(transactions.debt_id, debtId)); - return result.map(this.mapToEntity); - } - - async findByContributionId(contributionId: number): Promise { - const result = await this.db - .select({ - id: transactions.id, - user_id: transactions.user_id, - amount: transactions.amount, - type: transactions.type, - category_id: transactions.category_id, - category: { - id: categories.id, - name: categories.name, - description: categories.description, - }, - description: transactions.description, - payment_method_id: transactions.payment_method_id, - payment_method: { - id: payment_methods.id, - name: payment_methods.name, - type: payment_methods.type, - last_four_digits: payment_methods.last_four_digits, - user_id: payment_methods.user_id, - }, - date: transactions.date, - scheduled_transaction_id: transactions.scheduled_transaction_id, - debt_id: transactions.debt_id, - budget_id: transactions.budget_id, - contribution_id: transactions.contribution_id, - }) - .from(transactions) - .leftJoin(categories, eq(transactions.category_id, categories.id)) - .leftJoin(payment_methods, eq(transactions.payment_method_id, payment_methods.id)) - .where(eq(transactions.contribution_id, contributionId)); - return result.map(this.mapToEntity); - } - - async findByBudgetId(budgetId: number): Promise { - const result = await this.db - .select({ - id: transactions.id, - user_id: transactions.user_id, - amount: transactions.amount, - type: transactions.type, - category_id: transactions.category_id, - category: { - id: categories.id, - name: categories.name, - description: categories.description, - }, - description: transactions.description, - payment_method_id: transactions.payment_method_id, - payment_method: { - id: payment_methods.id, - name: payment_methods.name, - type: payment_methods.type, - last_four_digits: payment_methods.last_four_digits, - user_id: payment_methods.user_id, - }, - date: transactions.date, - scheduled_transaction_id: transactions.scheduled_transaction_id, - debt_id: transactions.debt_id, - budget_id: transactions.budget_id, - contribution_id: transactions.contribution_id, - }) - .from(transactions) - .leftJoin(categories, eq(transactions.category_id, categories.id)) - .leftJoin(payment_methods, eq(transactions.payment_method_id, payment_methods.id)) - .where(eq(transactions.budget_id, budgetId)); - return result.map(this.mapToEntity); - } + private db = DatabaseConnection.getInstance().db; + private static instance: PgTransactionRepository; + + private constructor() {} + + public static getInstance(): PgTransactionRepository { + if (!PgTransactionRepository.instance) { + PgTransactionRepository.instance = new PgTransactionRepository(); + } + return PgTransactionRepository.instance; + } + + async findAll(): Promise { + const result = await this.db + .select({ + id: transactions.id, + user_id: transactions.user_id, + amount: transactions.amount, + type: transactions.type, + category_id: transactions.category_id, + category: { + id: categories.id, + name: categories.name, + description: categories.description, + }, + description: transactions.description, + payment_method_id: transactions.payment_method_id, + payment_method: { + id: payment_methods.id, + name: payment_methods.name, + type: payment_methods.type, + last_four_digits: payment_methods.last_four_digits, + user_id: payment_methods.user_id, + }, + date: transactions.date, + scheduled_transaction_id: transactions.scheduled_transaction_id, + debt_id: transactions.debt_id, + budget_id: transactions.budget_id, + contribution_id: transactions.contribution_id, + }) + .from(transactions) + .leftJoin(categories, eq(transactions.category_id, categories.id)) + .leftJoin( + payment_methods, + eq(transactions.payment_method_id, payment_methods.id) + ); + return result.map(this.mapToEntity); + } + + async findById(id: number): Promise { + const result = await this.db + .select({ + id: transactions.id, + user_id: transactions.user_id, + amount: transactions.amount, + type: transactions.type, + category_id: transactions.category_id, + category: { + id: categories.id, + name: categories.name, + description: categories.description, + }, + description: transactions.description, + payment_method_id: transactions.payment_method_id, + payment_method: { + id: payment_methods.id, + name: payment_methods.name, + type: payment_methods.type, + last_four_digits: payment_methods.last_four_digits, + user_id: payment_methods.user_id, + }, + date: transactions.date, + scheduled_transaction_id: transactions.scheduled_transaction_id, + debt_id: transactions.debt_id, + budget_id: transactions.budget_id, + contribution_id: transactions.contribution_id, + }) + .from(transactions) + .leftJoin(categories, eq(transactions.category_id, categories.id)) + .leftJoin( + payment_methods, + eq(transactions.payment_method_id, payment_methods.id) + ) + .where(eq(transactions.id, id)); + return result[0] ? this.mapToEntity(result[0]) : null; + } + + async findByUserId(userId: number): Promise { + const result = await this.db + .select({ + id: transactions.id, + user_id: transactions.user_id, + amount: transactions.amount, + type: transactions.type, + category_id: transactions.category_id, + category: { + id: categories.id, + name: categories.name, + description: categories.description, + }, + description: transactions.description, + payment_method_id: transactions.payment_method_id, + payment_method: { + id: payment_methods.id, + name: payment_methods.name, + type: payment_methods.type, + last_four_digits: payment_methods.last_four_digits, + user_id: payment_methods.user_id, + }, + date: transactions.date, + scheduled_transaction_id: transactions.scheduled_transaction_id, + debt_id: transactions.debt_id, + budget_id: transactions.budget_id, + contribution_id: transactions.contribution_id, + }) + .from(transactions) + .leftJoin(categories, eq(transactions.category_id, categories.id)) + .leftJoin( + payment_methods, + eq(transactions.payment_method_id, payment_methods.id) + ) + .where(eq(transactions.user_id, userId)); + return result.map(this.mapToEntity); + } + + async findByUserIdAndDateRange( + userId: number, + startDate: Date, + endDate: Date + ): Promise { + const result = await this.db + .select() + .from(transactions) + .where( + and( + eq(transactions.user_id, userId), + gte(transactions.date, setStartOfDay(startDate)), + lte(transactions.date, setEndOfDay(endDate)) + ) + ); + return result.map(this.mapToEntity); + } + + async findByFilters( + userId: number, + filters: TransactionFilters + ): Promise { + const conditions = [eq(transactions.user_id, userId)]; + + if (filters.startDate && filters.endDate) { + conditions.push( + between( + transactions.date, + setStartOfDay(new Date(filters.startDate)), + setEndOfDay(new Date(filters.endDate)) + ) + ); + } else if (filters.startDate) { + conditions.push( + gte(transactions.date, setStartOfDay(new Date(filters.startDate))) + ); + } else if (filters.endDate) { + conditions.push( + lte(transactions.date, setEndOfDay(new Date(filters.endDate))) + ); + } + + if (filters.type) { + conditions.push(eq(transactions.type, filters.type)); + } + + if (filters.category_id) { + conditions.push(eq(transactions.category_id, filters.category_id)); + } + + if (filters.payment_method_id) { + conditions.push( + eq(transactions.payment_method_id, filters.payment_method_id) + ); + } + + if (filters.min_amount) { + conditions.push( + gte(transactions.amount, sql`${filters.min_amount}::numeric`) + ); + } + + if (filters.max_amount) { + conditions.push( + lte(transactions.amount, sql`${filters.max_amount}::numeric`) + ); + } + + if (filters.debt_id) { + conditions.push(eq(transactions.debt_id, filters.debt_id)); + } + + if (filters.contribution_id) { + conditions.push( + eq(transactions.contribution_id, filters.contribution_id) + ); + } + + if (filters.budget_id) { + conditions.push(eq(transactions.budget_id, filters.budget_id)); + } + + const result = await this.db + .select({ + id: transactions.id, + user_id: transactions.user_id, + amount: transactions.amount, + type: transactions.type, + category_id: transactions.category_id, + category: { + id: categories.id, + name: categories.name, + description: categories.description, + }, + description: transactions.description, + payment_method_id: transactions.payment_method_id, + payment_method: { + id: payment_methods.id, + name: payment_methods.name, + type: payment_methods.type, + last_four_digits: payment_methods.last_four_digits, + user_id: payment_methods.user_id, + }, + date: transactions.date, + scheduled_transaction_id: transactions.scheduled_transaction_id, + debt_id: transactions.debt_id, + budget_id: transactions.budget_id, + contribution_id: transactions.contribution_id, + }) + .from(transactions) + .leftJoin(categories, eq(transactions.category_id, categories.id)) + .leftJoin( + payment_methods, + eq(transactions.payment_method_id, payment_methods.id) + ) + .where(and(...conditions)) + .orderBy(transactions.date); + + return result.map(this.mapToEntity); + } + + async create( + transactionData: Omit + ): Promise { + const result = await this.db + .insert(transactions) + .values({ + user_id: transactionData.userId, + amount: transactionData.amount.toString(), + type: transactionData.type, + category_id: transactionData.categoryId, + description: transactionData.description || null, + payment_method_id: transactionData.paymentMethodId || null, + scheduled_transaction_id: + transactionData.scheduledTransactionId || null, + debt_id: transactionData.debtId || null, + contribution_id: transactionData.contributionId || null, + budget_id: transactionData.budgetId || null, + }) + .returning(); + + return this.mapToEntity(result[0]); + } + + async update( + id: number, + transactionData: Partial + ): Promise { + const updateData: Record = {}; + + if (transactionData.amount !== undefined) { + updateData.amount = transactionData.amount; + } + if (transactionData.type !== undefined) { + updateData.type = transactionData.type; + } + if (transactionData.categoryId !== undefined) { + updateData.category_id = transactionData.categoryId; + } + if (transactionData.description !== undefined) { + updateData.description = transactionData.description; + } + if (transactionData.paymentMethodId !== undefined) { + updateData.payment_method_id = transactionData.paymentMethodId; + } + if (transactionData.scheduledTransactionId !== undefined) { + updateData.scheduled_transaction_id = + transactionData.scheduledTransactionId; + } + if (transactionData.debtId !== undefined) { + updateData.debt_id = transactionData.debtId; + } + + const result = await this.db + .update(transactions) + .set(updateData) + .where(eq(transactions.id, id)) + .returning(); + + return this.mapToEntity(result[0]); + } + + async delete(id: number): Promise { + const result = await this.db + .delete(transactions) + .where(eq(transactions.id, id)) + .returning(); + + return result.length > 0; + } + + async getMonthlyBalance( + userId: number, + month: Date + ): Promise<{ + totalIncome: number; + totalExpense: number; + balance: number; + }> { + const year = month.getUTCFullYear(); + const monthNumber = month.getUTCMonth(); + + const startDate = new Date(Date.UTC(year, monthNumber, 1)); + const endDate = new Date(Date.UTC(year, monthNumber + 1, 0)); + + const result = await this.db + .select({ + type: transactions.type, + total: sql`sum(${transactions.amount})`, + }) + .from(transactions) + .where( + and( + eq(transactions.user_id, userId), + between(transactions.date, startDate, endDate) + ) + ) + .groupBy(transactions.type); + + const totals = { + totalIncome: 0, + totalExpense: 0, + balance: 0, + }; + + result.forEach((row) => { + if (row.type === "INCOME") { + totals.totalIncome = Number(row.total) || 0; + } else { + totals.totalExpense = Number(row.total) || 0; + } + }); + + totals.balance = totals.totalIncome - totals.totalExpense; + return totals; + } + + async getCategoryTotals( + userId: number, + startDate: Date, + endDate: Date + ): Promise< + Array<{ categoryId: number; categoryName: string | null; total: number }> + > { + const utcStartDate = setStartOfDay(startDate); + const utcEndDate = setEndOfDay(endDate); + + console.log("UTC Start date:", utcStartDate.toISOString()); + console.log("UTC End date:", utcEndDate.toISOString()); + + const result = await this.db + .select({ + categoryId: transactions.category_id, + categoryName: categories.name, + total: sql`sum(${transactions.amount})`, + }) + .from(transactions) + .leftJoin(categories, eq(transactions.category_id, categories.id)) + .where( + and( + eq(transactions.user_id, userId), + between(transactions.date, utcStartDate, utcEndDate) + ) + ) + .groupBy(transactions.category_id, categories.name); + + return result.map((row) => ({ + categoryId: row.categoryId || 0, + categoryName: row.categoryName, + total: Number(row.total) || 0, + })); + } + private mapToEntity(raw: any): ITransaction { + return { + id: raw.id, + userId: raw.user_id, + amount: Number(raw.amount), + type: raw.type, + categoryId: raw.category_id, + category: raw.category + ? { + id: raw.category.id, + name: raw.category.name, + description: raw.category.description, + } + : null, + description: raw.description, + paymentMethodId: raw.payment_method_id, + paymentMethod: raw.payment_method + ? { + id: raw.payment_method.id, + name: raw.payment_method.name, + type: raw.payment_method.type, + lastFourDigits: raw.payment_method.last_four_digits, + userId: raw.payment_method.user_id, + createdAt: raw.payment_method.created_at, + updatedAt: raw.payment_method.updated_at, + } + : null, + date: raw.date, + scheduledTransactionId: raw.scheduled_transaction_id, + debtId: raw.debt_id, + budgetId: raw.budget_id, + contributionId: raw.contribution_id, + createdAt: raw.created_at, + updatedAt: raw.updated_at, + }; + } + + async getMonthlyTrends(userId: number): Promise { + console.log("Getting monthly trends for user:", userId); + + try { + const result = await this.db + .select({ + month: sql`date_trunc('month', ${transactions.date})::date`, + type: transactions.type, + total: sql`COALESCE(sum(${transactions.amount}::numeric), 0)`, + }) + .from(transactions) + .where(eq(transactions.user_id, userId)) + .groupBy( + sql`date_trunc('month', ${transactions.date})::date`, + transactions.type + ) + .orderBy(sql`date_trunc('month', ${transactions.date})::date`); + + console.log("Raw query result:", result); + + if (!result || result.length === 0) { + return []; + } + + const monthlyData: Record = {}; + + result.forEach(({ month, type, total }) => { + const monthKey = month.substring(0, 7); + + if (!monthlyData[monthKey]) { + monthlyData[monthKey] = { + month: monthKey, + income: 0, + expense: 0, + }; + } + + if (type === "INCOME") { + monthlyData[monthKey].income = Number(total) || 0; + } else { + monthlyData[monthKey].expense = Number(total) || 0; + } + }); + + const trends = Object.values(monthlyData).sort( + (a, b) => new Date(a.month).getTime() - new Date(b.month).getTime() + ); + + console.log("Processed trends:", trends); + return trends; + } catch (error) { + console.error("Error in getMonthlyTrends:", error); + throw error; + } + } + + async findByDebtId(debtId: number): Promise { + const result = await this.db + .select({ + id: transactions.id, + user_id: transactions.user_id, + amount: transactions.amount, + type: transactions.type, + category_id: transactions.category_id, + category: { + id: categories.id, + name: categories.name, + description: categories.description, + }, + description: transactions.description, + payment_method_id: transactions.payment_method_id, + payment_method: { + id: payment_methods.id, + name: payment_methods.name, + type: payment_methods.type, + last_four_digits: payment_methods.last_four_digits, + user_id: payment_methods.user_id, + }, + date: transactions.date, + scheduled_transaction_id: transactions.scheduled_transaction_id, + debt_id: transactions.debt_id, + budget_id: transactions.budget_id, + contribution_id: transactions.contribution_id, + }) + .from(transactions) + .leftJoin(categories, eq(transactions.category_id, categories.id)) + .leftJoin( + payment_methods, + eq(transactions.payment_method_id, payment_methods.id) + ) + .where(eq(transactions.debt_id, debtId)); + return result.map(this.mapToEntity); + } + + async findByContributionId(contributionId: number): Promise { + const result = await this.db + .select({ + id: transactions.id, + user_id: transactions.user_id, + amount: transactions.amount, + type: transactions.type, + category_id: transactions.category_id, + category: { + id: categories.id, + name: categories.name, + description: categories.description, + }, + description: transactions.description, + payment_method_id: transactions.payment_method_id, + payment_method: { + id: payment_methods.id, + name: payment_methods.name, + type: payment_methods.type, + last_four_digits: payment_methods.last_four_digits, + user_id: payment_methods.user_id, + }, + date: transactions.date, + scheduled_transaction_id: transactions.scheduled_transaction_id, + debt_id: transactions.debt_id, + budget_id: transactions.budget_id, + contribution_id: transactions.contribution_id, + }) + .from(transactions) + .leftJoin(categories, eq(transactions.category_id, categories.id)) + .leftJoin( + payment_methods, + eq(transactions.payment_method_id, payment_methods.id) + ) + .where(eq(transactions.contribution_id, contributionId)); + return result.map(this.mapToEntity); + } + + async findByBudgetId(budgetId: number): Promise { + const result = await this.db + .select({ + id: transactions.id, + user_id: transactions.user_id, + amount: transactions.amount, + type: transactions.type, + category_id: transactions.category_id, + category: { + id: categories.id, + name: categories.name, + description: categories.description, + }, + description: transactions.description, + payment_method_id: transactions.payment_method_id, + payment_method: { + id: payment_methods.id, + name: payment_methods.name, + type: payment_methods.type, + last_four_digits: payment_methods.last_four_digits, + user_id: payment_methods.user_id, + }, + date: transactions.date, + scheduled_transaction_id: transactions.scheduled_transaction_id, + debt_id: transactions.debt_id, + budget_id: transactions.budget_id, + contribution_id: transactions.contribution_id, + }) + .from(transactions) + .leftJoin(categories, eq(transactions.category_id, categories.id)) + .leftJoin( + payment_methods, + eq(transactions.payment_method_id, payment_methods.id) + ) + .where(eq(transactions.budget_id, budgetId)); + return result.map(this.mapToEntity); + } } diff --git a/src/shared/utils/date.utils.ts b/src/shared/utils/date.utils.ts new file mode 100644 index 0000000..95b177a --- /dev/null +++ b/src/shared/utils/date.utils.ts @@ -0,0 +1,22 @@ +/** + * Sets the end date with the last hour of the day (23:59:59.999) + * to ensure all transactions from the day are included + * @param date - Base date + * @returns New date with the last hour of the day + */ +export function setEndOfDay(date: Date): Date { + const endOfDay = new Date(date); + endOfDay.setHours(23, 59, 59, 999); + return endOfDay; +} + +/** + * Sets the start date with the first hour of the day (00:00:00.000) + * @param date - Base date + * @returns New date with the first hour of the day + */ +export function setStartOfDay(date: Date): Date { + const startOfDay = new Date(date); + startOfDay.setHours(0, 0, 0, 0); + return startOfDay; +} From f353858b08ef362a23c1c3bf41e24bcf805196ef Mon Sep 17 00:00:00 2001 From: devjaes Date: Sat, 11 Oct 2025 10:12:47 -0500 Subject: [PATCH 2/4] feat: enhance reporting features with new transaction and budget reports - Added new report types: Transactions Summary, Expenses by Category, Monthly Trend, Budget Performance, and Financial Overview. - Implemented corresponding formatting functions for PDF generation of new report types. - Updated report service to handle new report types and their respective data processing. - Improved date handling in report filters and ensured proper validation for goal IDs in report requests. - Refactored PDF generation components for better modularity and maintainability. --- .../infrastucture/adapters/goal.repository.ts | 146 ++- .../application/services/report.service.ts | 832 +++++++++++++++--- .../reports/domain/entities/report.entity.ts | 128 +++ .../controllers/report.controller.ts | 42 +- .../infrastructure/services/pdf.service.ts | 568 ++---------- .../services/pdf/pdf-budget-formatters.ts | 116 +++ .../infrastructure/services/pdf/pdf-charts.ts | 283 ++++++ .../services/pdf/pdf-components.ts | 87 ++ .../services/pdf/pdf-goal-formatters.ts | 295 +++++++ .../infrastructure/services/pdf/pdf-layout.ts | 77 ++ .../services/pdf/pdf-overview-formatters.ts | 243 +++++ .../infrastructure/services/pdf/pdf-table.ts | 76 ++ .../pdf/pdf-transaction-formatters.ts | 303 +++++++ .../infrastructure/services/pdf/pdf-utils.ts | 95 ++ 14 files changed, 2616 insertions(+), 675 deletions(-) create mode 100644 src/features/reports/infrastructure/services/pdf/pdf-budget-formatters.ts create mode 100644 src/features/reports/infrastructure/services/pdf/pdf-charts.ts create mode 100644 src/features/reports/infrastructure/services/pdf/pdf-components.ts create mode 100644 src/features/reports/infrastructure/services/pdf/pdf-goal-formatters.ts create mode 100644 src/features/reports/infrastructure/services/pdf/pdf-layout.ts create mode 100644 src/features/reports/infrastructure/services/pdf/pdf-overview-formatters.ts create mode 100644 src/features/reports/infrastructure/services/pdf/pdf-table.ts create mode 100644 src/features/reports/infrastructure/services/pdf/pdf-transaction-formatters.ts create mode 100644 src/features/reports/infrastructure/services/pdf/pdf-utils.ts diff --git a/src/features/goals/infrastucture/adapters/goal.repository.ts b/src/features/goals/infrastucture/adapters/goal.repository.ts index 80813f6..b501b35 100644 --- a/src/features/goals/infrastucture/adapters/goal.repository.ts +++ b/src/features/goals/infrastucture/adapters/goal.repository.ts @@ -1,4 +1,4 @@ -import { eq, sql, and, gte, lte } from "drizzle-orm"; +import { eq, sql, and, gte, lte, or } from "drizzle-orm"; import DatabaseConnection from "@/core/infrastructure/database"; import { categories, goal_contributions, goals } from "@/schema"; import { IGoalRepository } from "@/goals/domain/ports/goal-repository.port"; @@ -48,21 +48,6 @@ export class PgGoalRepository implements IGoalRepository { return result.map((row) => this.mapToEntity(row.goal)); } - async findById(id: number): Promise { - const result = await this.db - .select({ - goal: goals, - category: categories, - }) - .from(goals) - .leftJoin(categories, eq(goals.category_id, categories.id)) - .where(eq(goals.id, id)); - - return result[0] - ? this.mapToEntity(result[0].goal, result[0].category) - : null; - } - async findByUserId(userId: number): Promise { const result = await this.db .select({ @@ -71,87 +56,51 @@ export class PgGoalRepository implements IGoalRepository { }) .from(goals) .leftJoin(categories, eq(goals.category_id, categories.id)) - .where(eq(goals.user_id, userId)); - - return result.map((row) => this.mapToEntity(row.goal, row.category)); - } - - async findAllActive(): Promise { - const result = await this.db - .select({ - goal: goals, - category: categories, - }) - .from(goals) - .leftJoin(categories, eq(goals.category_id, categories.id)) - .where( - sql`${goals.current_amount} < ${goals.target_amount}` - ); - - return result.map((row) => this.mapToEntity(row.goal, row.category)); - } - - async findSharedWithUser(userId: number): Promise { - const result = await this.db - .select({ - goal: goals, - category: categories, - }) - .from(goals) - .leftJoin(categories, eq(goals.category_id, categories.id)) - .where(eq(goals.shared_user_id, userId)); + .where(or(eq(goals.user_id, userId), eq(goals.shared_user_id, userId))); return result.map((row) => this.mapToEntity(row.goal, row.category)); } - async create(goalData: Omit): Promise { + async create(goal: IGoal): Promise { const result = await this.db .insert(goals) .values({ - user_id: goalData.userId, - shared_user_id: goalData.sharedUserId, - name: goalData.name, - target_amount: goalData.targetAmount.toString(), - current_amount: goalData.currentAmount.toString(), - end_date: goalData.endDate, - category_id: goalData.categoryId, - contribution_frequency: goalData.contributionFrequency || 0, - contribution_amount: goalData.contributionAmount?.toString() || "0" + user_id: goal.userId, + shared_user_id: goal.sharedUserId, + name: goal.name, + target_amount: goal.targetAmount.toString(), + current_amount: goal.currentAmount.toString(), + end_date: goal.endDate, + category_id: goal.categoryId, + contribution_frequency: goal.contributionFrequency, + contribution_amount: goal.contributionAmount.toString(), }) .returning(); - const category = goalData.categoryId + const category = result[0].category_id ? await this.db .select() .from(categories) - .where(eq(categories.id, goalData.categoryId)) + .where(eq(categories.id, result[0].category_id)) .then((result) => result[0]) : null; return this.mapToEntity(result[0], category); } - async update(id: number, goalData: Partial): Promise { - const updateData: Record = {}; - - if (goalData.name !== undefined) updateData.name = goalData.name; - if (goalData.targetAmount !== undefined) - updateData.target_amount = goalData.targetAmount.toString(); - if (goalData.currentAmount !== undefined) - updateData.current_amount = goalData.currentAmount.toString(); - if (goalData.endDate !== undefined) updateData.end_date = goalData.endDate; - if (goalData.sharedUserId !== undefined) - updateData.shared_user_id = goalData.sharedUserId; - if (goalData.categoryId !== undefined) - updateData.category_id = goalData.categoryId; - if (goalData.contributionFrequency !== undefined) - updateData.contribution_frequency = goalData.contributionFrequency; - if (goalData.contributionAmount !== undefined) - updateData.contribution_amount = goalData.contributionAmount?.toString() || null; - + async update(id: number, goal: Partial): Promise { const result = await this.db .update(goals) - .set(updateData) + .set({ + name: goal.name, + target_amount: goal.targetAmount?.toString(), + current_amount: goal.currentAmount?.toString(), + end_date: goal.endDate, + category_id: goal.categoryId, + shared_user_id: goal.sharedUserId, + contribution_frequency: goal.contributionFrequency, + contribution_amount: goal.contributionAmount?.toString(), + }) .where(eq(goals.id, id)) .returning(); @@ -166,6 +115,29 @@ export class PgGoalRepository implements IGoalRepository { return this.mapToEntity(result[0], category); } + async findById(id: number): Promise { + const result = await this.db + .select({ + goal: goals, + category: categories, + }) + .from(goals) + .leftJoin(categories, eq(goals.category_id, categories.id)) + .where(eq(goals.id, id)); + + if (result.length === 0) return null; + + const category = result[0].category_id + ? await this.db + .select() + .from(categories) + .where(eq(categories.id, result[0].category_id)) + .then((result) => result[0]) + : null; + + return this.mapToEntity(result[0].goal, result[0].category); + } + async delete(id: number): Promise { const result = await this.db .delete(goals) @@ -203,22 +175,38 @@ export class PgGoalRepository implements IGoalRepository { async findByFilters(filters: ReportFilters): Promise { const conditions = []; + // Usuario es obligatorio if (filters.userId) { - conditions.push(eq(goals.user_id, Number(filters.userId))); + conditions.push( + or( + eq(goals.user_id, Number(filters.userId)), + eq(goals.shared_user_id, Number(filters.userId)) + ) + ); } + // Filtro por categoría (opcional) if (filters.categoryId) { conditions.push(eq(goals.category_id, Number(filters.categoryId))); } + // FIXED: Determinar qué campo usar para filtrar fechas + // Default: 'end_date' (para dashboard - metas que vencen en el período) + // Opción: 'created_at' (para reportes - metas creadas en el período) + const useCreatedAt = filters.filterBy === 'created_at'; + const dateField = useCreatedAt ? goals.created_at : goals.end_date; + + // Si hay startDate, filtrar metas según el campo determinado if (filters.startDate) { - conditions.push(gte(goals.end_date, new Date(filters.startDate))); + conditions.push(gte(dateField, filters.startDate)); } + // Si hay endDate, filtrar metas según el campo determinado if (filters.endDate) { - conditions.push(lte(goals.end_date, new Date(filters.endDate))); + conditions.push(lte(dateField, filters.endDate)); } + // Incluir compartidas (opcional) if (filters.includeShared) { conditions.push(sql`${goals.shared_user_id} IS NOT NULL`); } diff --git a/src/features/reports/application/services/report.service.ts b/src/features/reports/application/services/report.service.ts index 08d6454..3cd9f6c 100644 --- a/src/features/reports/application/services/report.service.ts +++ b/src/features/reports/application/services/report.service.ts @@ -8,6 +8,11 @@ import { ContributionReport, SavingsComparisonReport, SavingsSummaryReport, + TransactionsSummaryReport, + ExpensesByCategoryReport, + MonthlyTrendReport, + BudgetPerformanceReport, + FinancialOverviewReport, } from "../../domain/entities/report.entity"; import { ReportService } from "../../domain/services/report.service"; import { ReportRepository } from "../../domain/repositories/report.repository"; @@ -18,9 +23,10 @@ import { PgGoalContributionRepository } from "../../../goals/infrastucture/adapt import { PgGoalRepository } from "../../../goals/infrastucture/adapters/goal.repository"; import { ExcelService } from "../../infrastructure/services/excel.service"; import { CSVService } from "../../infrastructure/services/csv.service"; -import { ICategory } from "../../../categories/domain/entities/ICategory"; import { IGoal } from "../../../goals/domain/entities/IGoal"; import { IGoalContribution } from "../../../goals/domain/entities/IGoalContribution"; +import { ITransaction } from "@/transactions/domain/entities/ITransaction"; +import { IBudget } from "@/budgets/domain/entities/IBudget"; export class ReportServiceImpl implements ReportService { private static instance: ReportServiceImpl; @@ -96,6 +102,21 @@ export class ReportServiceImpl implements ReportService { case ReportType.SAVINGS_SUMMARY: data = await this.generateSavingsSummaryReport(filters); break; + case ReportType.TRANSACTIONS_SUMMARY: + data = await this.generateTransactionsSummaryReport(filters); + break; + case ReportType.EXPENSES_BY_CATEGORY: + data = await this.generateExpensesByCategoryReport(filters); + break; + case ReportType.MONTHLY_TREND: + data = await this.generateMonthlyTrendReport(filters); + break; + case ReportType.BUDGET_PERFORMANCE: + data = await this.generateBudgetPerformanceReport(filters); + break; + case ReportType.FINANCIAL_OVERVIEW: + data = await this.generateFinancialOverviewReport(filters); + break; default: throw new Error(`Unsupported report type: ${type}`); } @@ -130,6 +151,507 @@ export class ReportServiceImpl implements ReportService { await this.reportRepository.deleteExpired(); } + private async generateTransactionsSummaryReport( + filters: ReportFilters + ): Promise { + if (!filters.userId) { + throw new Error("User ID is required for transactions summary report"); + } + + const transactions = await this.transactionRepository.findByFilters( + Number(filters.userId), + { + startDate: filters.startDate?.toISOString(), + endDate: filters.endDate?.toISOString(), + type: undefined, + } + ); + + if (transactions.length === 0) { + return { + totalIncome: 0, + totalExpense: 0, + netBalance: 0, + transactionCount: 0, + incomeCount: 0, + expenseCount: 0, + averageIncome: 0, + averageExpense: 0, + transactions: [], + }; + } + + const incomeTransactions = transactions.filter( + (t: ITransaction) => t.type === "INCOME" + ); + const expenseTransactions = transactions.filter( + (t: ITransaction) => t.type === "EXPENSE" + ); + + const totalIncome = incomeTransactions.reduce( + (sum: number, t: ITransaction) => sum + (t.amount || 0), + 0 + ); + const totalExpense = expenseTransactions.reduce( + (sum: number, t: ITransaction) => sum + (t.amount || 0), + 0 + ); + + const categoryTotals = new Map< + string, + { name: string; amount: number; type: string } + >(); + transactions.forEach((t: ITransaction) => { + const categoryName = t.category?.name || "Sin categoría"; + const key = `${t.type}-${categoryName}`; + + if (!categoryTotals.has(key)) { + categoryTotals.set(key, { + name: categoryName, + amount: 0, + type: t.type, + }); + } + + const current = categoryTotals.get(key)!; + current.amount += t.amount || 0; + }); + + const topIncome = Array.from(categoryTotals.values()) + .filter((c) => c.type === "INCOME") + .sort((a, b) => b.amount - a.amount)[0]; + + const topExpense = Array.from(categoryTotals.values()) + .filter((c) => c.type === "EXPENSE") + .sort((a, b) => b.amount - a.amount)[0]; + + return { + totalIncome: Math.round(totalIncome * 100) / 100, + totalExpense: Math.round(totalExpense * 100) / 100, + netBalance: Math.round((totalIncome - totalExpense) * 100) / 100, + transactionCount: transactions.length, + incomeCount: incomeTransactions.length, + expenseCount: expenseTransactions.length, + averageIncome: + incomeTransactions.length > 0 + ? Math.round((totalIncome / incomeTransactions.length) * 100) / 100 + : 0, + averageExpense: + expenseTransactions.length > 0 + ? Math.round((totalExpense / expenseTransactions.length) * 100) / 100 + : 0, + topIncomeCategory: topIncome + ? { name: topIncome.name, amount: topIncome.amount } + : undefined, + topExpenseCategory: topExpense + ? { name: topExpense.name, amount: topExpense.amount } + : undefined, + transactions: transactions.map((t: ITransaction) => ({ + id: t.id?.toString() || "", + type: t.type as "INCOME" | "EXPENSE", + amount: t.amount || 0, + category: t.category?.name, + description: t.description || undefined, + date: t.date || new Date(), + })), + }; + } + + private async generateExpensesByCategoryReport( + filters: ReportFilters + ): Promise { + if (!filters.userId) { + throw new Error("User ID is required for expenses by category report"); + } + + const transactions = await this.transactionRepository.findByFilters( + Number(filters.userId), + { + startDate: filters.startDate?.toISOString(), + endDate: filters.endDate?.toISOString(), + type: "EXPENSE", + } + ); + + if (transactions.length === 0) { + return { + totalExpenses: 0, + categoryCount: 0, + categories: [], + }; + } + + const totalExpenses = transactions.reduce( + (sum: number, t: ITransaction) => sum + (t.amount || 0), + 0 + ); + + const categoryMap = new Map< + string, + { id: string; name: string; transactions: ITransaction[] } + >(); + + transactions.forEach((t: ITransaction) => { + const categoryId = t.categoryId?.toString() || "sin-categoria"; + const categoryName = t.category?.name || "Sin categoría"; + + if (!categoryMap.has(categoryId)) { + categoryMap.set(categoryId, { + id: categoryId, + name: categoryName, + transactions: [], + }); + } + + categoryMap.get(categoryId)!.transactions.push(t); + }); + + const categories = Array.from(categoryMap.values()) + .map((category) => { + const amount = category.transactions.reduce( + (sum: number, t: ITransaction) => sum + (t.amount || 0), + 0 + ); + const percentage = + totalExpenses > 0 + ? Math.round((amount / totalExpenses) * 100 * 100) / 100 + : 0; + + return { + id: category.id, + name: category.name, + amount: Math.round(amount * 100) / 100, + percentage, + transactionCount: category.transactions.length, + transactions: category.transactions.map((t: ITransaction) => ({ + id: t.id?.toString() || "", + amount: t.amount || 0, + description: t.description || undefined, + date: t.date || new Date(), + })), + }; + }) + .sort((a, b) => b.amount - a.amount); + + return { + totalExpenses: Math.round(totalExpenses * 100) / 100, + categoryCount: categories.length, + categories, + }; + } + + private async generateMonthlyTrendReport( + filters: ReportFilters + ): Promise { + if (!filters.userId) { + throw new Error("User ID is required for monthly trend report"); + } + + const transactions = await this.transactionRepository.findByFilters( + Number(filters.userId), + { + startDate: filters.startDate?.toISOString(), + endDate: filters.endDate?.toISOString(), + type: undefined, + } + ); + + if (transactions.length === 0) { + return { + months: [], + averageMonthlyIncome: 0, + averageMonthlyExpense: 0, + trend: "stable", + }; + } + + const monthlyData = new Map< + string, + { income: number; expense: number; count: number } + >(); + + transactions.forEach((t: ITransaction) => { + const date = new Date(t.date); + const monthKey = `${date.getFullYear()}-${String( + date.getMonth() + 1 + ).padStart(2, "0")}`; + + if (!monthlyData.has(monthKey)) { + monthlyData.set(monthKey, { income: 0, expense: 0, count: 0 }); + } + + const data = monthlyData.get(monthKey)!; + data.count++; + + if (t.type === "INCOME") { + data.income += t.amount || 0; + } else { + data.expense += t.amount || 0; + } + }); + + const months = Array.from(monthlyData.entries()) + .map(([month, data]) => ({ + month, + income: Math.round(data.income * 100) / 100, + expense: Math.round(data.expense * 100) / 100, + balance: Math.round((data.income - data.expense) * 100) / 100, + transactionCount: data.count, + })) + .sort((a, b) => a.month.localeCompare(b.month)); + + const totalIncome = months.reduce((sum, m) => sum + m.income, 0); + const totalExpense = months.reduce((sum, m) => sum + m.expense, 0); + const averageMonthlyIncome = + months.length > 0 + ? Math.round((totalIncome / months.length) * 100) / 100 + : 0; + const averageMonthlyExpense = + months.length > 0 + ? Math.round((totalExpense / months.length) * 100) / 100 + : 0; + + let trend: "increasing" | "decreasing" | "stable" = "stable"; + if (months.length >= 2) { + const firstHalf = months.slice(0, Math.floor(months.length / 2)); + const secondHalf = months.slice(Math.floor(months.length / 2)); + + const firstHalfBalance = + firstHalf.reduce((sum, m) => sum + m.balance, 0) / firstHalf.length; + const secondHalfBalance = + secondHalf.reduce((sum, m) => sum + m.balance, 0) / secondHalf.length; + + if (secondHalfBalance > firstHalfBalance * 1.1) trend = "increasing"; + else if (secondHalfBalance < firstHalfBalance * 0.9) trend = "decreasing"; + } + + return { + months, + averageMonthlyIncome, + averageMonthlyExpense, + trend, + }; + } + + private async generateBudgetPerformanceReport( + filters: ReportFilters + ): Promise { + if (!filters.userId) { + throw new Error("User ID is required for budget performance report"); + } + + const budgets = await this.budgetRepository.findByUserId( + Number(filters.userId) + ); + + if (budgets.length === 0) { + return { + totalBudgets: 0, + exceededCount: 0, + warningCount: 0, + goodCount: 0, + budgets: [], + }; + } + + let exceededCount = 0; + let warningCount = 0; + let goodCount = 0; + + const budgetDetails = budgets.map((budget: IBudget) => { + const limitAmount = budget.limitAmount || 0; + const currentAmount = budget.currentAmount || 0; + const percentage = + limitAmount > 0 + ? Math.round((currentAmount / limitAmount) * 100 * 100) / 100 + : 0; + + let status: "exceeded" | "warning" | "good"; + if (percentage >= 100) { + status = "exceeded"; + exceededCount++; + } else if (percentage >= 80) { + status = "warning"; + warningCount++; + } else { + status = "good"; + goodCount++; + } + + const startDate = budget.month ? new Date(budget.month) : new Date(); + const monthKey = `${startDate.getFullYear()}-${String( + startDate.getMonth() + 1 + ).padStart(2, "0")}`; + + return { + id: budget.id?.toString() || "", + categoryName: budget.category?.name || "Sin categoría", + limitAmount: Math.round(limitAmount * 100) / 100, + currentAmount: Math.round(currentAmount * 100) / 100, + percentage, + status, + month: monthKey, + }; + }); + + return { + totalBudgets: budgets.length, + exceededCount, + warningCount, + goodCount, + budgets: budgetDetails, + }; + } + + private async generateFinancialOverviewReport( + filters: ReportFilters + ): Promise { + if (!filters.userId) { + throw new Error("User ID is required for financial overview report"); + } + + const startDate = + filters.startDate || new Date(new Date().getFullYear(), 0, 1); + const endDate = filters.endDate || new Date(); + + const [transactions, goals, budgets] = await Promise.all([ + this.transactionRepository.findByFilters(Number(filters.userId), { + startDate: filters.startDate?.toISOString(), + endDate: filters.endDate?.toISOString(), + type: undefined, + }), + this.goalRepository.findByFilters({ userId: filters.userId }), + this.budgetRepository.findByUserId(Number(filters.userId)), + ]); + + const totalIncome = transactions + .filter((t: ITransaction) => t.type === "INCOME") + .reduce((sum: number, t: ITransaction) => sum + (t.amount || 0), 0); + + const totalExpense = transactions + .filter((t: ITransaction) => t.type === "EXPENSE") + .reduce((sum: number, t: ITransaction) => sum + (t.amount || 0), 0); + + const netBalance = totalIncome - totalExpense; + const savingsRate = + totalIncome > 0 + ? Math.round((netBalance / totalIncome) * 100 * 100) / 100 + : 0; + + const now = new Date(); + const completedGoals = goals.filter( + (g: IGoal) => (g.currentAmount || 0) >= (g.targetAmount || 0) + ).length; + const inProgressGoals = goals.filter( + (g: IGoal) => + (g.currentAmount || 0) < (g.targetAmount || 0) && g.endDate >= now + ).length; + const totalSaved = goals.reduce( + (sum: number, g: IGoal) => sum + (g.currentAmount || 0), + 0 + ); + const totalTarget = goals.reduce( + (sum: number, g: IGoal) => sum + (g.targetAmount || 0), + 0 + ); + const overallProgress = + totalTarget > 0 + ? Math.round((totalSaved / totalTarget) * 100 * 100) / 100 + : 0; + + const exceededBudgets = budgets.filter( + (b: IBudget) => (b.currentAmount || 0) >= (b.limitAmount || 0) + ).length; + const totalUtilization = budgets.reduce((sum: number, b: IBudget) => { + const limit = b.limitAmount || 0; + const current = b.currentAmount || 0; + return sum + (limit > 0 ? (current / limit) * 100 : 0); + }, 0); + const averageUtilization = + budgets.length > 0 + ? Math.round((totalUtilization / budgets.length) * 100) / 100 + : 0; + + const expenseCategoryTotals = new Map(); + const incomeCategoryTotals = new Map(); + + transactions.forEach((t: ITransaction) => { + const categoryName = t.category?.name || "Sin categoría"; + const amount = t.amount || 0; + + if (t.type === "EXPENSE") { + expenseCategoryTotals.set( + categoryName, + (expenseCategoryTotals.get(categoryName) || 0) + amount + ); + } else { + incomeCategoryTotals.set( + categoryName, + (incomeCategoryTotals.get(categoryName) || 0) + amount + ); + } + }); + + const topExpenses = Array.from(expenseCategoryTotals.entries()) + .map(([name, amount]) => ({ + name, + amount: Math.round(amount * 100) / 100, + percentage: + totalExpense > 0 + ? Math.round((amount / totalExpense) * 100 * 100) / 100 + : 0, + })) + .sort((a, b) => b.amount - a.amount) + .slice(0, 5); + + const topIncome = Array.from(incomeCategoryTotals.entries()) + .map(([name, amount]) => ({ + name, + amount: Math.round(amount * 100) / 100, + percentage: + totalIncome > 0 + ? Math.round((amount / totalIncome) * 100 * 100) / 100 + : 0, + })) + .sort((a, b) => b.amount - a.amount) + .slice(0, 5); + + return { + period: { + startDate, + endDate, + }, + summary: { + totalIncome: Math.round(totalIncome * 100) / 100, + totalExpense: Math.round(totalExpense * 100) / 100, + netBalance: Math.round(netBalance * 100) / 100, + savingsRate, + }, + goals: { + total: goals.length, + completed: completedGoals, + inProgress: inProgressGoals, + totalSaved: Math.round(totalSaved * 100) / 100, + totalTarget: Math.round(totalTarget * 100) / 100, + overallProgress, + }, + budgets: { + total: budgets.length, + exceeded: exceededBudgets, + averageUtilization, + }, + debts: { + total: 0, + totalAmount: 0, + totalPending: 0, + }, + topCategories: { + expenses: topExpenses, + income: topIncome, + }, + }; + } + private async generateGoalsByStatusReport( filters: ReportFilters ): Promise { @@ -141,7 +663,7 @@ export class ReportServiceImpl implements ReportService { const now = new Date(); if (goals.length === 0) { - console.log("No goals found"); + console.log("No goals found for user:", filters.userId); return { completed: 0, @@ -156,76 +678,127 @@ export class ReportServiceImpl implements ReportService { let expiredGoals = 0; let completedGoals = 0; - const report: GoalStatusReport = { - completed: completedGoals, - expired: expiredGoals, - inProgress: inProgressGoals, - total: goals.length, - goals: goals.map((goal: IGoal) => { - const status = this.determineGoalStatus(goal, now); - if (status === "completed") completedGoals++; - else if (status === "expired") expiredGoals++; - else inProgressGoals++; + const goalsWithStatus = goals.map((goal: IGoal) => { + const status = this.determineGoalStatus(goal, now); - return { - id: goal.id?.toString() || "", - name: goal.name, - status, - targetAmount: goal.targetAmount, - currentAmount: goal.currentAmount, - progress: (goal.currentAmount / goal.targetAmount) * 100, - deadline: goal.endDate, - }; - }), - }; + if (status === "completed") completedGoals++; + else if (status === "expired") expiredGoals++; + else inProgressGoals++; + + // Validaciones null-safe + const targetAmount = goal.targetAmount || 0; + const currentAmount = goal.currentAmount || 0; + const progress = + targetAmount > 0 + ? Math.round((currentAmount / targetAmount) * 100 * 100) / 100 + : 0; + + return { + id: goal.id?.toString() || "", + name: goal.name || "Sin nombre", + status, + targetAmount, + currentAmount, + progress, + deadline: goal.endDate || new Date(), + categoryName: goal.category?.name || "Sin categoría", // ADDED + }; + }); return { - ...report, completed: completedGoals, expired: expiredGoals, inProgress: inProgressGoals, + total: goals.length, + goals: goalsWithStatus, }; } private async generateGoalsByCategoryReport( filters: ReportFilters ): Promise { + if (!filters.userId) { + throw new Error("User ID is required for goals by category report"); + } + const goals = await this.goalRepository.findByFilters(filters); - const categories = await this.goalRepository.findAll(); - const report: GoalCategoryReport = { - categories: categories.map((category: ICategory) => { - const categoryGoals = goals.filter( - (goal: IGoal) => goal.categoryId === category.id - ); + if (goals.length === 0) { + console.log("No goals found for user:", filters.userId); + + return { + totalCategories: 0, + totalGoals: 0, + categories: [], + }; + } + + // Agrupar metas por categoría + const goalsByCategory = new Map(); + + goals.forEach((goal: IGoal) => { + const categoryId = goal.categoryId?.toString() || "sin-categoria"; + const categoryName = goal.category?.name || "Sin categoría"; + const key = `${categoryId}|${categoryName}`; + + if (!goalsByCategory.has(key)) { + goalsByCategory.set(key, []); + } + goalsByCategory.get(key)!.push(goal); + }); + + const now = new Date(); + const categories = Array.from(goalsByCategory.entries()).map( + ([key, categoryGoals]) => { + const [categoryId, categoryName] = key.split("|"); + const totalAmount = categoryGoals.reduce( - (sum: number, goal: IGoal) => sum + goal.targetAmount, + (sum: number, goal: IGoal) => sum + (goal.targetAmount || 0), 0 ); const completedAmount = categoryGoals.reduce( - (sum: number, goal: IGoal) => sum + goal.currentAmount, + (sum: number, goal: IGoal) => sum + (goal.currentAmount || 0), 0 ); + const progress = + totalAmount > 0 + ? Math.round((completedAmount / totalAmount) * 100 * 100) / 100 + : 0; return { - id: category.id?.toString() || "", - name: category.name, + id: categoryId, + name: categoryName, totalGoals: categoryGoals.length, totalAmount, completedAmount, - progress: (completedAmount / totalAmount) * 100, - goals: categoryGoals.map((goal: IGoal) => ({ - id: goal.id?.toString() || "", - name: goal.name, - targetAmount: goal.targetAmount, - currentAmount: goal.currentAmount, - progress: (goal.currentAmount / goal.targetAmount) * 100, - })), + progress, + goals: categoryGoals.map((goal: IGoal) => { + const targetAmount = goal.targetAmount || 0; + const currentAmount = goal.currentAmount || 0; + const goalProgress = + targetAmount > 0 + ? Math.round((currentAmount / targetAmount) * 100 * 100) / 100 + : 0; + + return { + id: goal.id?.toString() || "", + name: goal.name || "Sin nombre", + targetAmount, + currentAmount, + progress: goalProgress, + endDate: goal.endDate || new Date(), + status: this.determineGoalStatus(goal, now), + }; + }), }; - }), - }; + } + ); - return report; + return { + totalCategories: categories.length, + totalGoals: goals.length, + categories, + }; } private async generateContributionsByGoalReport( @@ -244,25 +817,24 @@ export class ReportServiceImpl implements ReportService { Number(filters.goalId) ); + const totalContributions = contributions.reduce( + (sum: number, c: IGoalContribution) => sum + (c.amount || 0), + 0 + ); + const report: ContributionReport = { goalId: goal.id?.toString() || "", - goalName: goal.name, + goalName: goal.name || "Sin nombre", contributions: contributions.map((contribution: IGoalContribution) => ({ id: contribution.id?.toString() || "", - amount: contribution.amount, - date: contribution.date, - transactionId: contribution.transactionId, + amount: contribution.amount || 0, + date: contribution.date || new Date(), + transactionId: contribution?.id?.toString(), })), - totalContributions: contributions.reduce( - (sum: number, c: IGoalContribution) => sum + c.amount, - 0 - ), + totalContributions, averageContribution: contributions.length > 0 - ? contributions.reduce( - (sum: number, c: IGoalContribution) => sum + c.amount, - 0 - ) / contributions.length + ? Math.round((totalContributions / contributions.length) * 100) / 100 : 0, lastContributionDate: contributions.length > 0 @@ -294,7 +866,7 @@ export class ReportServiceImpl implements ReportService { const report: SavingsComparisonReport = { goalId: goal.id?.toString() || "", - goalName: goal.name, + goalName: goal.name || "Sin nombre", plannedSavings, actualSavings, deviations: plannedSavings.map((planned, index) => ({ @@ -311,73 +883,115 @@ export class ReportServiceImpl implements ReportService { private async generateSavingsSummaryReport( filters: ReportFilters ): Promise { + if (!filters.userId) { + throw new Error("User ID is required for savings summary report"); + } + const goals = await this.goalRepository.findByFilters(filters); - const categories = await this.goalRepository.findAll(); const contributions = await this.goalContributionRepository.findByFilters( filters ); + if (goals.length === 0) { + return { + totalGoals: 0, + totalTargetAmount: 0, + totalCurrentAmount: 0, + overallProgress: 0, + completedGoals: 0, + expiredGoals: 0, + inProgressGoals: 0, + averageContribution: 0, + lastContributionDate: undefined, + categoryBreakdown: [], + }; + } + const now = new Date(); const completedGoals = goals.filter( - (g: IGoal) => g.currentAmount >= g.targetAmount + (g: IGoal) => (g.currentAmount || 0) >= (g.targetAmount || 0) ).length; const expiredGoals = goals.filter( - (g: IGoal) => g.endDate < now && g.currentAmount < g.targetAmount + (g: IGoal) => + g.endDate < now && (g.currentAmount || 0) < (g.targetAmount || 0) ).length; const inProgressGoals = goals.length - completedGoals - expiredGoals; + const totalTargetAmount = goals.reduce( + (sum: number, g: IGoal) => sum + (g.targetAmount || 0), + 0 + ); + const totalCurrentAmount = goals.reduce( + (sum: number, g: IGoal) => sum + (g.currentAmount || 0), + 0 + ); + + const overallProgress = + totalTargetAmount > 0 + ? Math.round((totalCurrentAmount / totalTargetAmount) * 100 * 100) / 100 + : 0; + + const totalContributions = contributions.reduce( + (sum: number, c: IGoalContribution) => sum + (c.amount || 0), + 0 + ); + + // Agrupar por categoría + const goalsByCategory = new Map(); + goals.forEach((goal: IGoal) => { + const categoryId = goal.categoryId?.toString() || "sin-categoria"; + const categoryName = goal.category?.name || "Sin categoría"; + const key = `${categoryId}|${categoryName}`; + + if (!goalsByCategory.has(key)) { + goalsByCategory.set(key, []); + } + goalsByCategory.get(key)!.push(goal); + }); + + const categoryBreakdown = Array.from(goalsByCategory.entries()).map( + ([key, categoryGoals]) => { + const [categoryId, categoryName] = key.split("|"); + const totalAmount = categoryGoals.reduce( + (sum: number, g: IGoal) => sum + (g.targetAmount || 0), + 0 + ); + const currentAmount = categoryGoals.reduce( + (sum: number, g: IGoal) => sum + (g.currentAmount || 0), + 0 + ); + const progress = + totalAmount > 0 + ? Math.round((currentAmount / totalAmount) * 100 * 100) / 100 + : 0; + + return { + categoryId, + categoryName, + totalGoals: categoryGoals.length, + totalAmount, + progress, + }; + } + ); + const report: SavingsSummaryReport = { totalGoals: goals.length, - totalTargetAmount: goals.reduce( - (sum: number, g: IGoal) => sum + g.targetAmount, - 0 - ), - totalCurrentAmount: goals.reduce( - (sum: number, g: IGoal) => sum + g.currentAmount, - 0 - ), - overallProgress: - (goals.reduce( - (sum: number, g: IGoal) => sum + g.currentAmount / g.targetAmount, - 0 - ) / - goals.length) * - 100, + totalTargetAmount, + totalCurrentAmount, + overallProgress, completedGoals, expiredGoals, inProgressGoals, averageContribution: contributions.length > 0 - ? contributions.reduce( - (sum: number, c: IGoalContribution) => sum + c.amount, - 0 - ) / contributions.length + ? Math.round((totalContributions / contributions.length) * 100) / 100 : 0, lastContributionDate: contributions.length > 0 ? contributions[contributions.length - 1].date : undefined, - categoryBreakdown: categories.map((category: ICategory) => { - const categoryGoals = goals.filter( - (g: IGoal) => g.categoryId === category.id - ); - const totalAmount = categoryGoals.reduce( - (sum: number, g: IGoal) => sum + g.targetAmount, - 0 - ); - const currentAmount = categoryGoals.reduce( - (sum: number, g: IGoal) => sum + g.currentAmount, - 0 - ); - - return { - categoryId: category.id?.toString() || "", - categoryName: category.name, - totalGoals: categoryGoals.length, - totalAmount, - progress: (currentAmount / totalAmount) * 100, - }; - }), + categoryBreakdown, }; return report; @@ -387,7 +1001,10 @@ export class ReportServiceImpl implements ReportService { goal: IGoal, now: Date ): "completed" | "expired" | "inProgress" { - if (goal.currentAmount >= goal.targetAmount) return "completed"; + const currentAmount = goal.currentAmount || 0; + const targetAmount = goal.targetAmount || 0; + + if (currentAmount >= targetAmount) return "completed"; if (goal.endDate < now) return "expired"; return "inProgress"; } @@ -402,8 +1019,9 @@ export class ReportServiceImpl implements ReportService { const plannedSavings = []; let currentDate = new Date(startDate); + const monthsBetween = this.monthsBetween(startDate, endDate); const monthlyTarget = - goal.targetAmount / this.monthsBetween(startDate, endDate); + monthsBetween > 0 ? (goal.targetAmount || 0) / monthsBetween : 0; while (currentDate <= endDate) { plannedSavings.push({ @@ -444,7 +1062,7 @@ export class ReportServiceImpl implements ReportService { } const currentAmount = groupedContributions.get(key) || 0; - groupedContributions.set(key, currentAmount + contribution.amount); + groupedContributions.set(key, currentAmount + (contribution.amount || 0)); }); return Array.from(groupedContributions.entries()) @@ -459,11 +1077,11 @@ export class ReportServiceImpl implements ReportService { } private monthsBetween(startDate: Date, endDate: Date): number { - return ( + const months = (endDate.getFullYear() - startDate.getFullYear()) * 12 + (endDate.getMonth() - startDate.getMonth()) + - 1 - ); + 1; + return months > 0 ? months : 1; // Evitar división por 0 } private generateReportId(): string { diff --git a/src/features/reports/domain/entities/report.entity.ts b/src/features/reports/domain/entities/report.entity.ts index cfc31cb..0e5d6af 100644 --- a/src/features/reports/domain/entities/report.entity.ts +++ b/src/features/reports/domain/entities/report.entity.ts @@ -20,6 +20,11 @@ export enum ReportType { CONTRIBUTIONS_BY_GOAL = "CONTRIBUTIONS_BY_GOAL", SAVINGS_COMPARISON = "SAVINGS_COMPARISON", SAVINGS_SUMMARY = "SAVINGS_SUMMARY", + TRANSACTIONS_SUMMARY = "TRANSACTIONS_SUMMARY", + EXPENSES_BY_CATEGORY = "EXPENSES_BY_CATEGORY", + MONTHLY_TREND = "MONTHLY_TREND", + BUDGET_PERFORMANCE = "BUDGET_PERFORMANCE", + FINANCIAL_OVERVIEW = "FINANCIAL_OVERVIEW", } export enum ReportFormat { @@ -37,6 +42,7 @@ export interface ReportFilters { goalId?: string; includeShared?: boolean; groupBy?: "day" | "week" | "month"; + filterBy?: "created_at" | "end_date"; [key: string]: any; } @@ -53,10 +59,13 @@ export interface GoalStatusReport { currentAmount: number; progress: number; deadline: Date; + categoryName?: string; }>; } export interface GoalCategoryReport { + totalCategories: number; + totalGoals: number; categories: Array<{ id: string; name: string; @@ -70,6 +79,8 @@ export interface GoalCategoryReport { targetAmount: number; currentAmount: number; progress: number; + endDate: Date; + status: "completed" | "expired" | "inProgress"; }>; }>; } @@ -125,3 +136,120 @@ export interface SavingsSummaryReport { progress: number; }>; } + +export interface TransactionsSummaryReport { + totalIncome: number; + totalExpense: number; + netBalance: number; + transactionCount: number; + incomeCount: number; + expenseCount: number; + averageIncome: number; + averageExpense: number; + topIncomeCategory?: { + name: string; + amount: number; + }; + topExpenseCategory?: { + name: string; + amount: number; + }; + transactions: Array<{ + id: string; + type: "INCOME" | "EXPENSE"; + amount: number; + category?: string; + description?: string; + date: Date; + }>; +} + +export interface ExpensesByCategoryReport { + totalExpenses: number; + categoryCount: number; + categories: Array<{ + id: string; + name: string; + amount: number; + percentage: number; + transactionCount: number; + transactions: Array<{ + id: string; + amount: number; + description?: string; + date: Date; + }>; + }>; +} + +export interface MonthlyTrendReport { + months: Array<{ + month: string; + income: number; + expense: number; + balance: number; + transactionCount: number; + }>; + averageMonthlyIncome: number; + averageMonthlyExpense: number; + trend: "increasing" | "decreasing" | "stable"; +} + +export interface BudgetPerformanceReport { + totalBudgets: number; + exceededCount: number; + warningCount: number; + goodCount: number; + budgets: Array<{ + id: string; + categoryName: string; + limitAmount: number; + currentAmount: number; + percentage: number; + status: "exceeded" | "warning" | "good"; + month: string; + }>; +} + +export interface FinancialOverviewReport { + period: { + startDate: Date; + endDate: Date; + }; + summary: { + totalIncome: number; + totalExpense: number; + netBalance: number; + savingsRate: number; + }; + goals: { + total: number; + completed: number; + inProgress: number; + totalSaved: number; + totalTarget: number; + overallProgress: number; + }; + budgets: { + total: number; + exceeded: number; + averageUtilization: number; + }; + debts: { + total: number; + totalAmount: number; + totalPending: number; + }; + topCategories: { + expenses: Array<{ + name: string; + amount: number; + percentage: number; + }>; + income: Array<{ + name: string; + amount: number; + percentage: number; + }>; + }; +} diff --git a/src/features/reports/infrastructure/controllers/report.controller.ts b/src/features/reports/infrastructure/controllers/report.controller.ts index f4f3dcc..5a4f994 100644 --- a/src/features/reports/infrastructure/controllers/report.controller.ts +++ b/src/features/reports/infrastructure/controllers/report.controller.ts @@ -41,7 +41,34 @@ const generateReportHandler = createHandler(async (c: Context) => { try { const { type, format, filters } = await c.req.json(); - const report = await reportService.generateReport(type, format, filters); + // FIXED: Convertir strings de fecha a Date objects + const processedFilters = { + ...filters, + startDate: filters.startDate ? new Date(filters.startDate) : undefined, + endDate: filters.endDate ? new Date(filters.endDate) : undefined, + // ADDED: Para reportes, siempre usar 'created_at' para filtrar metas + filterBy: 'created_at' as const, + }; + + // FIXED: Validar que goalId sea proporcionado cuando se requiere + const requiresGoalId = [ + ReportType.CONTRIBUTIONS_BY_GOAL, + ReportType.SAVINGS_COMPARISON, + ]; + + if (requiresGoalId.includes(type) && !processedFilters.goalId) { + return c.json( + { + success: false, + data: null, + message: "Se requiere seleccionar una meta para este tipo de reporte", + }, + HttpStatusCodes.BAD_REQUEST + ); + } + + const report = await reportService.generateReport(type, format, processedFilters); + return c.json( { success: true, @@ -57,7 +84,7 @@ const generateReportHandler = createHandler(async (c: Context) => { HttpStatusCodes.OK ); } catch (error) { - console.error(error); + console.error("Error generating report:", error); return c.json( { @@ -111,6 +138,7 @@ const getReportHandler = createHandler(async (c: Context) => { }); } + // FIXED: Manejar correctamente las fechas en el response JSON return c.json( { success: true, @@ -119,14 +147,20 @@ const getReportHandler = createHandler(async (c: Context) => { type: report.type, format: report.format, data: report.data, - createdAt: report.createdAt?.toISOString(), - expiresAt: report.expiresAt?.toISOString(), + createdAt: typeof report.createdAt === 'string' + ? report.createdAt + : report.createdAt?.toISOString(), + expiresAt: typeof report.expiresAt === 'string' + ? report.expiresAt + : report.expiresAt?.toISOString(), }, message: "Report retrieved successfully", }, HttpStatusCodes.OK ); } catch (error) { + console.error("Error retrieving report:", error); + return c.json( { success: false, diff --git a/src/features/reports/infrastructure/services/pdf.service.ts b/src/features/reports/infrastructure/services/pdf.service.ts index 2366f1e..6d5502c 100644 --- a/src/features/reports/infrastructure/services/pdf.service.ts +++ b/src/features/reports/infrastructure/services/pdf.service.ts @@ -1,37 +1,30 @@ import { Report, ReportType } from "../../domain/entities/report.entity"; import PDFDocument from "pdfkit"; -import path from "path"; -import fs from "fs/promises"; -import { PDFDocument as PDFLib, rgb, StandardFonts } from "pdf-lib"; +import { addModernHeader, addModernFooter, addReportTitle } from "./pdf/pdf-layout"; +import { + formatGoalsByStatus, + formatGoalsByCategory, + formatContributionsByGoal, + formatSavingsComparison, + formatSavingsSummary, +} from "./pdf/pdf-goal-formatters"; +import { + formatTransactionsSummary, + formatExpensesByCategory, + formatMonthlyTrend, +} from "./pdf/pdf-transaction-formatters"; +import { formatBudgetPerformance } from "./pdf/pdf-budget-formatters"; +import { formatFinancialOverview } from "./pdf/pdf-overview-formatters"; export class PDFService { - private readonly HEADER_HEIGHT = 50; - private readonly FOOTER_HEIGHT = 30; - private readonly PRIMARY_COLOR = "#2c3e50"; - private readonly SECONDARY_COLOR = "#3498db"; - private readonly TEXT_COLOR = "#2c3e50"; - private readonly TABLE_HEADER_COLOR = "#f8f9fa"; - private readonly TABLE_BORDER_COLOR = "#dee2e6"; - private readonly ROW_HEIGHT = 25; - private readonly HEADER_HEIGHT_TABLE = 30; - private readonly MIN_SPACE_AFTER_TABLE = 30; - private readonly CELL_PADDING = 5; - private readonly ITEM_SPACING = 15; - private readonly SECTION_SPACING = 20; - async generatePDF(report: Report): Promise { - // Si es un reporte de tipo GOAL, usamos el template - if (report.type === ReportType.GOAL) { - return this.generateGoalPDF(report); - } - - // Para otros tipos de reportes, generamos dinámicamente const doc = new PDFDocument({ size: "A4", margin: 50, + bufferPages: true, info: { - Title: "Reporte Financiero", - Author: "Fopymes", + Title: `Reporte Financiero - ${this.getReportTypeLabel(report.type)}`, + Author: "FoppyAI", Subject: "Análisis Financiero", Keywords: "finanzas, reporte, metas, ahorro", CreationDate: new Date(), @@ -48,473 +41,78 @@ export class PDFService { }); doc.on("error", reject); - this.addHeader(doc); - doc.moveDown(2); - doc.fontSize(24).fillColor(this.PRIMARY_COLOR).text("Reporte", 50, 60); - doc.moveDown(); - - switch (report.type) { - case ReportType.GOALS_BY_STATUS: - this.formatGoalsByStatus(doc, report.data); - break; - case ReportType.GOALS_BY_CATEGORY: - this.formatGoalsByCategory(doc, report.data); - break; - case ReportType.CONTRIBUTIONS_BY_GOAL: - this.formatContributionsByGoal(doc, report.data); - break; - case ReportType.SAVINGS_COMPARISON: - this.formatSavingsComparison(doc, report.data); - break; - case ReportType.SAVINGS_SUMMARY: - this.formatSavingsSummary(doc, report.data); - break; - default: - reject( - new Error(`Unsupported report type for PDF export: ${report.type}`) - ); - return; - } - - this.addFooter(doc); - doc.end(); - }); - } - - private async generateGoalPDF(report: Report): Promise { - try { - // Cargar el template PDF - const templatePath = path.join( - __dirname, - "../../domain/templates/goals.pdf" - ); - const existingPdfBytes = await fs.readFile(templatePath); - const pdfDoc = await PDFLib.load(new Uint8Array(existingPdfBytes)); - - // Obtener la primera página - const page = pdfDoc.getPages()[0]; - const { width, height } = page.getSize(); - - // Añadir el contenido al template - const helveticaFont = await pdfDoc.embedFont(StandardFonts.Helvetica); - - // Configurar el contenido - page.drawText(`Meta: ${report.data.name}`, { - x: 50, - y: height - 150, - size: 16, - font: helveticaFont, - color: rgb(0.17, 0.24, 0.31), // PRIMARY_COLOR en RGB - }); - - // Añadir más información de la meta - const yPositions = { - targetAmount: height - 180, - currentAmount: height - 210, - progress: height - 240, - endDate: height - 270, - }; - - page.drawText( - `Meta: $${report.data?.targetAmount?.toLocaleString() || "0"}`, - { - x: 50, - y: yPositions.targetAmount, - size: 12, - font: helveticaFont, + try { + addModernHeader(doc, this.getReportTypeLabel(report.type)); + doc.moveDown(1); + addReportTitle(doc, this.getReportTypeLabel(report.type)); + doc.moveDown(1); + + switch (report.type) { + case ReportType.GOALS_BY_STATUS: + formatGoalsByStatus(doc, report.data); + break; + case ReportType.GOALS_BY_CATEGORY: + formatGoalsByCategory(doc, report.data); + break; + case ReportType.CONTRIBUTIONS_BY_GOAL: + formatContributionsByGoal(doc, report.data); + break; + case ReportType.SAVINGS_COMPARISON: + formatSavingsComparison(doc, report.data); + break; + case ReportType.SAVINGS_SUMMARY: + formatSavingsSummary(doc, report.data); + break; + case ReportType.TRANSACTIONS_SUMMARY: + formatTransactionsSummary(doc, report.data); + break; + case ReportType.EXPENSES_BY_CATEGORY: + formatExpensesByCategory(doc, report.data); + break; + case ReportType.MONTHLY_TREND: + formatMonthlyTrend(doc, report.data); + break; + case ReportType.BUDGET_PERFORMANCE: + formatBudgetPerformance(doc, report.data); + break; + case ReportType.FINANCIAL_OVERVIEW: + formatFinancialOverview(doc, report.data); + break; + default: + throw new Error(`Unsupported report type for PDF export: ${report.type}`); } - ); - page.drawText( - `Ahorrado: $${report.data?.currentAmount?.toLocaleString() || "0"}`, - { - x: 50, - y: yPositions.currentAmount, - size: 12, - font: helveticaFont, + const pageCount = doc.bufferedPageRange().count; + for (let i = 0; i < pageCount; i++) { + doc.switchToPage(i); + addModernFooter(doc, i + 1, pageCount); } - ); - - const progress = ( - (report.data?.currentAmount / report.data?.targetAmount) * - 100 - ).toFixed(2); - page.drawText(`Progreso: ${progress}%`, { - x: 50, - y: yPositions.progress, - size: 12, - font: helveticaFont, - }); - - page.drawText( - `Fecha límite: ${ - report.data?.endDate - ? new Date(report.data.endDate).toLocaleDateString() - : "No disponible" - }`, - { - x: 50, - y: yPositions.endDate, - size: 12, - font: helveticaFont, - } - ); - - // Guardar el PDF modificado - const pdfBytes = await pdfDoc.save(); - return Buffer.from(pdfBytes); - } catch (error) { - console.error("Error al generar el PDF de meta:", error); - throw new Error("No se pudo generar el PDF de meta"); - } - } - - private addHeader(doc: typeof PDFDocument) { - doc - .rect(0, 0, doc.page.width, this.HEADER_HEIGHT) - .fillColor(this.PRIMARY_COLOR) - .fill(); - - doc - .fontSize(16) - .fillColor("#ffffff") - .text("Reporte Financiero de Fopymes", 50, 20); - - const date = new Date().toLocaleDateString(); - doc.fontSize(10).text(date, doc.page.width - 100, 20, { align: "right" }); - } - - private addFooter(doc: typeof PDFDocument) { - const pageHeight = doc.page.height; - doc - .rect( - 0, - pageHeight - this.FOOTER_HEIGHT, - doc.page.width, - this.FOOTER_HEIGHT - ) - .fillColor(this.PRIMARY_COLOR) - .fill(); - - doc - .fontSize(8) - .fillColor("#ffffff") - .text( - "© 2025 Fopymes. Todos los derechos reservados.", - 50, - pageHeight - 20 - ); - - doc.text( - `Página ${doc.bufferedPageRange().start + 1} de ${ - doc.bufferedPageRange().count - }`, - doc.page.width - 100, - pageHeight - 20, - { align: "right" } - ); - } - - private checkPageBreak( - doc: typeof PDFDocument, - requiredHeight: number - ): boolean { - const currentY = doc.y; - const pageHeight = doc.page.height; - const footerHeight = this.FOOTER_HEIGHT; - return currentY + requiredHeight > pageHeight - footerHeight; - } - - private addNewPage(doc: typeof PDFDocument) { - doc.addPage(); - this.addHeader(doc); - doc.moveDown(2); - } - - private drawSummaryTable( - doc: typeof PDFDocument, - headers: string[], - rows: any[][], - startX: number, - startY: number, - colWidths: number[] - ) { - const tableWidth = colWidths.reduce((a, b) => a + b, 0); - const tableHeight = - this.HEADER_HEIGHT_TABLE + rows.length * this.ROW_HEIGHT; - - if (this.checkPageBreak(doc, tableHeight)) { - this.addNewPage(doc); - startY = doc.y; - } - - // Draw header - doc - .rect(startX, startY, tableWidth, this.HEADER_HEIGHT_TABLE) - .fillColor(this.TABLE_HEADER_COLOR) - .fill() - .strokeColor(this.TABLE_BORDER_COLOR) - .stroke(); - - let x = startX; - headers.forEach((header, i) => { - doc - .fontSize(12) - .fillColor(this.PRIMARY_COLOR) - .text(header, x + this.CELL_PADDING, startY + this.CELL_PADDING, { - width: colWidths[i] - 2 * this.CELL_PADDING, - align: "left", - }); - x += colWidths[i]; - }); - - // Draw rows - rows.forEach((row, rowIndex) => { - const y = startY + this.HEADER_HEIGHT_TABLE + rowIndex * this.ROW_HEIGHT; - x = startX; - - doc - .rect(startX, y, tableWidth, this.ROW_HEIGHT) - .strokeColor(this.TABLE_BORDER_COLOR) - .stroke(); - - row.forEach((cell, colIndex) => { - doc - .fontSize(10) - .fillColor(this.TEXT_COLOR) - .text(cell.toString(), x + this.CELL_PADDING, y + this.CELL_PADDING, { - width: colWidths[colIndex] - 2 * this.CELL_PADDING, - align: "left", - }); - x += colWidths[colIndex]; - }); - }); - - doc.y = - startY + - this.HEADER_HEIGHT_TABLE + - rows.length * this.ROW_HEIGHT + - this.MIN_SPACE_AFTER_TABLE; - } - private drawDataItem( - doc: typeof PDFDocument, - label: string, - value: string, - indent: number = 0 - ) { - if (this.checkPageBreak(doc, this.ITEM_SPACING)) { - this.addNewPage(doc); - } - - doc - .fontSize(12) - .fillColor(this.TEXT_COLOR) - .text(`${label}:`, 50 + indent, doc.y, { continued: true }) - .text(value, { align: "left" }); - doc.moveDown(); - } - - private formatGoalsByStatus(doc: typeof PDFDocument, data: any) { - doc - .fontSize(18) - .fillColor(this.SECONDARY_COLOR) - .text("Metas por estado", 50, 100); - doc.moveDown(); - - data.goals.forEach((goal: any) => { - if (this.checkPageBreak(doc, this.SECTION_SPACING * 5)) { - this.addNewPage(doc); - } - - doc.fontSize(14).fillColor(this.PRIMARY_COLOR).text(goal.name); - doc.moveDown(); - - this.drawDataItem(doc, "Estado", goal.status, 20); - this.drawDataItem(doc, "Meta", goal.targetAmount, 20); - this.drawDataItem(doc, "Ahorrado", goal.currentAmount, 20); - this.drawDataItem(doc, "Progreso", `${goal.progress}%`, 20); - this.drawDataItem(doc, "Plazo", goal.deadline, 20); - doc.moveDown(); - }); - - // Summary table - doc.fontSize(16).fillColor(this.SECONDARY_COLOR).text("Resumen"); - doc.moveDown(); - - const summaryHeaders = ["Métrica", "Valor"]; - const summaryRows = [ - ["Total Metas", data.total], - ["Metas completadas", data.completed], - ["Metas expiradas", data.expired], - ["Metas en progreso", data.inProgress], - ]; - - this.drawSummaryTable( - doc, - summaryHeaders, - summaryRows, - 50, - doc.y, - [150, 100] - ); - } - - private formatGoalsByCategory(doc: typeof PDFDocument, data: any) { - doc - .fontSize(18) - .fillColor(this.SECONDARY_COLOR) - .text("Metas de ahorro por categoría"); - doc.moveDown(); - - data.categories.forEach((category: any) => { - if (this.checkPageBreak(doc, this.SECTION_SPACING * 10)) { - this.addNewPage(doc); - } - - doc.fontSize(16).fillColor(this.PRIMARY_COLOR).text(category.name); - doc.moveDown(); - - this.drawDataItem(doc, "Total Metas", category.totalGoals, 20); - this.drawDataItem(doc, "Total Cantidad", category.totalAmount, 20); - this.drawDataItem(doc, "Ahorrado", category.completedAmount, 20); - this.drawDataItem(doc, "Progreso", `${category.progress}%`, 20); - doc.moveDown(); - - doc - .fontSize(14) - .fillColor(this.SECONDARY_COLOR) - .text("Metas en esta categoría:"); - doc.moveDown(); - - category.goals.forEach((goal: any) => { - this.drawDataItem(doc, "Meta de ahorro", goal.name, 40); - this.drawDataItem(doc, "Meta", goal.targetAmount, 40); - this.drawDataItem(doc, "Ahorrado", goal.currentAmount, 40); - this.drawDataItem(doc, "Progreso", `${goal.progress}%`, 40); - doc.moveDown(); - }); - }); - } - - private formatContributionsByGoal(doc: typeof PDFDocument, data: any) { - doc - .fontSize(18) - .fillColor(this.SECONDARY_COLOR) - .text("Contribuciones por meta"); - doc.moveDown(); - - doc.fontSize(16).fillColor(this.PRIMARY_COLOR).text(data.goalName); - doc.moveDown(); - - data.contributions.forEach((contribution: any) => { - if (this.checkPageBreak(doc, this.SECTION_SPACING * 3)) { - this.addNewPage(doc); - } - - this.drawDataItem(doc, "Fecha", contribution.date, 20); - this.drawDataItem(doc, "Cantidad", contribution.amount, 20); - this.drawDataItem( - doc, - "ID de transacción", - contribution.transactionId, - 20 - ); - doc.moveDown(); - }); - - // Summary table - doc.fontSize(16).fillColor(this.SECONDARY_COLOR).text("Resumen"); - doc.moveDown(); - - const summaryHeaders = ["Metric", "Value"]; - const summaryRows = [ - ["Total Contribuciones", data.totalContributions], - ["Contribución promedio", data.averageContribution], - ["Última contribución", data.lastContributionDate], - ]; - - this.drawSummaryTable( - doc, - summaryHeaders, - summaryRows, - 50, - doc.y, - [150, 100] - ); - } - - private formatSavingsComparison(doc: typeof PDFDocument, data: any) { - doc - .fontSize(18) - .fillColor(this.SECONDARY_COLOR) - .text("Comparación de ahorro"); - doc.moveDown(); - - doc.fontSize(16).fillColor(this.PRIMARY_COLOR).text(data.goalName); - doc.moveDown(); - - data.deviations.forEach((deviation: any) => { - if (this.checkPageBreak(doc, this.SECTION_SPACING * 4)) { - this.addNewPage(doc); + doc.end(); + } catch (error) { + reject(error); } - - this.drawDataItem(doc, "Fecha", deviation.date, 20); - this.drawDataItem(doc, "Meta", deviation.plannedAmount, 20); - this.drawDataItem(doc, "Ahorrado", deviation.actualAmount, 20); - this.drawDataItem(doc, "Diferencia", deviation.difference, 20); - doc.moveDown(); }); } - private formatSavingsSummary(doc: typeof PDFDocument, data: any) { - doc.fontSize(18).fillColor(this.SECONDARY_COLOR).text("Resumen de ahorro"); - doc.moveDown(); - - // Overall metrics table - doc.fontSize(16).fillColor(this.PRIMARY_COLOR).text("Métricas generales"); - doc.moveDown(); - - const metricsHeaders = ["Métrica", "Valor"]; - const metricsRows = [ - ["Total Metas", data.totalGoals], - ["Meta total", data.totalTargetAmount], - ["Ahorrado total", data.totalCurrentAmount], - ["Progreso general", `${data.overallProgress}%`], - ["Metas completadas", data.completedGoals], - ["Metas expiradas", data.expiredGoals], - ["Metas en progreso", data.inProgressGoals], - ["Contribución promedio", data.averageContribution], - ["Última contribución", data.lastContributionDate], - ]; - - this.drawSummaryTable( - doc, - metricsHeaders, - metricsRows, - 50, - doc.y, - [200, 150] - ); - doc.moveDown(); - - // Category breakdown - doc - .fontSize(16) - .fillColor(this.PRIMARY_COLOR) - .text("Desglose por categoría"); - doc.moveDown(); - - data.categoryBreakdown.forEach((category: any) => { - if (this.checkPageBreak(doc, this.SECTION_SPACING * 4)) { - this.addNewPage(doc); - } - - this.drawDataItem(doc, "Categoría", category.categoryName, 20); - this.drawDataItem(doc, "Total Metas", category.totalGoals, 20); - this.drawDataItem(doc, "Total Cantidad", category.totalAmount, 20); - this.drawDataItem(doc, "Progreso", `${category.progress}%`, 20); - doc.moveDown(); - }); + private getReportTypeLabel(type: ReportType): string { + const labels: Record = { + [ReportType.GOAL]: "Reporte de Meta", + [ReportType.CONTRIBUTION]: "Reporte de Contribuciones", + [ReportType.BUDGET]: "Reporte de Presupuesto", + [ReportType.EXPENSE]: "Reporte de Gastos", + [ReportType.INCOME]: "Reporte de Ingresos", + [ReportType.GOALS_BY_STATUS]: "Metas por Estado", + [ReportType.GOALS_BY_CATEGORY]: "Metas por Categoría", + [ReportType.CONTRIBUTIONS_BY_GOAL]: "Contribuciones por Meta", + [ReportType.SAVINGS_COMPARISON]: "Comparación de Ahorro", + [ReportType.SAVINGS_SUMMARY]: "Resumen de Ahorro", + [ReportType.TRANSACTIONS_SUMMARY]: "Resumen de Transacciones", + [ReportType.EXPENSES_BY_CATEGORY]: "Gastos por Categoría", + [ReportType.MONTHLY_TREND]: "Tendencia Mensual", + [ReportType.BUDGET_PERFORMANCE]: "Rendimiento de Presupuestos", + [ReportType.FINANCIAL_OVERVIEW]: "Vista General Financiera", + }; + return labels[type] || "Reporte Financiero"; } } diff --git a/src/features/reports/infrastructure/services/pdf/pdf-budget-formatters.ts b/src/features/reports/infrastructure/services/pdf/pdf-budget-formatters.ts new file mode 100644 index 0000000..54a199b --- /dev/null +++ b/src/features/reports/infrastructure/services/pdf/pdf-budget-formatters.ts @@ -0,0 +1,116 @@ +import PDFDocument from "pdfkit"; +import { BudgetPerformanceReport } from "../../../domain/entities/report.entity"; +import { addMetricCard, drawProgressBar } from "./pdf-components"; +import { drawTable } from "./pdf-table"; +import { formatCurrency } from "./pdf-utils"; +import { addNewPage } from "./pdf-layout"; + +export function formatBudgetPerformance( + doc: typeof PDFDocument.prototype, + data: BudgetPerformanceReport +): void { + const pageWidth = doc.page.width; + const margin = 50; + const contentWidth = pageWidth - margin * 2; + const cardWidth = (contentWidth - 30) / 4; + + addMetricCard( + doc, + "Total Budgets", + `${data.totalBudgets}`, + margin, + doc.y, + cardWidth, + "#3b82f6" + ); + addMetricCard( + doc, + "Exceeded", + `${data.exceededCount}`, + margin + cardWidth + 10, + doc.y - 80, + cardWidth, + "#ef4444" + ); + addMetricCard( + doc, + "Warning", + `${data.warningCount}`, + margin + (cardWidth + 10) * 2, + doc.y - 80, + cardWidth, + "#f59e0b" + ); + addMetricCard( + doc, + "Good", + `${data.goodCount}`, + margin + (cardWidth + 10) * 3, + doc.y - 80, + cardWidth, + "#10b981" + ); + + doc.moveDown(1); + + if (data.budgets.length > 0) { + doc.fontSize(14).fillColor("#1f2937").text("Budget Details", margin, doc.y); + doc.moveDown(0.5); + + data.budgets.forEach((budget, index) => { + if (doc.y > 650) addNewPage(doc); + + doc + .fontSize(12) + .fillColor("#1f2937") + .text(`${budget.categoryName} (${budget.month})`, margin, doc.y); + doc.moveDown(0.3); + + doc.fontSize(10).fillColor("#6b7280"); + doc.text( + `Limit: ${formatCurrency( + budget.limitAmount + )} | Current: ${formatCurrency(budget.currentAmount)}`, + margin, + doc.y + ); + doc.moveDown(0.5); + + const color = + budget.status === "exceeded" + ? "#ef4444" + : budget.status === "warning" + ? "#f59e0b" + : "#10b981"; + + drawProgressBar(doc, margin, doc.y, contentWidth, budget.percentage); + doc.moveDown(1); + + if (index < data.budgets.length - 1) { + doc.moveDown(0.5); + } + }); + + if (doc.y > 600) addNewPage(doc); + + doc.fontSize(14).fillColor("#1f2937").text("Summary Table", margin, doc.y); + doc.moveDown(0.5); + + const budgetData = data.budgets.map((b) => [ + b.categoryName, + b.month, + formatCurrency(b.limitAmount), + formatCurrency(b.currentAmount), + `${b.percentage.toFixed(1)}%`, + b.status.toUpperCase(), + ]); + + const columnWidths = [120, 80, 100, 100, 80, 80]; + drawTable( + doc, + ["Category", "Month", "Limit", "Current", "Used", "Status"], + budgetData, + columnWidths + ); + } +} diff --git a/src/features/reports/infrastructure/services/pdf/pdf-charts.ts b/src/features/reports/infrastructure/services/pdf/pdf-charts.ts new file mode 100644 index 0000000..a0c8739 --- /dev/null +++ b/src/features/reports/infrastructure/services/pdf/pdf-charts.ts @@ -0,0 +1,283 @@ +import PDFDocument from "pdfkit"; +import { COLORS, DIMENSIONS, checkPageBreak, formatCurrency } from "./pdf-utils"; +import { addNewPage } from "./pdf-layout"; + +export interface ChartDataPoint { + label: string; + value: number; + color?: string; +} + +export function drawBarChart( + doc: typeof PDFDocument, + data: ChartDataPoint[], + title: string, + width: number = 450, + height: number = 200 +) { + if (checkPageBreak(doc, height + 60)) { + addNewPage(doc); + } + + const startX = DIMENSIONS.MARGIN; + const startY = doc.y; + const chartWidth = width; + const chartHeight = height; + const barWidth = chartWidth / (data.length * 2); + const maxValue = Math.max(...data.map(d => d.value), 1); + + doc.fontSize(12) + .fillColor(COLORS.TEXT) + .font("Helvetica-Bold") + .text(title, startX, startY); + + doc.moveDown(0.5); + const chartStartY = doc.y; + + doc.rect(startX, chartStartY, chartWidth, chartHeight) + .strokeColor(COLORS.MEDIUM_GRAY) + .lineWidth(1) + .stroke(); + + const gridLines = 5; + for (let i = 0; i <= gridLines; i++) { + const y = chartStartY + (chartHeight / gridLines) * i; + doc.moveTo(startX, y) + .lineTo(startX + chartWidth, y) + .strokeColor(COLORS.LIGHT_GRAY) + .lineWidth(0.5) + .stroke(); + + const value = maxValue - (maxValue / gridLines) * i; + doc.fontSize(8) + .fillColor(COLORS.TEXT) + .text( + formatCurrency(value), + startX - 60, + y - 4, + { width: 50, align: "right" } + ); + } + + data.forEach((point, index) => { + const barHeight = (point.value / maxValue) * chartHeight; + const x = startX + (barWidth * 2 * index) + barWidth / 2; + const y = chartStartY + chartHeight - barHeight; + + doc.rect(x, y, barWidth, barHeight) + .fillColor(point.color || COLORS.SECONDARY) + .fill(); + + doc.fontSize(8) + .fillColor(COLORS.TEXT) + .text( + point.label, + x - 20, + chartStartY + chartHeight + 5, + { width: barWidth + 40, align: "center" } + ); + + doc.fontSize(7) + .text( + formatCurrency(point.value), + x - 20, + y - 12, + { width: barWidth + 40, align: "center" } + ); + }); + + doc.y = chartStartY + chartHeight + 40; +} + +export function drawPieChart( + doc: typeof PDFDocument, + data: ChartDataPoint[], + title: string, + size: number = 150 +) { + if (checkPageBreak(doc, size + 100)) { + addNewPage(doc); + } + + const startX = DIMENSIONS.MARGIN + size; + const startY = doc.y + size / 2; + const radius = size / 2; + + doc.fontSize(12) + .fillColor(COLORS.TEXT) + .font("Helvetica-Bold") + .text(title, DIMENSIONS.MARGIN, doc.y); + + doc.moveDown(1.5); + + const total = data.reduce((sum, d) => sum + d.value, 0); + let currentAngle = -Math.PI / 2; + + const colors = [ + COLORS.SECONDARY, + COLORS.ACCENT, + COLORS.WARNING, + COLORS.DANGER, + "#9333ea", + "#f97316", + ]; + + data.forEach((point, index) => { + const percentage = (point.value / total) * 100; + const angle = (point.value / total) * 2 * Math.PI; + const color = point.color || colors[index % colors.length]; + + doc.path( + `M ${startX} ${startY} ` + + `L ${startX + radius * Math.cos(currentAngle)} ${startY + radius * Math.sin(currentAngle)} ` + + `A ${radius} ${radius} 0 ${angle > Math.PI ? 1 : 0} 1 ` + + `${startX + radius * Math.cos(currentAngle + angle)} ${startY + radius * Math.sin(currentAngle + angle)} Z` + ) + .fillColor(color) + .fill(); + + currentAngle += angle; + }); + + const legendX = startX + radius + 40; + let legendY = startY - radius; + + data.forEach((point, index) => { + const color = point.color || colors[index % colors.length]; + const percentage = ((point.value / total) * 100).toFixed(1); + + doc.rect(legendX, legendY, 12, 12) + .fillColor(color) + .fill(); + + doc.fontSize(9) + .fillColor(COLORS.TEXT) + .font("Helvetica") + .text( + `${point.label} (${percentage}%)`, + legendX + 18, + legendY, + { width: 150 } + ); + + legendY += 20; + }); + + doc.y = Math.max(startY + radius + 20, legendY + 20); +} + +export function drawLineChart( + doc: typeof PDFDocument, + data: { label: string; values: { name: string; value: number; color: string }[] }[], + title: string, + width: number = 450, + height: number = 200 +) { + if (checkPageBreak(doc, height + 80)) { + addNewPage(doc); + } + + const startX = DIMENSIONS.MARGIN + 50; + const startY = doc.y; + const chartWidth = width - 50; + const chartHeight = height; + + doc.fontSize(12) + .fillColor(COLORS.TEXT) + .font("Helvetica-Bold") + .text(title, DIMENSIONS.MARGIN, startY); + + doc.moveDown(0.5); + const chartStartY = doc.y; + + const allValues = data.flatMap(d => d.values.map(v => v.value)); + const maxValue = Math.max(...allValues, 1); + const minValue = Math.min(...allValues, 0); + const valueRange = maxValue - minValue || 1; + + doc.rect(startX, chartStartY, chartWidth, chartHeight) + .strokeColor(COLORS.MEDIUM_GRAY) + .lineWidth(1) + .stroke(); + + const gridLines = 5; + for (let i = 0; i <= gridLines; i++) { + const y = chartStartY + (chartHeight / gridLines) * i; + doc.moveTo(startX, y) + .lineTo(startX + chartWidth, y) + .strokeColor(COLORS.LIGHT_GRAY) + .lineWidth(0.5) + .stroke(); + + const value = maxValue - (valueRange / gridLines) * i; + doc.fontSize(8) + .fillColor(COLORS.TEXT) + .text( + formatCurrency(value), + startX - 45, + y - 4, + { width: 40, align: "right" } + ); + } + + if (data.length > 0 && data[0].values.length > 0) { + const pointSpacing = chartWidth / (data.length - 1 || 1); + + data[0].values.forEach((series, seriesIndex) => { + const color = series.color; + let firstPoint = true; + + data.forEach((point, pointIndex) => { + const value = point.values[seriesIndex]?.value || 0; + const x = startX + pointSpacing * pointIndex; + const y = chartStartY + chartHeight - ((value - minValue) / valueRange) * chartHeight; + + if (firstPoint) { + doc.moveTo(x, y); + firstPoint = false; + } else { + doc.lineTo(x, y); + } + + doc.circle(x, y, 3) + .fillColor(color) + .fill(); + }); + + doc.strokeColor(color) + .lineWidth(2) + .stroke(); + }); + } + + data.forEach((point, index) => { + const x = startX + (chartWidth / (data.length - 1 || 1)) * index; + doc.fontSize(8) + .fillColor(COLORS.TEXT) + .text( + point.label, + x - 30, + chartStartY + chartHeight + 10, + { width: 60, align: "center" } + ); + }); + + if (data.length > 0 && data[0].values.length > 0) { + const legendY = chartStartY + chartHeight + 40; + let legendX = startX; + + data[0].values.forEach((series) => { + doc.rect(legendX, legendY, 12, 12) + .fillColor(series.color) + .fill(); + + doc.fontSize(9) + .fillColor(COLORS.TEXT) + .text(series.name, legendX + 18, legendY, { width: 80 }); + + legendX += 120; + }); + } + + doc.y = chartStartY + chartHeight + 70; +} diff --git a/src/features/reports/infrastructure/services/pdf/pdf-components.ts b/src/features/reports/infrastructure/services/pdf/pdf-components.ts new file mode 100644 index 0000000..35b85d4 --- /dev/null +++ b/src/features/reports/infrastructure/services/pdf/pdf-components.ts @@ -0,0 +1,87 @@ +import PDFDocument from "pdfkit"; +import { COLORS, DIMENSIONS, SPACING, checkPageBreak } from "./pdf-utils"; +import { addNewPage } from "./pdf-layout"; + +export function addSectionTitle(doc: typeof PDFDocument, title: string) { + if (checkPageBreak(doc, 40)) { + addNewPage(doc); + } + + doc + .fontSize(16) + .fillColor(COLORS.SECONDARY) + .font("Helvetica-Bold") + .text(title, DIMENSIONS.MARGIN, doc.y); + + doc.moveDown(0.5); + doc.rect(DIMENSIONS.MARGIN, doc.y, 150, 2).fillColor(COLORS.SECONDARY).fill(); + doc.moveDown(1); +} + +export function addMetricCard( + doc: typeof PDFDocument, + label: string, + value: string, + x: number, + y: number, + width: number = 120, + color: string = COLORS.SECONDARY +) { + const height = 60; + + doc.rect(x, y, width, height).fillColor(COLORS.LIGHT_GRAY).fill(); + + doc.rect(x, y, width, 4).fillColor(color).fill(); + + doc + .fontSize(9) + .fillColor(COLORS.TEXT) + .font("Helvetica") + .text(label, x + 10, y + 15, { width: width - 20, align: "center" }); + + doc + .fontSize(16) + .fillColor(color) + .font("Helvetica-Bold") + .text(value, x + 10, y + 32, { width: width - 20, align: "center" }); +} + +export function drawProgressBar( + doc: typeof PDFDocument, + x: number, + y: number, + width: number, + progress: number +) { + const height = 12; + const validProgress = Math.min(Math.max(progress, 0), 100); + + doc.rect(x, y, width, height).fillColor(COLORS.LIGHT_GRAY).fill(); + + if (validProgress > 0) { + const progressWidth = (width * validProgress) / 100; + const color = + validProgress >= 100 + ? COLORS.ACCENT + : validProgress >= 70 + ? COLORS.WARNING + : COLORS.SECONDARY; + + doc.rect(x, y, progressWidth, height).fillColor(color).fill(); + } + + doc + .rect(x, y, width, height) + .strokeColor(COLORS.MEDIUM_GRAY) + .lineWidth(1) + .stroke(); + + doc + .fontSize(8) + .fillColor(COLORS.TEXT) + .font("Helvetica-Bold") + .text(`${validProgress.toFixed(1)}%`, x, y + 2, { + width: width, + align: "center", + }); +} diff --git a/src/features/reports/infrastructure/services/pdf/pdf-goal-formatters.ts b/src/features/reports/infrastructure/services/pdf/pdf-goal-formatters.ts new file mode 100644 index 0000000..bbf6fa9 --- /dev/null +++ b/src/features/reports/infrastructure/services/pdf/pdf-goal-formatters.ts @@ -0,0 +1,295 @@ +import PDFDocument from "pdfkit"; +import { + COLORS, + DIMENSIONS, + formatCurrency, + formatDate, + getStatusLabel, + getStatusColor, + checkPageBreak, +} from "./pdf-utils"; +import { addSectionTitle, addMetricCard, drawProgressBar } from "./pdf-components"; +import { drawTable } from "./pdf-table"; +import { addNewPage } from "./pdf-layout"; + +export function formatGoalsByStatus(doc: typeof PDFDocument, data: any) { + if (!data || !data.goals) { + doc.fontSize(12).fillColor(COLORS.TEXT).text("No hay datos disponibles"); + return; + } + + addSectionTitle(doc, "Resumen General"); + + const cardY = doc.y; + const cardWidth = 110; + const cardSpacing = 15; + + addMetricCard(doc, "Total Metas", data.total?.toString() || "0", DIMENSIONS.MARGIN, cardY, cardWidth, COLORS.SECONDARY); + addMetricCard(doc, "Completadas", data.completed?.toString() || "0", DIMENSIONS.MARGIN + cardWidth + cardSpacing, cardY, cardWidth, COLORS.ACCENT); + addMetricCard(doc, "En Progreso", data.inProgress?.toString() || "0", DIMENSIONS.MARGIN + 2 * (cardWidth + cardSpacing), cardY, cardWidth, COLORS.WARNING); + addMetricCard(doc, "Expiradas", data.expired?.toString() || "0", DIMENSIONS.MARGIN + 3 * (cardWidth + cardSpacing), cardY, cardWidth, COLORS.DANGER); + + doc.y = cardY + 70; + doc.moveDown(1); + + addSectionTitle(doc, "Detalle de Metas"); + + if (data.goals.length === 0) { + doc.fontSize(11).fillColor(COLORS.TEXT).text("No se encontraron metas para mostrar", DIMENSIONS.MARGIN, doc.y); + return; + } + + data.goals.forEach((goal: any, index: number) => { + if (checkPageBreak(doc, 120)) { + addNewPage(doc); + } + + if (index > 0) { + doc.moveDown(1); + } + + const statusColor = getStatusColor(goal.status); + const statusLabel = getStatusLabel(goal.status); + + doc.fontSize(13) + .fillColor(COLORS.PRIMARY) + .font("Helvetica-Bold") + .text(goal.name || "Sin nombre", DIMENSIONS.MARGIN, doc.y, { continued: true }) + .fontSize(9) + .fillColor(statusColor) + .text(` [${statusLabel}]`, { continued: false }); + + doc.moveDown(0.5); + + const col1X = DIMENSIONS.MARGIN; + const col2X = DIMENSIONS.MARGIN + 250; + const rowY = doc.y; + + doc.fontSize(10) + .fillColor(COLORS.TEXT) + .font("Helvetica") + .text(`Meta: ${formatCurrency(goal.targetAmount || 0)}`, col1X, rowY) + .text(`Categoría: ${goal.categoryName || "Sin categoría"}`, col2X, rowY); + + doc.moveDown(0.8); + const row2Y = doc.y; + + doc.text(`Ahorrado: ${formatCurrency(goal.currentAmount || 0)}`, col1X, row2Y) + .text(`Fecha límite: ${formatDate(goal.deadline)}`, col2X, row2Y); + + doc.moveDown(1); + + drawProgressBar(doc, DIMENSIONS.MARGIN, doc.y, 450, goal.progress || 0); + + doc.moveDown(1.5); + }); +} + +export function formatGoalsByCategory(doc: typeof PDFDocument, data: any) { + if (!data || !data.categories) { + doc.fontSize(12).fillColor(COLORS.TEXT).text("No hay datos disponibles"); + return; + } + + addSectionTitle(doc, "Resumen General"); + + const cardY = doc.y; + const cardWidth = 140; + const cardSpacing = 20; + + addMetricCard(doc, "Total Categorías", data.totalCategories?.toString() || "0", DIMENSIONS.MARGIN, cardY, cardWidth, COLORS.SECONDARY); + addMetricCard(doc, "Total Metas", data.totalGoals?.toString() || "0", DIMENSIONS.MARGIN + cardWidth + cardSpacing, cardY, cardWidth, COLORS.PRIMARY); + + doc.y = cardY + 70; + doc.moveDown(1); + + addSectionTitle(doc, "Detalle por Categoría"); + + if (data.categories.length === 0) { + doc.fontSize(11).fillColor(COLORS.TEXT).text("No se encontraron categorías con metas", DIMENSIONS.MARGIN, doc.y); + return; + } + + data.categories.forEach((category: any, catIndex: number) => { + if (checkPageBreak(doc, 200)) { + addNewPage(doc); + } + + if (catIndex > 0) { + doc.moveDown(1.5); + } + + doc.fontSize(14) + .fillColor(COLORS.SECONDARY) + .font("Helvetica-Bold") + .text(category.name || "Sin nombre", DIMENSIONS.MARGIN, doc.y); + + doc.moveDown(0.5); + + const col1X = DIMENSIONS.MARGIN; + const col2X = DIMENSIONS.MARGIN + 200; + const col3X = DIMENSIONS.MARGIN + 350; + const statsY = doc.y; + + doc.fontSize(9) + .fillColor(COLORS.TEXT) + .font("Helvetica") + .text(`Metas: ${category.totalGoals || 0}`, col1X, statsY) + .text(`Total: ${formatCurrency(category.totalAmount || 0)}`, col2X, statsY) + .text(`Ahorrado: ${formatCurrency(category.completedAmount || 0)}`, col3X, statsY); + + doc.moveDown(1); + + drawProgressBar(doc, DIMENSIONS.MARGIN, doc.y, 450, category.progress || 0); + + doc.moveDown(1); + + if (category.goals && category.goals.length > 0) { + const headers = ["Meta", "Objetivo", "Ahorrado", "Progreso", "Estado"]; + const rows = category.goals.map((goal: any) => [ + goal.name || "Sin nombre", + formatCurrency(goal.targetAmount || 0), + formatCurrency(goal.currentAmount || 0), + `${(goal.progress || 0).toFixed(1)}%`, + getStatusLabel(goal.status || "inProgress"), + ]); + + drawTable(doc, headers, rows, [140, 80, 80, 70, 80]); + } + + doc.moveDown(0.5); + }); +} + +export function formatContributionsByGoal(doc: typeof PDFDocument, data: any) { + if (!data) { + doc.fontSize(12).fillColor(COLORS.TEXT).text("No hay datos disponibles"); + return; + } + + addSectionTitle(doc, "Información de la Meta"); + + doc.fontSize(14) + .fillColor(COLORS.PRIMARY) + .font("Helvetica-Bold") + .text(data.goalName || "Sin nombre", DIMENSIONS.MARGIN, doc.y); + + doc.moveDown(1); + + const cardY = doc.y; + const cardWidth = 150; + const cardSpacing = 15; + + addMetricCard(doc, "Total Contribuciones", formatCurrency(data.totalContributions || 0), DIMENSIONS.MARGIN, cardY, cardWidth, COLORS.ACCENT); + addMetricCard(doc, "Promedio", formatCurrency(data.averageContribution || 0), DIMENSIONS.MARGIN + cardWidth + cardSpacing, cardY, cardWidth, COLORS.SECONDARY); + addMetricCard(doc, "Última Contribución", data.lastContributionDate ? formatDate(data.lastContributionDate) : "N/A", DIMENSIONS.MARGIN + 2 * (cardWidth + cardSpacing), cardY, cardWidth, COLORS.PRIMARY); + + doc.y = cardY + 70; + doc.moveDown(1); + + addSectionTitle(doc, "Historial de Contribuciones"); + + if (!data.contributions || data.contributions.length === 0) { + doc.fontSize(11).fillColor(COLORS.TEXT).text("No se encontraron contribuciones", DIMENSIONS.MARGIN, doc.y); + return; + } + + const headers = ["Fecha", "Monto", "ID Transacción"]; + const rows = data.contributions.map((contrib: any) => [ + formatDate(contrib.date), + formatCurrency(contrib.amount || 0), + contrib.transactionId || "N/A", + ]); + + drawTable(doc, headers, rows, [150, 150, 150]); +} + +export function formatSavingsComparison(doc: typeof PDFDocument, data: any) { + if (!data) { + doc.fontSize(12).fillColor(COLORS.TEXT).text("No hay datos disponibles"); + return; + } + + addSectionTitle(doc, "Comparación de Ahorro"); + + doc.fontSize(14) + .fillColor(COLORS.PRIMARY) + .font("Helvetica-Bold") + .text(data.goalName || "Sin nombre", DIMENSIONS.MARGIN, doc.y); + + doc.moveDown(1.5); + + if (!data.deviations || data.deviations.length === 0) { + doc.fontSize(11).fillColor(COLORS.TEXT).text("No hay datos de comparación disponibles", DIMENSIONS.MARGIN, doc.y); + return; + } + + const headers = ["Fecha", "Planificado", "Real", "Diferencia"]; + const rows = data.deviations.map((dev: any) => { + const diff = dev.difference || 0; + const diffStr = (diff >= 0 ? "+" : "") + formatCurrency(Math.abs(diff)); + return [ + formatDate(dev.date), + formatCurrency(dev.plannedAmount || 0), + formatCurrency(dev.actualAmount || 0), + diffStr, + ]; + }); + + drawTable(doc, headers, rows, [120, 120, 120, 120]); +} + +export function formatSavingsSummary(doc: typeof PDFDocument, data: any) { + if (!data) { + doc.fontSize(12).fillColor(COLORS.TEXT).text("No hay datos disponibles"); + return; + } + + addSectionTitle(doc, "Resumen General"); + + const cardY = doc.y; + const cardWidth = 110; + const cardSpacing = 12; + + addMetricCard(doc, "Total Metas", data.totalGoals?.toString() || "0", DIMENSIONS.MARGIN, cardY, cardWidth, COLORS.SECONDARY); + addMetricCard(doc, "Completadas", data.completedGoals?.toString() || "0", DIMENSIONS.MARGIN + cardWidth + cardSpacing, cardY, cardWidth, COLORS.ACCENT); + addMetricCard(doc, "En Progreso", data.inProgressGoals?.toString() || "0", DIMENSIONS.MARGIN + 2 * (cardWidth + cardSpacing), cardY, cardWidth, COLORS.WARNING); + addMetricCard(doc, "Expiradas", data.expiredGoals?.toString() || "0", DIMENSIONS.MARGIN + 3 * (cardWidth + cardSpacing), cardY, cardWidth, COLORS.DANGER); + + doc.y = cardY + 70; + doc.moveDown(1); + + addSectionTitle(doc, "Métricas Financieras"); + + const metricsY = doc.y; + + addMetricCard(doc, "Meta Total", formatCurrency(data.totalTargetAmount || 0), DIMENSIONS.MARGIN, metricsY, 150, COLORS.PRIMARY); + addMetricCard(doc, "Ahorrado Total", formatCurrency(data.totalCurrentAmount || 0), DIMENSIONS.MARGIN + 165, metricsY, 150, COLORS.ACCENT); + addMetricCard(doc, "Contribución Promedio", formatCurrency(data.averageContribution || 0), DIMENSIONS.MARGIN + 330, metricsY, 150, COLORS.SECONDARY); + + doc.y = metricsY + 70; + doc.moveDown(1); + + doc.fontSize(11) + .fillColor(COLORS.TEXT) + .font("Helvetica") + .text("Progreso General:", DIMENSIONS.MARGIN, doc.y); + + doc.moveDown(0.5); + drawProgressBar(doc, DIMENSIONS.MARGIN, doc.y, 450, data.overallProgress || 0); + doc.moveDown(1.5); + + if (data.categoryBreakdown && data.categoryBreakdown.length > 0) { + addSectionTitle(doc, "Desglose por Categoría"); + + const headers = ["Categoría", "Metas", "Monto Total", "Progreso"]; + const rows = data.categoryBreakdown.map((cat: any) => [ + cat.categoryName || "Sin categoría", + cat.totalGoals?.toString() || "0", + formatCurrency(cat.totalAmount || 0), + `${(cat.progress || 0).toFixed(1)}%`, + ]); + + drawTable(doc, headers, rows, [180, 80, 120, 100]); + } +} diff --git a/src/features/reports/infrastructure/services/pdf/pdf-layout.ts b/src/features/reports/infrastructure/services/pdf/pdf-layout.ts new file mode 100644 index 0000000..57ce513 --- /dev/null +++ b/src/features/reports/infrastructure/services/pdf/pdf-layout.ts @@ -0,0 +1,77 @@ +import PDFDocument from "pdfkit"; +import { COLORS, DIMENSIONS } from "./pdf-utils"; + +export function addModernHeader(doc: typeof PDFDocument, reportTitle: string) { + doc.rect(0, 0, DIMENSIONS.PAGE_WIDTH, DIMENSIONS.HEADER_HEIGHT) + .fillColor(COLORS.PRIMARY) + .fill(); + + doc.fontSize(24) + .fillColor(COLORS.WHITE) + .font("Helvetica-Bold") + .text("FoppyAI", DIMENSIONS.MARGIN, 20, { align: "left" }); + + doc.fontSize(10) + .fillColor(COLORS.WHITE) + .font("Helvetica") + .text("Sistema de Gestión Financiera Personal", DIMENSIONS.MARGIN, 48); + + const date = new Date().toLocaleDateString("es-ES", { + day: "2-digit", + month: "long", + year: "numeric", + }); + doc.fontSize(10) + .text(date, DIMENSIONS.PAGE_WIDTH - DIMENSIONS.MARGIN - 150, 25, { + width: 150, + align: "right" + }); + + doc.rect(0, DIMENSIONS.HEADER_HEIGHT - 5, DIMENSIONS.PAGE_WIDTH, 5) + .fillColor(COLORS.SECONDARY) + .fill(); + + doc.y = DIMENSIONS.HEADER_HEIGHT + 20; +} + +export function addModernFooter( + doc: typeof PDFDocument, + pageNum: number, + totalPages: number +) { + const footerY = DIMENSIONS.PAGE_HEIGHT - DIMENSIONS.FOOTER_HEIGHT; + + doc.rect(0, footerY, DIMENSIONS.PAGE_WIDTH, 2) + .fillColor(COLORS.MEDIUM_GRAY) + .fill(); + + doc.fontSize(8) + .fillColor(COLORS.TEXT) + .font("Helvetica") + .text( + `© ${new Date().getFullYear()} FoppyAI. Todos los derechos reservados.`, + DIMENSIONS.MARGIN, + footerY + 15, + { align: "left" } + ); + + doc.fontSize(8) + .text( + `Página ${pageNum} de ${totalPages}`, + DIMENSIONS.PAGE_WIDTH - DIMENSIONS.MARGIN - 100, + footerY + 15, + { width: 100, align: "right" } + ); +} + +export function addReportTitle(doc: typeof PDFDocument, title: string) { + doc.fontSize(20) + .fillColor(COLORS.PRIMARY) + .font("Helvetica-Bold") + .text(title, DIMENSIONS.MARGIN, doc.y, { align: "left" }); +} + +export function addNewPage(doc: typeof PDFDocument) { + doc.addPage(); + doc.y = DIMENSIONS.HEADER_HEIGHT + 20; +} diff --git a/src/features/reports/infrastructure/services/pdf/pdf-overview-formatters.ts b/src/features/reports/infrastructure/services/pdf/pdf-overview-formatters.ts new file mode 100644 index 0000000..ba6864e --- /dev/null +++ b/src/features/reports/infrastructure/services/pdf/pdf-overview-formatters.ts @@ -0,0 +1,243 @@ +import PDFDocument from "pdfkit"; +import { FinancialOverviewReport } from "../../../domain/entities/report.entity"; +import { addMetricCard, drawProgressBar } from "./pdf-components"; +import { drawTable } from "./pdf-table"; +import { drawBarChart, drawPieChart } from "./pdf-charts"; +import { formatCurrency, formatDate } from "./pdf-utils"; +import { addNewPage } from "./pdf-layout"; + +export function formatFinancialOverview( + doc: typeof PDFDocument.prototype, + data: FinancialOverviewReport +): void { + const pageWidth = doc.page.width; + const margin = 50; + const contentWidth = pageWidth - margin * 2; + const cardWidth = (contentWidth - 30) / 4; + + doc + .fontSize(12) + .fillColor("#6b7280") + .text( + `Period: ${formatDate(data.period.startDate)} - ${formatDate( + data.period.endDate + )}`, + margin, + doc.y + ); + doc.moveDown(1); + + doc + .fontSize(14) + .fillColor("#1f2937") + .text("Financial Summary", margin, doc.y); + doc.moveDown(0.5); + + addMetricCard( + doc, + "Total Income", + formatCurrency(data.summary.totalIncome), + margin, + doc.y, + cardWidth, + "#10b981" + ); + addMetricCard( + doc, + "Total Expense", + formatCurrency(data.summary.totalExpense), + margin + cardWidth + 10, + doc.y - 80, + cardWidth, + "#ef4444" + ); + addMetricCard( + doc, + "Net Balance", + formatCurrency(data.summary.netBalance), + margin + (cardWidth + 10) * 2, + doc.y - 80, + cardWidth, + "#3b82f6" + ); + addMetricCard( + doc, + "Savings Rate", + `${data.summary.savingsRate.toFixed(1)}%`, + margin + (cardWidth + 10) * 3, + doc.y - 80, + cardWidth, + "#8b5cf6" + ); + + doc.moveDown(1); + + if (data.summary.totalIncome > 0 || data.summary.totalExpense > 0) { + doc + .fontSize(14) + .fillColor("#1f2937") + .text("Income vs Expense", margin, doc.y); + doc.moveDown(0.5); + + const chartData = [ + { label: "Income", value: data.summary.totalIncome, color: "#10b981" }, + { label: "Expense", value: data.summary.totalExpense, color: "#ef4444" }, + ]; + + drawBarChart(doc, chartData, "Income vs Expense", contentWidth, 150); + doc.moveDown(2); + } + + if (doc.y > 600) addNewPage(doc); + + doc.fontSize(14).fillColor("#1f2937").text("Goals Overview", margin, doc.y); + doc.moveDown(0.5); + + const goalCardWidth = (contentWidth - 20) / 3; + addMetricCard( + doc, + "Total Goals", + `${data.goals.total}`, + margin, + doc.y, + goalCardWidth, + "#3b82f6" + ); + addMetricCard( + doc, + "Completed", + `${data.goals.completed}`, + margin + goalCardWidth + 10, + doc.y - 70, + goalCardWidth, + "#10b981" + ); + addMetricCard( + doc, + "In Progress", + `${data.goals.inProgress}`, + margin + (goalCardWidth + 10) * 2, + doc.y - 70, + goalCardWidth, + "#f59e0b" + ); + + doc.moveDown(0.8); + + doc + .fontSize(10) + .fillColor("#6b7280") + .text( + `Saved: ${formatCurrency( + data.goals.totalSaved + )} / Target: ${formatCurrency(data.goals.totalTarget)}`, + margin, + doc.y + ); + doc.moveDown(0.5); + + drawProgressBar(doc, margin, doc.y, contentWidth, data.goals.overallProgress); + doc.moveDown(1); + + doc.fontSize(14).fillColor("#1f2937").text("Budgets Overview", margin, doc.y); + doc.moveDown(0.5); + + const budgetCardWidth = (contentWidth - 20) / 3; + addMetricCard( + doc, + "Total Budgets", + `${data.budgets.total}`, + margin, + doc.y, + budgetCardWidth, + "#3b82f6" + ); + addMetricCard( + doc, + "Exceeded", + `${data.budgets.exceeded}`, + margin + budgetCardWidth + 10, + doc.y - 70, + budgetCardWidth, + "#ef4444" + ); + addMetricCard( + doc, + "Avg Usage", + `${data.budgets.averageUtilization.toFixed(1)}%`, + margin + (budgetCardWidth + 10) * 2, + doc.y - 70, + budgetCardWidth, + "#f59e0b" + ); + + doc.moveDown(1); + + if (doc.y > 600) addNewPage(doc); + + if (data.topCategories.expenses.length > 0) { + doc + .fontSize(14) + .fillColor("#1f2937") + .text("Top Expense Categories", margin, doc.y); + doc.moveDown(0.5); + + const expensePieData = data.topCategories.expenses.map((cat) => ({ + label: cat.name, + value: cat.amount, + percentage: cat.percentage, + })); + + drawPieChart(doc, expensePieData, "Top Expense Categories", 180); + doc.moveDown(2); + } + + if (doc.y > 600) addNewPage(doc); + + if (data.topCategories.expenses.length > 0) { + doc + .fontSize(14) + .fillColor("#1f2937") + .text("Top Expenses Breakdown", margin, doc.y); + doc.moveDown(0.5); + + const expenseData = data.topCategories.expenses.map((cat) => [ + cat.name, + formatCurrency(cat.amount), + `${cat.percentage.toFixed(1)}%`, + ]); + + const columnWidths = [150, 120, 100]; + drawTable( + doc, + ["Category", "Amount", "Percentage"], + expenseData, + columnWidths + ); + doc.moveDown(1); + } + + if (data.topCategories.income.length > 0) { + if (doc.y > 600) addNewPage(doc); + + doc + .fontSize(14) + .fillColor("#1f2937") + .text("Top Income Breakdown", margin, doc.y); + doc.moveDown(0.5); + + const incomeData = data.topCategories.income.map((cat) => [ + cat.name, + formatCurrency(cat.amount), + `${cat.percentage.toFixed(1)}%`, + ]); + + const columnWidths = [150, 120, 100]; + drawTable( + doc, + ["Category", "Amount", "Percentage"], + incomeData, + columnWidths + ); + } +} diff --git a/src/features/reports/infrastructure/services/pdf/pdf-table.ts b/src/features/reports/infrastructure/services/pdf/pdf-table.ts new file mode 100644 index 0000000..00db9f8 --- /dev/null +++ b/src/features/reports/infrastructure/services/pdf/pdf-table.ts @@ -0,0 +1,76 @@ +import PDFDocument from "pdfkit"; +import { COLORS, DIMENSIONS, TABLE, checkPageBreak } from "./pdf-utils"; +import { addNewPage } from "./pdf-layout"; + +export function drawTable( + doc: typeof PDFDocument, + headers: string[], + rows: string[][], + columnWidths: number[] +) { + const startX = DIMENSIONS.MARGIN; + const totalWidth = columnWidths.reduce((a, b) => a + b, 0); + const tableHeight = TABLE.HEADER_HEIGHT + rows.length * TABLE.ROW_HEIGHT; + + if (checkPageBreak(doc, tableHeight)) { + addNewPage(doc); + } + + let currentY = doc.y; + + doc.rect(startX, currentY, totalWidth, TABLE.HEADER_HEIGHT) + .fillColor(TABLE.HEADER_COLOR) + .fill(); + + let x = startX; + headers.forEach((header, i) => { + doc.fontSize(10) + .fillColor(COLORS.WHITE) + .font("Helvetica-Bold") + .text( + header, + x + TABLE.CELL_PADDING, + currentY + TABLE.CELL_PADDING, + { + width: columnWidths[i] - 2 * TABLE.CELL_PADDING, + align: "left", + } + ); + x += columnWidths[i]; + }); + + currentY += TABLE.HEADER_HEIGHT; + + rows.forEach((row, rowIndex) => { + const fillColor = rowIndex % 2 === 0 ? COLORS.WHITE : COLORS.LIGHT_GRAY; + doc.rect(startX, currentY, totalWidth, TABLE.ROW_HEIGHT) + .fillColor(fillColor) + .fill(); + + doc.rect(startX, currentY, totalWidth, TABLE.ROW_HEIGHT) + .strokeColor(TABLE.BORDER_COLOR) + .lineWidth(0.5) + .stroke(); + + x = startX; + row.forEach((cell, colIndex) => { + doc.fontSize(9) + .fillColor(COLORS.TEXT) + .font("Helvetica") + .text( + cell, + x + TABLE.CELL_PADDING, + currentY + TABLE.CELL_PADDING, + { + width: columnWidths[colIndex] - 2 * TABLE.CELL_PADDING, + align: colIndex === 0 ? "left" : "right", + } + ); + x += columnWidths[colIndex]; + }); + + currentY += TABLE.ROW_HEIGHT; + }); + + doc.y = currentY + 20; +} diff --git a/src/features/reports/infrastructure/services/pdf/pdf-transaction-formatters.ts b/src/features/reports/infrastructure/services/pdf/pdf-transaction-formatters.ts new file mode 100644 index 0000000..70b48b2 --- /dev/null +++ b/src/features/reports/infrastructure/services/pdf/pdf-transaction-formatters.ts @@ -0,0 +1,303 @@ +import PDFDocument from "pdfkit"; +import { + TransactionsSummaryReport, + ExpensesByCategoryReport, + MonthlyTrendReport, +} from "../../../domain/entities/report.entity"; +import { addMetricCard } from "./pdf-components"; +import { drawTable } from "./pdf-table"; +import { drawBarChart, drawPieChart, drawLineChart } from "./pdf-charts"; +import { formatCurrency, formatDate } from "./pdf-utils"; +import { addNewPage } from "./pdf-layout"; + +export function formatTransactionsSummary( + doc: typeof PDFDocument.prototype, + data: TransactionsSummaryReport +): void { + const pageWidth = doc.page.width; + const margin = 50; + const contentWidth = pageWidth - margin * 2; + const cardWidth = (contentWidth - 20) / 2; + + addMetricCard( + doc, + "Total Income", + formatCurrency(data.totalIncome), + margin, + doc.y, + cardWidth, + "#10b981" + ); + addMetricCard( + doc, + "Total Expense", + formatCurrency(data.totalExpense), + margin + cardWidth + 20, + doc.y - 80, + cardWidth, + "#ef4444" + ); + + doc.moveDown(1); + addMetricCard( + doc, + "Net Balance", + formatCurrency(data.netBalance), + margin, + doc.y, + cardWidth, + "#3b82f6" + ); + addMetricCard( + doc, + "Transactions", + `${data.transactionCount}`, + margin + cardWidth + 20, + doc.y - 80, + cardWidth, + "#8b5cf6" + ); + + doc.moveDown(1); + + if (data.totalIncome > 0 || data.totalExpense > 0) { + doc + .fontSize(14) + .fillColor("#1f2937") + .text("Income vs Expense", margin, doc.y); + doc.moveDown(0.5); + + const chartData = [ + { label: "Income", value: data.totalIncome, color: "#10b981" }, + { label: "Expense", value: data.totalExpense, color: "#ef4444" }, + ]; + + drawBarChart(doc, chartData, "Income vs Expense", contentWidth, 150); + doc.moveDown(2); + } + + if (data.topIncomeCategory || data.topExpenseCategory) { + doc.fontSize(14).fillColor("#1f2937").text("Top Categories", margin, doc.y); + doc.moveDown(0.5); + + const topCategoriesData = []; + if (data.topIncomeCategory) { + topCategoriesData.push([ + "Top Income", + data.topIncomeCategory.name, + formatCurrency(data.topIncomeCategory.amount), + ]); + } + if (data.topExpenseCategory) { + topCategoriesData.push([ + "Top Expense", + data.topExpenseCategory.name, + formatCurrency(data.topExpenseCategory.amount), + ]); + } + + const columnWidths = [100, 200, 150]; + drawTable( + doc, + ["Type", "Category", "Amount"], + topCategoriesData, + columnWidths + ); + doc.moveDown(1); + } + + if (data.transactions.length > 0) { + if (doc.y > 600) addNewPage(doc); + + doc + .fontSize(14) + .fillColor("#1f2937") + .text("Transaction Details", margin, doc.y); + doc.moveDown(0.5); + + const transactionsData = data.transactions + .slice(0, 20) + .map((t) => [ + formatDate(t.date), + t.type, + t.category || "N/A", + formatCurrency(t.amount), + ]); + + const columnWidths = [100, 80, 150, 120]; + drawTable( + doc, + ["Date", "Type", "Category", "Amount"], + transactionsData, + columnWidths + ); + } +} + +export function formatExpensesByCategory( + doc: typeof PDFDocument.prototype, + data: ExpensesByCategoryReport +): void { + const pageWidth = doc.page.width; + const margin = 50; + const contentWidth = pageWidth - margin * 2; + const cardWidth = (contentWidth - 20) / 2; + + addMetricCard( + doc, + "Total Expenses", + formatCurrency(data.totalExpenses), + margin, + doc.y, + cardWidth, + "#ef4444" + ); + addMetricCard( + doc, + "Categories", + `${data.categoryCount}`, + margin + cardWidth + 20, + doc.y - 80, + cardWidth, + "#3b82f6" + ); + + doc.moveDown(1); + + if (data.categories.length > 0) { + doc + .fontSize(14) + .fillColor("#1f2937") + .text("Expense Distribution", margin, doc.y); + doc.moveDown(0.5); + + const pieData = data.categories.slice(0, 10).map((cat) => ({ + label: cat.name, + value: cat.amount, + percentage: cat.percentage, + })); + + drawPieChart(doc, pieData, "Expense Distribution", 200); + doc.moveDown(2); + + if (doc.y > 600) addNewPage(doc); + + doc + .fontSize(14) + .fillColor("#1f2937") + .text("Category Breakdown", margin, doc.y); + doc.moveDown(0.5); + + const categoryData = data.categories.map((cat) => [ + cat.name, + formatCurrency(cat.amount), + `${cat.percentage.toFixed(1)}%`, + `${cat.transactionCount}`, + ]); + + const columnWidths = [150, 120, 100, 100]; + drawTable( + doc, + ["Category", "Amount", "Percentage", "Transactions"], + categoryData, + columnWidths + ); + } +} + +export function formatMonthlyTrend( + doc: typeof PDFDocument.prototype, + data: MonthlyTrendReport +): void { + const pageWidth = doc.page.width; + const margin = 50; + const contentWidth = pageWidth - margin * 2; + const cardWidth = (contentWidth - 30) / 3; + + addMetricCard( + doc, + "Avg Monthly Income", + formatCurrency(data.averageMonthlyIncome), + margin, + doc.y, + cardWidth, + "#10b981" + ); + addMetricCard( + doc, + "Avg Monthly Expense", + formatCurrency(data.averageMonthlyExpense), + margin + cardWidth + 15, + doc.y - 80, + cardWidth, + "#ef4444" + ); + addMetricCard( + doc, + "Trend", + data.trend.toUpperCase(), + margin + (cardWidth + 15) * 2, + doc.y - 80, + cardWidth, + "#3b82f6" + ); + + doc.moveDown(1); + + if (data.months.length > 0) { + doc + .fontSize(14) + .fillColor("#1f2937") + .text("Monthly Financial Trend", margin, doc.y); + doc.moveDown(0.5); + + const lineData = data.months.map((m) => ({ + label: m.month, + income: m.income, + expense: m.expense, + balance: m.balance, + })); + + // Convert lineData to the expected format + const formattedLineData = data.months.map((m) => ({ + label: m.month, + values: [ + { name: "Income", value: m.income, color: "#10b981" }, + { name: "Expense", value: m.expense, color: "#ef4444" }, + { name: "Balance", value: m.balance, color: "#3b82f6" }, + ], + })); + drawLineChart( + doc, + formattedLineData, + "Monthly Financial Trend", + contentWidth, + 180 + ); + doc.moveDown(2); + + if (doc.y > 600) addNewPage(doc); + + doc + .fontSize(14) + .fillColor("#1f2937") + .text("Monthly Details", margin, doc.y); + doc.moveDown(0.5); + + const monthData = data.months.map((m) => [ + m.month, + formatCurrency(m.income), + formatCurrency(m.expense), + formatCurrency(m.balance), + `${m.transactionCount}`, + ]); + + const columnWidths = [100, 120, 120, 120, 100]; + drawTable( + doc, + ["Month", "Income", "Expense", "Balance", "Transactions"], + monthData, + columnWidths + ); + } +} diff --git a/src/features/reports/infrastructure/services/pdf/pdf-utils.ts b/src/features/reports/infrastructure/services/pdf/pdf-utils.ts new file mode 100644 index 0000000..3b034c3 --- /dev/null +++ b/src/features/reports/infrastructure/services/pdf/pdf-utils.ts @@ -0,0 +1,95 @@ +import PDFDocument from "pdfkit"; + +export const COLORS = { + PRIMARY: "#1e3a8a", + SECONDARY: "#3b82f6", + ACCENT: "#10b981", + WARNING: "#f59e0b", + DANGER: "#ef4444", + TEXT: "#1f2937", + LIGHT_GRAY: "#f3f4f6", + MEDIUM_GRAY: "#e5e7eb", + WHITE: "#ffffff", +} as const; + +export const DIMENSIONS = { + PAGE_WIDTH: 595.28, + PAGE_HEIGHT: 841.89, + MARGIN: 50, + HEADER_HEIGHT: 80, + FOOTER_HEIGHT: 40, +} as const; + +export const SPACING = { + TITLE: 25, + SECTION: 20, + ITEM: 12, +} as const; + +export const TABLE = { + HEADER_COLOR: COLORS.PRIMARY, + BORDER_COLOR: COLORS.MEDIUM_GRAY, + ROW_HEIGHT: 30, + HEADER_HEIGHT: 35, + CELL_PADDING: 8, +} as const; + +export function formatCurrency(amount: number): string { + return `$${amount.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`; +} + +export function formatDate(date: Date | string): string { + const d = typeof date === "string" ? new Date(date) : date; + return d.toLocaleDateString("es-ES", { + day: "2-digit", + month: "short", + year: "numeric", + }); +} + +export function formatMonth(date: Date | string): string { + const d = typeof date === "string" ? new Date(date) : date; + return d.toLocaleDateString("es-ES", { + month: "short", + year: "numeric", + }); +} + +export function getStatusColor(status: string): string { + const colors: Record = { + completed: COLORS.ACCENT, + expired: COLORS.DANGER, + inProgress: COLORS.WARNING, + active: COLORS.ACCENT, + paid: COLORS.ACCENT, + overdue: COLORS.DANGER, + exceeded: COLORS.DANGER, + warning: COLORS.WARNING, + good: COLORS.ACCENT, + }; + return colors[status] || COLORS.TEXT; +} + +export function getStatusLabel(status: string): string { + const labels: Record = { + completed: "Completada", + expired: "Expirada", + inProgress: "En Progreso", + active: "Activa", + paid: "Pagada", + overdue: "Vencida", + exceeded: "Excedido", + warning: "Alerta", + good: "Bueno", + }; + return labels[status] || status; +} + +export function checkPageBreak( + doc: typeof PDFDocument, + requiredHeight: number +): boolean { + const currentY = doc.y; + const footerSpace = DIMENSIONS.FOOTER_HEIGHT + 20; + return currentY + requiredHeight > DIMENSIONS.PAGE_HEIGHT - footerSpace; +} From 8c2e8a856cbbad543783f5799334af291f63d8ca Mon Sep 17 00:00:00 2001 From: devjaes Date: Sun, 12 Oct 2025 00:45:54 -0500 Subject: [PATCH 3/4] feat: add comprehensive project documentation and enhance reporting services - Introduced a complete project documentation file detailing system architecture, backend and frontend technologies, and database structure. - Added new CSV and Excel formatting functions for various report types, including Budget Performance and Financial Overview. - Enhanced PDF generation for reports with improved layout and modularity. - Implemented new repository methods for goal sharing and refined existing report handling logic. - Updated date formatting utilities for consistent date representation across reports. --- documentation/BACK_PROJECT_DOC.md | 1302 +++++++++++++++++ documentation/PROJECT_DOC.md | 54 + .../infrastucture/adapters/goal.repository.ts | 35 +- .../controllers/report.controller.ts | 32 +- .../infrastructure/services/csv.service.ts | 184 +-- .../services/csv/csv-budget-formatters.ts | 58 + .../services/csv/csv-goal-formatters.ts | 152 ++ .../services/csv/csv-overview-formatters.ts | 109 ++ .../csv/csv-transaction-formatters.ts | 144 ++ .../services/csv/date-formatter.ts | 41 + .../infrastructure/services/excel.service.ts | 161 +- .../services/excel/excel-budget-formatters.ts | 38 + .../services/excel/excel-goal-formatters.ts | 130 ++ .../excel/excel-overview-formatters.ts | 68 + .../excel/excel-transaction-formatters.ts | 111 ++ .../infrastructure/services/pdf.service.ts | 68 +- .../services/pdf/pdf-budget-formatters.ts | 202 ++- .../services/pdf/pdf-components.ts | 4 +- .../services/pdf/pdf-goal-formatters.ts | 332 ++++- .../infrastructure/services/pdf/pdf-layout.ts | 98 +- .../services/pdf/pdf-overview-formatters.ts | 277 ++-- .../infrastructure/services/pdf/pdf-table.ts | 19 +- .../pdf/pdf-transaction-formatters.ts | 144 +- 23 files changed, 3055 insertions(+), 708 deletions(-) create mode 100644 documentation/BACK_PROJECT_DOC.md create mode 100644 documentation/PROJECT_DOC.md create mode 100644 src/features/reports/infrastructure/services/csv/csv-budget-formatters.ts create mode 100644 src/features/reports/infrastructure/services/csv/csv-goal-formatters.ts create mode 100644 src/features/reports/infrastructure/services/csv/csv-overview-formatters.ts create mode 100644 src/features/reports/infrastructure/services/csv/csv-transaction-formatters.ts create mode 100644 src/features/reports/infrastructure/services/csv/date-formatter.ts create mode 100644 src/features/reports/infrastructure/services/excel/excel-budget-formatters.ts create mode 100644 src/features/reports/infrastructure/services/excel/excel-goal-formatters.ts create mode 100644 src/features/reports/infrastructure/services/excel/excel-overview-formatters.ts create mode 100644 src/features/reports/infrastructure/services/excel/excel-transaction-formatters.ts diff --git a/documentation/BACK_PROJECT_DOC.md b/documentation/BACK_PROJECT_DOC.md new file mode 100644 index 0000000..6e20877 --- /dev/null +++ b/documentation/BACK_PROJECT_DOC.md @@ -0,0 +1,1302 @@ +# FoppyAI - Documentación Completa del Proyecto + +## Índice +1. [Información General](#información-general) +2. [Arquitectura del Sistema](#arquitectura-del-sistema) +3. [Backend (API REST)](#backend-api-rest) +4. [Base de Datos](#base-de-datos) +5. [Características Implementadas](#características-implementadas) +6. [Tareas Pendientes y Bugs](#tareas-pendientes-y-bugs) +7. [Flujos de Datos](#flujos-de-datos) +8. [Configuración y Deployment](#configuración-y-deployment) + +--- + +## 1. Información General + +### Descripción del Proyecto +FoppyAI (anteriormente Fopymes) es un sistema de gestión de finanzas personales con capacidades de inteligencia artificial que permite a los usuarios controlar sus ingresos, gastos, metas de ahorro, presupuestos y deudas. + +### Stack Tecnológico + +#### Backend +- **Runtime**: Bun (JavaScript runtime y package manager) +- **Framework**: Hono.js v4.6.12 +- **Base de Datos**: PostgreSQL +- **ORM**: Drizzle ORM v0.38.2 +- **Validación**: Zod v3.23.8 +- **Autenticación**: JWT con jose v5.9.6 +- **Documentación API**: OpenAPI con @scalar/hono-api-reference +- **IA/ML**: LangChain v0.3.34, @langchain/core v0.3.77 +- **Generación de Reportes**: + - Excel: exceljs v4.4.0 + - CSV: json2csv v6.0.0-alpha.2 + - PDF: pdf-lib v1.17.1, pdfkit v0.17.0 +- **Emails**: @getbrevo/brevo v2.2.0 +- **Tareas programadas**: cron v4.3.0 +- **Logging**: pino v9.5.0, hono-pino v0.7.0 +- **Utilidades**: date-fns v4.1.0, uuid v10.0.0 + +#### Frontend +**Nota**: Se requiere acceso a `/Users/jair/fopyments-app` para documentar completamente el frontend. La carpeta mencionada no está en los directorios permitidos actualmente. + +### Directorios del Proyecto +- **Backend**: `/Users/jair/devProjects/fopymes-backend` +- **Frontend**: `/Users/jair/fopyments-app` (pendiente de acceso) +- **Base de Datos**: PostgreSQL - nombre: "database" + +--- + +## 2. Arquitectura del Sistema + +### Patrón Arquitectónico +El proyecto sigue una **Arquitectura Hexagonal (Ports & Adapters)** con principios de Clean Architecture. + +### Estructura del Backend + +``` +src/ +├── core/ +│ └── infrastructure/ +│ ├── database/ # Configuración de base de datos +│ │ ├── index.ts # DatabaseConnection (Singleton) +│ │ └── schema.ts # Esquemas Drizzle ORM +│ ├── env/ # Variables de entorno +│ ├── lib/ # Librerías compartidas +│ │ ├── create-app.ts # Factory de aplicación Hono +│ │ ├── configure-open-api.ts +│ │ └── handler.wrapper.ts +│ ├── middleware/ # Middleware global +│ ├── types/ # Tipos globales +│ ├── cron/ # Trabajos programados +│ │ ├── budget-notifications.cron.ts +│ │ ├── debt-notifications.cron.ts +│ │ ├── expired-notifications.cron.ts +│ │ ├── financial-suggestions.cron.ts +│ │ ├── goal-notifications.cron.ts +│ │ ├── goal-suggestions.cron.ts +│ │ ├── recalculate-contribution-amount.cron.ts +│ │ └── scheduled-transactions.cron.ts +│ └── scripts/ # Scripts de utilidad +│ ├── categories.seed.ts +│ └── clean-database.ts +│ +├── features/ # Módulos por dominio +│ ├── ai-agents/ +│ │ ├── application/ # Lógica de negocio +│ │ │ ├── dtos/ +│ │ │ └── services/ +│ │ ├── domain/ # Entidades y puertos +│ │ │ ├── entities/ +│ │ │ └── ports/ +│ │ └── infrastructure/ # Adaptadores +│ │ ├── adapters/ +│ │ └── controllers/ +│ ├── auth/ +│ ├── budgets/ +│ ├── categories/ +│ ├── debts/ +│ ├── email/ +│ ├── friends/ +│ ├── goals/ +│ │ └── infrastucture/ # Nota: typo en el código original +│ ├── notifications/ +│ ├── payment-methods/ +│ ├── reports/ +│ ├── scheduled-transactions/ +│ ├── transactions/ +│ └── users/ +│ +└── shared/ # Código compartido + ├── utils/ # Funciones puras + └── services/ # Servicios compartidos +``` + +### Capas de la Arquitectura Hexagonal + +#### 1. Domain (Dominio) +- **Entidades**: Objetos de negocio puros +- **Ports (Puertos)**: Interfaces que definen contratos +- **Casos de uso**: Lógica de negocio independiente + +#### 2. Application (Aplicación) +- **DTOs**: Data Transfer Objects +- **Services**: Servicios de aplicación +- **Use Cases**: Implementación de casos de uso + +#### 3. Infrastructure (Infraestructura) +- **Adapters**: Implementaciones de puertos + - Repositorios (PgXxxRepository) + - Servicios externos +- **Controllers**: Controladores HTTP +- **Routes**: Definiciones de rutas OpenAPI + +--- + +## 3. Backend (API REST) + +### Punto de Entrada +**Archivo**: `src/index.ts` +```typescript +import { serve } from "bun"; +import app from "./app"; +import env from "@/env"; + +serve({ + fetch: app.fetch, + port: env.PORT, +}); +``` + +### Configuración de la Aplicación +**Archivo**: `src/app.ts` + +#### Middleware Configurado +1. **CORS**: Configurado para localhost:3000, localhost:3001 y origen wildcard +2. **Logger**: Logging con pino +3. **Body Logger**: Middleware personalizado para loggear request bodies +4. **Authentication**: JWT validation en rutas protegidas + +#### Rutas Registradas +```typescript +const routes = [ + index, // GET / + auth, // /auth/* + users, // /users/* + paymentMethods, // /payment-methods/* + transactions, // /transactions/* + goals, // /goals/* + budgets, // /budgets/* + scheduledTransactions, // /scheduled-transactions/* + debts, // /debts/* + friends, // /friends/* + categories, // /categories/* + goalContributions, // /goal-contributions/* + goalContributionSchedules, // /goal-contribution-schedules/* + notifications, // /notifications/* + email, // /email/* + reports, // /reports/* + aiAgents, // /voice-command + notificationSocket, // WebSocket para notificaciones +] +``` + +#### Trabajos Cron Iniciados +```typescript +// Comentado: startScheduledTransactionsJob(); +startNotificationsCleanupJob(); // Limpieza de notificaciones expiradas +recalculateContributionAmountCron.start(); // Recalcula contribuciones a metas +startDebtNotificationsJob(); // Notificaciones de deudas +startBudgetSummaryJob(); // Resumen de presupuestos +startGoalNotificationsJob(); // Notificaciones de metas +startFinancialSuggestionsJob(); // Sugerencias financieras +startGoalSuggestionsJob(); // Sugerencias de metas +``` + +### Path Aliases (tsconfig.json) +```typescript +{ + "@/env": "./src/core/infrastructure/env/env.ts", + "@/db": "./src/core/infrastructure/database/index.ts", + "@/schema": "./src/core/infrastructure/database/schema.ts", + "@/shared/*": "./src/shared/*", + "@/users/*": "./src/features/users/*", + "@/auth/*": "./src/features/auth/*", + "@/payment-methods/*": "./src/features/payment-methods/*", + "@/transactions/*": "./src/features/transactions/*", + "@/goals/*": "./src/features/goals/*", + "@/budgets/*": "./src/features/budgets/*", + "@/scheduled-transactions/*": "./src/features/scheduled-transactions/*", + "@/debts/*": "./src/features/debts/*", + "@/categories/*": "./src/features/categories/*", + "@/friends/*": "./src/features/friends/*", + "@/email/*": "./src/features/email/*", + "@/core/*": "./src/core/*", + "@/notifications/*": "./src/features/notifications/*" +} +``` + +### Scripts Disponibles +```json +{ + "dev": "bun run --hot src/index.ts", + "build": "bun build src/index.ts --outdir dist --target node --external bun", + "migrate": "bun drizzle-kit migrate", + "generate": "bun drizzle-kit generate", + "db:seed": "bun run src/core/infrastructure/scripts/categories.seed.ts", + "db:clean": "bun run src/core/infrastructure/scripts/clean-database.ts", + "db:refresh": "bun run db:clean && bun run db:seed" +} +``` + +--- + +## 4. Base de Datos + +### Tablas en PostgreSQL + +#### 1. **users** - Usuarios del sistema +```sql +- id (serial, PK) +- name (varchar, NOT NULL) +- username (varchar, NOT NULL, UNIQUE) +- email (varchar, NOT NULL, UNIQUE) +- password_hash (varchar, NOT NULL) +- registration_date (timestamp, DEFAULT CURRENT_TIMESTAMP) +- active (boolean, DEFAULT true) +- recovery_token (varchar, NULLABLE) +- recovery_token_expires (timestamp, NULLABLE) +- created_at (timestamp, DEFAULT CURRENT_TIMESTAMP) +- updated_at (timestamp, DEFAULT CURRENT_TIMESTAMP) +``` + +#### 2. **categories** - Categorías de transacciones +```sql +- id (serial, PK) +- name (varchar, NOT NULL, UNIQUE) +- description (text) +- created_at (timestamp, DEFAULT CURRENT_TIMESTAMP) +- updated_at (timestamp, DEFAULT CURRENT_TIMESTAMP) +``` + +#### 3. **payment_methods** - Métodos de pago +```sql +- id (serial, PK) +- user_id (integer, FK -> users.id, NOT NULL) +- shared_user_id (integer, FK -> users.id, NULLABLE) +- name (varchar, NOT NULL) +- type (varchar, NOT NULL) +- last_four_digits (varchar, NULLABLE) +- created_at (timestamp, DEFAULT CURRENT_TIMESTAMP) +- updated_at (timestamp, DEFAULT CURRENT_TIMESTAMP) +Índices: pm_user_idx, pm_shared_user_idx +``` + +#### 4. **transactions** - Transacciones financieras +```sql +- id (serial, PK) +- user_id (integer, FK -> users.id, NOT NULL) +- amount (decimal(10,2), NOT NULL) +- type (varchar, NOT NULL) -- 'INCOME' | 'EXPENSE' +- category_id (integer, FK -> categories.id) +- description (text) +- payment_method_id (integer, FK -> payment_methods.id) +- date (timestamp, DEFAULT CURRENT_TIMESTAMP) +- scheduled_transaction_id (integer) +- debt_id (integer, FK -> debts.id) +- contribution_id (integer, FK -> goal_contributions.id) +- budget_id (integer, FK -> budgets.id) +- created_at (timestamp, DEFAULT CURRENT_TIMESTAMP) +- updated_at (timestamp, DEFAULT CURRENT_TIMESTAMP) +Índices: tx_user_idx, tx_date_idx, tx_category_idx, tx_budget_idx +``` + +#### 5. **goals** - Metas de ahorro +```sql +- id (serial, PK) +- user_id (integer, FK -> users.id, NOT NULL) +- shared_user_id (integer, FK -> users.id, NULLABLE) +- name (varchar, NOT NULL) +- target_amount (decimal(10,2), NOT NULL) +- current_amount (decimal(10,2), DEFAULT 0) +- end_date (timestamp, NOT NULL) +- contribution_frequency (integer, NOT NULL) -- En días +- contribution_amount (decimal(10,2), NOT NULL) +- category_id (integer, FK -> categories.id) +- created_at (timestamp, DEFAULT CURRENT_TIMESTAMP) +- updated_at (timestamp, DEFAULT CURRENT_TIMESTAMP) +Índices: goals_user_idx, goals_shared_user_idx +``` + +#### 6. **goal_contributions** - Contribuciones a metas +```sql +- id (serial, PK) +- goal_id (integer, FK -> goals.id) +- amount (decimal(10,2)) +- date (timestamp) +- transaction_id (integer, FK -> transactions.id) +- created_at (timestamp) +- updated_at (timestamp) +``` + +#### 7. **goal_contribution_schedule** - Programación de contribuciones +```sql +- id (serial, PK) +- goal_id (integer, FK -> goals.id) +- scheduled_date (timestamp) +- amount (decimal(10,2)) +- status (varchar) +- created_at (timestamp) +- updated_at (timestamp) +``` + +#### 8. **budgets** - Presupuestos +```sql +- id (serial, PK) +- user_id (integer, FK -> users.id) +- category_id (integer, FK -> categories.id) +- amount (decimal(10,2)) +- period (varchar) -- 'MONTHLY' | 'WEEKLY' | etc. +- start_date (timestamp) +- end_date (timestamp) +- created_at (timestamp) +- updated_at (timestamp) +``` + +#### 9. **scheduled_transactions** - Transacciones programadas +```sql +- id (serial, PK) +- user_id (integer, FK -> users.id) +- amount (decimal(10,2)) +- type (varchar) +- category_id (integer, FK -> categories.id) +- description (text) +- payment_method_id (integer, FK -> payment_methods.id) +- frequency (varchar) -- 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY' +- next_execution_date (timestamp) +- active (boolean) +- created_at (timestamp) +- updated_at (timestamp) +``` + +#### 10. **debts** - Deudas +```sql +- id (serial, PK) +- user_id (integer, FK -> users.id) +- creditor_name (varchar) +- total_amount (decimal(10,2)) +- remaining_amount (decimal(10,2)) +- interest_rate (decimal(5,2)) +- due_date (timestamp) +- status (varchar) +- created_at (timestamp) +- updated_at (timestamp) +``` + +#### 11. **friends** - Relaciones entre usuarios +```sql +- id (serial, PK) +- user_id (integer, FK -> users.id) +- friend_id (integer, FK -> users.id) +- status (varchar) -- 'PENDING' | 'ACCEPTED' | 'REJECTED' +- created_at (timestamp) +- updated_at (timestamp) +``` + +#### 12. **notifications** - Notificaciones del sistema +```sql +- id (serial, PK) +- user_id (integer, FK -> users.id) +- type (varchar) +- title (varchar) +- message (text) +- read (boolean) +- data (jsonb) +- created_at (timestamp) +- expires_at (timestamp) +``` + +#### 13. **reports** - Reportes generados +```sql +- id (varchar, PK) -- UUID +- user_id (integer, FK -> users.id) +- type (varchar) -- Ver ReportType enum +- format (varchar) -- 'JSON' | 'PDF' | 'EXCEL' | 'CSV' +- filters (jsonb) +- data (jsonb) +- created_at (timestamp) +- expires_at (timestamp) +``` + +### Relaciones Principales +1. **users** 1:N con **transactions**, **goals**, **budgets**, **debts**, **notifications**, **reports** +2. **users** N:N con **users** (friends) +3. **goals** 1:N con **goal_contributions** +4. **transactions** N:1 con **categories**, **payment_methods**, **budgets**, **debts** +5. **payment_methods** puede ser compartido entre usuarios + +--- + +## 5. Características Implementadas + +### 5.1 Autenticación y Usuarios +**Módulo**: `features/auth` y `features/users` + +#### Funcionalidades +- Registro de usuarios +- Login con JWT +- Recuperación de contraseña (tokens temporales) +- Perfil de usuario +- Gestión de sesiones + +#### Endpoints Principales +``` +POST /auth/register +POST /auth/login +POST /auth/forgot-password +POST /auth/reset-password +GET /users/profile +PUT /users/profile +``` + +### 5.2 Métodos de Pago +**Módulo**: `features/payment-methods` + +#### Funcionalidades +- CRUD de métodos de pago +- Soporte para compartir métodos entre usuarios +- Tipos: efectivo, tarjeta de crédito, tarjeta de débito, etc. + +### 5.3 Categorías +**Módulo**: `features/categories` + +#### Funcionalidades +- Categorías predefinidas del sistema +- CRUD de categorías +- Seed inicial de categorías comunes + +### 5.4 Transacciones +**Módulo**: `features/transactions` + +#### Funcionalidades +- Registro de ingresos y gastos +- Filtrado por fecha, categoría, tipo +- Vinculación con presupuestos, deudas y metas +- Soporte para transacciones recurrentes + +#### Tipos de Transacciones +- **INCOME**: Ingresos +- **EXPENSE**: Gastos + +### 5.5 Metas de Ahorro +**Módulo**: `features/goals` + +#### Funcionalidades +- Creación de metas con monto objetivo y fecha límite +- Frecuencia de contribución configurable +- Seguimiento de progreso (current_amount vs target_amount) +- Metas compartidas entre usuarios +- Contribuciones manuales y automáticas +- Programación de contribuciones + +#### Endpoints +``` +POST /goals +GET /goals +GET /goals/:id +PUT /goals/:id +DELETE /goals/:id +POST /goals/:id/contributions +GET /goals/:id/contributions +``` + +### 5.6 Presupuestos +**Módulo**: `features/budgets` + +#### Funcionalidades +- Presupuestos por categoría +- Períodos: mensual, semanal, anual +- Tracking automático de gastos vs presupuesto +- Alertas cuando se excede el presupuesto + +### 5.7 Transacciones Programadas +**Módulo**: `features/scheduled-transactions` + +#### Funcionalidades +- Transacciones recurrentes automáticas +- Frecuencias: diaria, semanal, mensual, anual +- Activación/desactivación de programaciones +- Generación automática vía cron job (actualmente deshabilitado) + +### 5.8 Deudas +**Módulo**: `features/debts` + +#### Funcionalidades +- Registro de deudas con acreedor +- Tasa de interés +- Tracking de pagos parciales +- Fecha de vencimiento +- Estados: activa, pagada, vencida + +### 5.9 Amigos/Conexiones +**Módulo**: `features/friends` + +#### Funcionalidades +- Solicitudes de amistad +- Aceptar/rechazar solicitudes +- Compartir metas y métodos de pago + +### 5.10 Notificaciones +**Módulo**: `features/notifications` + +#### Funcionalidades +- Sistema de notificaciones en tiempo real +- WebSocket para push notifications +- Notificaciones automáticas para: + - Vencimiento de deudas + - Progreso de metas + - Exceso de presupuesto + - Sugerencias financieras +- Expiración automática de notificaciones +- Marcado de leído/no leído + +### 5.11 Reportes +**Módulo**: `features/reports` + +#### Tipos de Reportes Disponibles +```typescript +enum ReportType { + GOAL = "GOAL", + CONTRIBUTION = "CONTRIBUTION", + BUDGET = "BUDGET", + EXPENSE = "EXPENSE", + INCOME = "INCOME", + GOALS_BY_STATUS = "GOALS_BY_STATUS", + GOALS_BY_CATEGORY = "GOALS_BY_CATEGORY", + CONTRIBUTIONS_BY_GOAL = "CONTRIBUTIONS_BY_GOAL", + SAVINGS_COMPARISON = "SAVINGS_COMPARISON", + SAVINGS_SUMMARY = "SAVINGS_SUMMARY", +} +``` + +#### Formatos de Exportación +- **JSON**: Datos estructurados +- **PDF**: Documentos imprimibles +- **Excel**: Hojas de cálculo (.xlsx) +- **CSV**: Archivos de texto separados por comas + +#### Servicios de Generación +1. **ExcelService**: Genera reportes en formato Excel +2. **CSVService**: Genera reportes en formato CSV +3. **PDFService**: Genera reportes en formato PDF + +#### Características +- Reportes temporales con expiración automática +- Filtros avanzados (fecha, categoría, usuario) +- Cleanup automático con cron job +- Endpoints RESTful para generación y descarga + +#### Endpoints +``` +POST /reports/generate +GET /reports/:id +DELETE /reports/:id +``` + +### 5.12 Agentes de IA +**Módulo**: `features/ai-agents` + +#### Arquitectura del Sistema de Agentes + +##### Componentes Principales +1. **VoiceOrchestratorService**: Orquestador principal +2. **Agentes Especializados**: + - TransactionAgentService + - GoalAgentService + - BudgetAgentService + - ValidationAgentService + +##### Flujo de Procesamiento +``` +Audio Input + ↓ +Transcripción (OpenAI Whisper) + ↓ +Clasificación de Intención (LLM) + ↓ +Extracción de Datos (LLM) + ↓ +Procesamiento por Agente + ↓ +Validación + ↓ +Respuesta Estructurada +``` + +##### Intenciones Soportadas +```typescript +enum CommandIntent { + CREATE_TRANSACTION, + CREATE_GOAL, + CREATE_BUDGET, + UNKNOWN +} +``` + +##### Endpoint +``` +POST /voice-command +Content-Type: multipart/form-data +Body: audio file (audio/wav) + +Response: +{ + "success": true, + "intent": "CREATE_TRANSACTION", + "extractedData": {...}, + "confidence": 0.95, + "message": "...", + "validationErrors": [], + "suggestedCorrections": {} +} +``` + +##### Ejemplos de Comandos de Voz +**Transacciones**: +- "Gasté 25 dólares en comida" +- "Recibí 500 dólares de mi trabajo" + +**Metas**: +- "Quiero ahorrar 1000 dólares para vacaciones hasta diciembre" + +**Presupuestos**: +- "Crear un presupuesto de 300 dólares para comida este mes" + +### 5.13 Email +**Módulo**: `features/email` + +#### Funcionalidades +- Envío de emails con Brevo +- Notificaciones por correo +- Recuperación de contraseña + +--- + +## 6. Tareas Pendientes y Bugs + +### 6.1 Dashboard +**Estado**: Problemas críticos + +#### Issues Identificados +1. **Cálculo de totales incorrecto** + - Los totales de ingresos y gastos no están considerando todas las transacciones + - Requiere revisión de la query de agregación + +2. **Gastos por categoría no se muestran** + - La visualización de distribución por categorías no funciona + - Posible problema en el endpoint o en el frontend + +3. **Falta de gráficos** + - Necesita implementación de visualizaciones: + - Gráfico de ingresos vs gastos + - Distribución por categorías (pie chart) + - Evolución temporal (line chart) + - Progreso de metas + +### 6.2 Reportes +**Estado**: Funcionalidad incompleta + +#### Issues Identificados +1. **Información incompleta** + - Reportes solo muestran títulos + - Faltan detalles de transacciones + - Algunos campos muestran "undefined" + +2. **Traducciones faltantes** + - Texto sin internacionalización (i18n) + - Mezcla de español e inglés + +3. **Tipos de reportes limitados** + - Ampliar tipos disponibles (ver ReportType enum) + - Agregar más filtros y opciones de agrupación + +4. **Falta de gráficos en reportes** + - Los reportes PDF/Excel no incluyen visualizaciones + - Integrar los mismos gráficos del dashboard + +5. **Errores intermitentes** + - Reportes fallan aleatoriamente en generación + - Requiere debugging y manejo de errores mejorado + +### 6.3 Información de Usuario +**Estado**: Funcionalidad básica faltante + +#### Tareas Pendientes +1. **Edición de perfil** + - Actualizar nombre de usuario + - Cambio de contraseña + - Actualizar email + - Foto de perfil + +2. **Preferencias de usuario** + - Moneda predeterminada + - Idioma + - Zona horaria + - Notificaciones + +### 6.4 Integración IA +**Estado**: Funcionalidad crítica no operativa + +#### Issues Identificados +1. **Asistente inteligente no funciona** + - El sistema de comandos de voz no responde + - Posible problema con la API de OpenAI + - Revisar integración con LangChain + +2. **Funcionalidad de recomendaciones faltante** + - Implementar sugerencias personalizadas basadas en: + - Patrones de gasto + - Metas de ahorro + - Comportamiento histórico + - Usar agentes de IA para análisis predictivo + +3. **Creación de entidades vía agente virtual** + - Revisar flujo de creación de: + - Metas de ahorro + - Presupuestos + - Transacciones + - Validar integraciones con repositorios + +### 6.5 Bugs Críticos + +#### Bug #1: Selección de categoría "Otros" +**Módulo**: Transacciones +**Descripción**: Al registrar una transacción, seleccionar la categoría "Otros" falla +**Posibles Causas**: +- Validación incorrecta en el formulario +- ID de categoría "Otros" no existe en BD +- Problema en el componente de selección + +#### Bug #2: Cálculo erróneo de totales en Dashboard +**Módulo**: Dashboard +**Descripción**: Los totales no reflejan todos los ingresos y gastos +**Investigar**: +- Query de agregación en el backend +- Filtros de fecha aplicados +- Transacciones excluidas (deudas, contribuciones a metas) + +#### Bug #3: Error en generación de reportes +**Módulo**: Reportes +**Descripción**: Reportes fallan intermitentemente +**Investigar**: +- Logs de errores específicos +- Timeouts en queries grandes +- Problemas con generación de PDF/Excel +- Validación de datos antes de generar reporte + +#### Bug #4: Notificaciones duplicadas de metas +**Módulo**: Notificaciones +**Descripción**: Las advertencias de metas de ahorro se muestran múltiples veces +**Investigar**: +- Lógica del cron job de notificaciones +- Verificación de notificaciones existentes +- Sistema de deduplicación + +--- + +## 7. Flujos de Datos + +### 7.1 Flujo de Creación de Transacción + +``` +Usuario → Frontend + ↓ +POST /transactions + ↓ +TransactionController + ↓ +CreateTransactionUseCase + ↓ +TransactionService + ↓ +PgTransactionRepository + ↓ +Database (transactions table) + ↓ +[Si está vinculado a Budget] + → UpdateBudgetUseCase + → Verificar límite + → Crear notificación si excede + ↓ +[Si está vinculado a Goal] + → CreateGoalContributionUseCase + → Actualizar current_amount + ↓ +Response → Frontend +``` + +### 7.2 Flujo de Creación de Meta + +``` +Usuario → Frontend + ↓ +POST /goals + ↓ +GoalController + ↓ +CreateGoalUseCase + ↓ +GoalService + ├→ Calcular contribution_amount automático + │ basado en: + │ - target_amount + │ - end_date + │ - contribution_frequency + ↓ +PgGoalRepository + ↓ +Database (goals table) + ↓ +[Opcional] GoalContributionScheduleService + → Generar programación de contribuciones + → Crear registros en goal_contribution_schedule + ↓ +Response → Frontend +``` + +### 7.3 Flujo de Comando de Voz + +``` +Usuario graba audio → Frontend + ↓ +POST /voice-command (multipart/form-data) + ↓ +VoiceCommandController + ↓ +VoiceOrchestratorService + ├→ 1. Transcribir audio (Whisper API) + ├→ 2. Clasificar intención (LLM) + ├→ 3. Extraer entidades (LLM) + │ - amount, description, category, etc. + ↓ +[Routing basado en intent] + ├→ CREATE_TRANSACTION → TransactionAgentService + ├→ CREATE_GOAL → GoalAgentService + ├→ CREATE_BUDGET → BudgetAgentService + ↓ +ValidationAgentService + ├→ Validar datos extraídos + ├→ Corregir inconsistencias + ├→ Completar datos faltantes + ↓ +Response con extractedData → Frontend + ↓ +Frontend muestra formulario pre-llenado + ↓ +Usuario confirma o edita + ↓ +Crear entidad normalmente +``` + +### 7.4 Flujo de Generación de Reportes + +``` +Usuario solicita reporte → Frontend + ↓ +POST /reports/generate +Body: { + type: ReportType, + format: ReportFormat, + filters: {...} +} + ↓ +ReportController + ↓ +ReportServiceImpl + ├→ Validar tipo y filtros + ├→ Ejecutar query específico según type: + │ ├→ GOAL → PgGoalRepository.findByFilters() + │ ├→ EXPENSE → PgTransactionRepository.findExpenses() + │ ├→ SAVINGS_SUMMARY → Múltiples queries agregadas + │ └→ ... + ↓ +Generar datos estructurados (JSON) + ↓ +PgReportRepository.create() + → Guardar en tabla reports + → Calcular expiresAt (24-72 horas) + ↓ +Response: { id: uuid, type, format, ... } + ↓ +Frontend redirige a GET /reports/:id + ↓ +ReportController.getReport() + ├→ PgReportRepository.findById() + ├→ Verificar expiración + │ + ├→ [Si format === JSON] + │ → Return c.json(report.data) + │ + ├→ [Si format === PDF] + │ → PDFService.generatePDF(report) + │ → Return Response(buffer, headers) + │ + ├→ [Si format === EXCEL] + │ → ExcelService.generateExcel(report) + │ → Return Response(buffer, headers) + │ + └→ [Si format === CSV] + → CSVService.generateCSV(report) + → Return Response(buffer, headers) + ↓ +Download automático en navegador +``` + +### 7.5 Flujo de Notificaciones + +``` +[Trigger: Cron Job o Evento] + ↓ +NotificationService + ├→ Tipo: BUDGET_EXCEEDED + │ └→ BudgetService detecta exceso + │ + ├→ Tipo: GOAL_WARNING + │ └→ GoalService detecta atraso + │ + ├→ Tipo: DEBT_DUE + │ └→ DebtService detecta vencimiento próximo + │ + └→ Tipo: FINANCIAL_SUGGESTION + └→ AIAgentService genera sugerencia + ↓ +PgNotificationRepository.create({ + user_id, + type, + title, + message, + data: {...}, + expires_at +}) + ↓ +Database (notifications table) + ↓ +[WebSocket Push] + → NotificationSocket + → Enviar a cliente conectado + → Frontend muestra notificación + ↓ +[Email opcional] + → EmailService + → Brevo API + → Enviar correo +``` + +--- + +## 8. Configuración y Deployment + +### 8.1 Variables de Entorno + +**Archivo**: `.env` + +```bash +# Server +PORT=3000 +NODE_ENV=development + +# Database +DATABASE_HOST=localhost +DATABASE_PORT=5432 +DATABASE_NAME=database +DATABASE_USERNAME=your_username +DATABASE_PASSWORD=your_password + +# JWT +JWT_SECRET=your_jwt_secret_key +JWT_EXPIRATION=7d + +# OpenAI (para agentes de IA) +OPENAI_API_KEY=your_openai_api_key + +# Brevo (Email) +BREVO_API_KEY=your_brevo_api_key +BREVO_SENDER_EMAIL=noreply@foppyai.com +BREVO_SENDER_NAME=FoppyAI + +# Frontend URL (CORS) +FRONTEND_URL=http://localhost:3001 +``` + +### 8.2 Docker + +**Archivo**: `docker-compose.yaml` + +El proyecto incluye configuración Docker para ejecutar PostgreSQL: + +```bash +# Iniciar base de datos +docker compose up -d + +# Detener +docker compose down + +# Ver logs +docker compose logs -f +``` + +### 8.3 Migraciones de Base de Datos + +#### Generar Migraciones +```bash +bun drizzle-kit generate +``` + +#### Ejecutar Migraciones +```bash +bun drizzle-kit migrate +``` + +#### Drizzle Studio (GUI) +```bash +bun drizzle-kit studio +``` + +### 8.4 Seed de Datos + +#### Limpiar Base de Datos +```bash +bun run db:clean +``` + +#### Seed de Categorías +```bash +bun run db:seed +``` + +#### Refresh Completo +```bash +bun run db:refresh +``` + +### 8.5 Desarrollo + +#### Iniciar Servidor de Desarrollo +```bash +bun install +bun run dev +``` + +El servidor se iniciará en `http://localhost:3000` con hot reload habilitado. + +#### Documentación API +Una vez iniciado el servidor, la documentación OpenAPI está disponible en: +``` +http://localhost:3000/reference +``` + +### 8.6 Producción + +#### Build +```bash +bun run build +``` + +#### Deployment +El proyecto incluye `Dockerfile` y configuración para deployment con Docker: + +```bash +# Build imagen +docker build -t foppyai-backend . + +# Run container +docker run -p 3000:3000 --env-file .env foppyai-backend +``` + +--- + +## 9. Mejoras Técnicas Sugeridas + +### 9.1 Prioridad Alta + +1. **Testing** + - Implementar pruebas unitarias (casos de uso) + - Pruebas de integración (repositorios) + - E2E tests para flujos críticos + - Coverage mínimo: 70% + +2. **Manejo de Errores** + - Middleware centralizado de errores + - Códigos de error consistentes + - Logging estructurado + - Alertas para errores críticos + +3. **Validación de Datos** + - Validación exhaustiva con Zod en todos los endpoints + - DTOs bien definidos + - Sanitización de inputs + +4. **Seguridad** + - Rate limiting + - Helmet middleware + - Validación de CSRF + - Auditoría de accesos + +### 9.2 Prioridad Media + +1. **Performance** + - Implementar caché (Redis) + - Paginación en todos los listados + - Índices de base de datos optimizados + - Query optimization + +2. **Documentación** + - Ampliar documentación OpenAPI + - Guías de integración + - Ejemplos de uso de API + - Diagramas de arquitectura + +3. **Monitoreo** + - APM (Application Performance Monitoring) + - Health checks + - Métricas de negocio + - Dashboards operacionales + +### 9.3 Prioridad Baja + +1. **Internacionalización** + - Sistema i18n completo + - Soporte multi-idioma + - Formatos de fecha/moneda localizados + +2. **Features Avanzadas** + - Exportación de datos completa + - Importación desde archivos CSV + - Integraciones con bancos (Open Banking) + - Aplicación móvil nativa + +--- + +## 10. Patrones y Mejores Prácticas + +### 10.1 Patrones Implementados + +1. **Singleton**: DatabaseConnection, Repositorios +2. **Repository Pattern**: Abstracción de acceso a datos +3. **Dependency Injection**: Servicios reciben dependencias +4. **Factory Pattern**: createApp, createRouter +5. **Strategy Pattern**: Agentes especializados de IA + +### 10.2 Convenciones de Código + +#### Nomenclatura +- **Archivos**: kebab-case (user-controller.ts) +- **Clases**: PascalCase (UserService) +- **Funciones**: camelCase (createUser) +- **Constantes**: UPPER_SNAKE_CASE (MAX_RETRIES) + +#### Estructura de Archivos +``` +feature/ +├── application/ +│ ├── dtos/ +│ │ └── create-xxx.dto.ts +│ └── services/ +│ └── xxx.service.ts +├── domain/ +│ ├── entities/ +│ │ └── xxx.entity.ts +│ └── ports/ +│ └── xxx.repository.interface.ts +└── infrastructure/ + ├── adapters/ + │ └── xxx.repository.ts + └── controllers/ + ├── xxx.controller.ts + └── xxx.routes.ts +``` + +#### Respuestas API Consistentes +```typescript +// Success +{ + "success": true, + "data": {...}, + "message": "Operation successful" +} + +// Error +{ + "success": false, + "data": null, + "message": "Error description" +} +``` + +--- + +## 11. Conclusiones y Próximos Pasos + +### Estado Actual +FoppyAI es un sistema de gestión financiera personal robusto con características avanzadas de IA. La arquitectura hexagonal proporciona una base sólida para el crecimiento futuro. + +### Áreas de Enfoque Inmediato + +1. **Resolver bugs críticos** (Dashboard, Reportes, Categorías) +2. **Completar funcionalidad de reportes** +3. **Reparar integración de IA** +4. **Implementar edición de perfil de usuario** +5. **Agregar tests automatizados** + +### Roadmap Sugerido + +**Q1 2025** +- Corrección de bugs críticos +- Completar reportes con gráficos +- Dashboard funcional completo +- Testing básico (>50% coverage) + +**Q2 2025** +- Sistema de IA completamente operativo +- Recomendaciones personalizadas +- Aplicación móvil (React Native) +- Integraciones bancarias + +**Q3 2025** +- Multi-tenancy +- Planes de suscripción +- Features premium +- Escalabilidad horizontal + +--- + +## Apéndices + +### A. Endpoints de API (Resumen) + +#### Autenticación +- POST /auth/register +- POST /auth/login +- POST /auth/forgot-password +- POST /auth/reset-password + +#### Usuarios +- GET /users/profile +- PUT /users/profile + +#### Transacciones +- POST /transactions +- GET /transactions +- GET /transactions/:id +- PUT /transactions/:id +- DELETE /transactions/:id + +#### Metas +- POST /goals +- GET /goals +- GET /goals/:id +- PUT /goals/:id +- DELETE /goals/:id +- POST /goals/:id/contributions + +#### Reportes +- POST /reports/generate +- GET /reports/:id +- DELETE /reports/:id + +#### IA +- POST /voice-command + +### B. Referencias + +- [Hono.js Documentation](https://hono.dev/) +- [Drizzle ORM](https://orm.drizzle.team/) +- [Bun Documentation](https://bun.sh/docs) +- [LangChain Documentation](https://js.langchain.com/) +- [OpenAPI Specification](https://swagger.io/specification/) + +--- + +**Última Actualización**: Octubre 2025 +**Versión**: 1.0.0 +**Mantenedor**: Equipo FoppyAI \ No newline at end of file diff --git a/documentation/PROJECT_DOC.md b/documentation/PROJECT_DOC.md new file mode 100644 index 0000000..6ee4089 --- /dev/null +++ b/documentation/PROJECT_DOC.md @@ -0,0 +1,54 @@ +📄 Documento 1: Backend Documentation +Incluye: + +Arquitectura hexagonal del backend +14 módulos de features completos +Sistema de agentes de IA con comandos de voz +Base de datos con 13 tablas +Sistema de reportes (PDF, Excel, CSV) +Trabajos cron automatizados +Configuración Docker y deployment + +📄 Documento 2: Frontend Documentation +Incluye: + +Arquitectura Next.js 14 con App Router +Stack completo (React Query, shadcn/ui, NextAuth) +Sistema de autenticación con JWT +12 módulos de features implementados +Componentes UI reutilizables +Integración con backend vía Axios +Sistema de notificaciones en tiempo real +Comandos de voz con IA +Rutas y navegación +Testing y deployment + +Resumen Ejecutivo +FoppyAI es un sistema completo de gestión de finanzas personales con las siguientes características destacadas: +✨ Características Principales + +Dashboard Financiero con resumen de ingresos/gastos/balance +Metas de Ahorro con contribuciones automáticas +Presupuestos por categoría con alertas +Gestión de Deudas con tracking de pagos +Transacciones completas (ingresos/gastos) +Reportes en múltiples formatos (PDF, Excel, CSV, JSON) +Comandos de Voz con IA para crear transacciones/metas/presupuestos +Notificaciones en Tiempo Real vía WebSocket +Tema Claro/Oscuro +Autenticación Segura con JWT + +📊 Estado del Proyecto + +Backend: Funcional con arquitectura hexagonal +Frontend: Funcional con Next.js 14 y UI moderna +IA: Implementado pero con issues +Testing: Configurado pero con baja cobertura + +🐛 Bugs Críticos Identificados + +Dashboard muestra totales incorrectos +Categoría "Otros" falla en transacciones +Reportes muestran información incompleta +Notificaciones de metas se duplican +Asistente de IA no funciona correctamente \ No newline at end of file diff --git a/src/features/goals/infrastucture/adapters/goal.repository.ts b/src/features/goals/infrastucture/adapters/goal.repository.ts index b501b35..9b31d20 100644 --- a/src/features/goals/infrastucture/adapters/goal.repository.ts +++ b/src/features/goals/infrastucture/adapters/goal.repository.ts @@ -61,6 +61,19 @@ export class PgGoalRepository implements IGoalRepository { return result.map((row) => this.mapToEntity(row.goal, row.category)); } + async findSharedWithUser(userId: number): Promise { + const result = await this.db + .select({ + goal: goals, + category: categories, + }) + .from(goals) + .leftJoin(categories, eq(goals.category_id, categories.id)) + .where(eq(goals.shared_user_id, userId)); + + return result.map((row) => this.mapToEntity(row.goal, row.category)); + } + async create(goal: IGoal): Promise { const result = await this.db .insert(goals) @@ -72,8 +85,8 @@ export class PgGoalRepository implements IGoalRepository { current_amount: goal.currentAmount.toString(), end_date: goal.endDate, category_id: goal.categoryId, - contribution_frequency: goal.contributionFrequency, - contribution_amount: goal.contributionAmount.toString(), + contribution_frequency: goal.contributionFrequency || 0, + contribution_amount: goal.contributionAmount?.toString() || "0", }) .returning(); @@ -98,7 +111,7 @@ export class PgGoalRepository implements IGoalRepository { end_date: goal.endDate, category_id: goal.categoryId, shared_user_id: goal.sharedUserId, - contribution_frequency: goal.contributionFrequency, + contribution_frequency: goal.contributionFrequency ?? undefined, contribution_amount: goal.contributionAmount?.toString(), }) .where(eq(goals.id, id)) @@ -127,14 +140,6 @@ export class PgGoalRepository implements IGoalRepository { if (result.length === 0) return null; - const category = result[0].category_id - ? await this.db - .select() - .from(categories) - .where(eq(categories.id, result[0].category_id)) - .then((result) => result[0]) - : null; - return this.mapToEntity(result[0].goal, result[0].category); } @@ -193,7 +198,7 @@ export class PgGoalRepository implements IGoalRepository { // FIXED: Determinar qué campo usar para filtrar fechas // Default: 'end_date' (para dashboard - metas que vencen en el período) // Opción: 'created_at' (para reportes - metas creadas en el período) - const useCreatedAt = filters.filterBy === 'created_at'; + const useCreatedAt = filters.filterBy === "created_at"; const dateField = useCreatedAt ? goals.created_at : goals.end_date; // Si hay startDate, filtrar metas según el campo determinado @@ -240,9 +245,11 @@ export class PgGoalRepository implements IGoalRepository { } : null, contributionFrequency: raw.contribution_frequency, - contributionAmount: raw.contribution_amount ? Number(raw.contribution_amount) : 0, + contributionAmount: raw.contribution_amount + ? Number(raw.contribution_amount) + : 0, createdAt: raw.created_at, - updatedAt: raw.updated_at + updatedAt: raw.updated_at, }; } } diff --git a/src/features/reports/infrastructure/controllers/report.controller.ts b/src/features/reports/infrastructure/controllers/report.controller.ts index 5a4f994..847c56d 100644 --- a/src/features/reports/infrastructure/controllers/report.controller.ts +++ b/src/features/reports/infrastructure/controllers/report.controller.ts @@ -47,7 +47,7 @@ const generateReportHandler = createHandler(async (c: Context) => { startDate: filters.startDate ? new Date(filters.startDate) : undefined, endDate: filters.endDate ? new Date(filters.endDate) : undefined, // ADDED: Para reportes, siempre usar 'created_at' para filtrar metas - filterBy: 'created_at' as const, + filterBy: "created_at" as const, }; // FIXED: Validar que goalId sea proporcionado cuando se requiere @@ -67,8 +67,12 @@ const generateReportHandler = createHandler(async (c: Context) => { ); } - const report = await reportService.generateReport(type, format, processedFilters); - + const report = await reportService.generateReport( + type, + format, + processedFilters + ); + return c.json( { success: true, @@ -106,7 +110,7 @@ const getReportHandler = createHandler(async (c: Context) => { if (report.format === ReportFormat.PDF) { const pdfBuffer = await pdfService.generatePDF(report); - return new Response(pdfBuffer, { + return new Response(new Uint8Array(pdfBuffer), { status: HttpStatusCodes.OK, headers: { "Content-Type": "application/pdf", @@ -117,7 +121,7 @@ const getReportHandler = createHandler(async (c: Context) => { if (report.format === ReportFormat.EXCEL) { const excelBuffer = await excelService.generateExcel(report); - return new Response(excelBuffer, { + return new Response(new Uint8Array(excelBuffer), { status: HttpStatusCodes.OK, headers: { "Content-Type": @@ -129,7 +133,7 @@ const getReportHandler = createHandler(async (c: Context) => { if (report.format === ReportFormat.CSV) { const csvBuffer = await csvService.generateCSV(report); - return new Response(csvBuffer, { + return new Response(new TextEncoder().encode(csvBuffer), { status: HttpStatusCodes.OK, headers: { "Content-Type": "text/csv", @@ -147,12 +151,14 @@ const getReportHandler = createHandler(async (c: Context) => { type: report.type, format: report.format, data: report.data, - createdAt: typeof report.createdAt === 'string' - ? report.createdAt - : report.createdAt?.toISOString(), - expiresAt: typeof report.expiresAt === 'string' - ? report.expiresAt - : report.expiresAt?.toISOString(), + createdAt: + typeof report.createdAt === "string" + ? report.createdAt + : report.createdAt?.toISOString(), + expiresAt: + typeof report.expiresAt === "string" + ? report.expiresAt + : report.expiresAt?.toISOString(), }, message: "Report retrieved successfully", }, @@ -160,7 +166,7 @@ const getReportHandler = createHandler(async (c: Context) => { ); } catch (error) { console.error("Error retrieving report:", error); - + return c.json( { success: false, diff --git a/src/features/reports/infrastructure/services/csv.service.ts b/src/features/reports/infrastructure/services/csv.service.ts index 01cbb1c..af7ff66 100644 --- a/src/features/reports/infrastructure/services/csv.service.ts +++ b/src/features/reports/infrastructure/services/csv.service.ts @@ -1,5 +1,19 @@ import { Report, ReportType } from "../../domain/entities/report.entity"; import { Parser } from "json2csv"; +import { + prepareGoalsByStatusData, + prepareGoalsByCategoryData, + prepareContributionsByGoalData, + prepareSavingsComparisonData, + prepareSavingsSummaryData, +} from "./csv/csv-goal-formatters"; +import { + prepareTransactionsSummaryData, + prepareExpensesByCategoryData, + prepareMonthlyTrendData, +} from "./csv/csv-transaction-formatters"; +import { prepareBudgetPerformanceData } from "./csv/csv-budget-formatters"; +import { prepareFinancialOverviewData } from "./csv/csv-overview-formatters"; export class CSVService { async generateCSV(report: Report): Promise { @@ -8,19 +22,34 @@ export class CSVService { switch (report.type) { case ReportType.GOALS_BY_STATUS: - ({ fields, data } = this.prepareGoalsByStatusData(report.data)); + ({ fields, data } = prepareGoalsByStatusData(report.data)); break; case ReportType.GOALS_BY_CATEGORY: - ({ fields, data } = this.prepareGoalsByCategoryData(report.data)); + ({ fields, data } = prepareGoalsByCategoryData(report.data)); break; case ReportType.CONTRIBUTIONS_BY_GOAL: - ({ fields, data } = this.prepareContributionsByGoalData(report.data)); + ({ fields, data } = prepareContributionsByGoalData(report.data)); break; case ReportType.SAVINGS_COMPARISON: - ({ fields, data } = this.prepareSavingsComparisonData(report.data)); + ({ fields, data } = prepareSavingsComparisonData(report.data)); break; case ReportType.SAVINGS_SUMMARY: - ({ fields, data } = this.prepareSavingsSummaryData(report.data)); + ({ fields, data } = prepareSavingsSummaryData(report.data)); + break; + case ReportType.TRANSACTIONS_SUMMARY: + ({ fields, data } = prepareTransactionsSummaryData(report.data)); + break; + case ReportType.EXPENSES_BY_CATEGORY: + ({ fields, data } = prepareExpensesByCategoryData(report.data)); + break; + case ReportType.MONTHLY_TREND: + ({ fields, data } = prepareMonthlyTrendData(report.data)); + break; + case ReportType.BUDGET_PERFORMANCE: + ({ fields, data } = prepareBudgetPerformanceData(report.data)); + break; + case ReportType.FINANCIAL_OVERVIEW: + ({ fields, data } = prepareFinancialOverviewData(report.data)); break; default: throw new Error( @@ -31,149 +60,4 @@ export class CSVService { const parser = new Parser({ fields }); return parser.parse(data); } - - private prepareGoalsByStatusData(data: any): { - fields: string[]; - data: any[]; - } { - const fields = [ - "name", - "status", - "targetAmount", - "currentAmount", - "progress", - "deadline", - ]; - const rows = data.goals.map((goal: any) => ({ - name: goal.name, - status: goal.status, - targetAmount: goal.targetAmount, - currentAmount: goal.currentAmount, - progress: goal.progress, - deadline: goal.deadline, - })); - - // Add summary rows - rows.push( - {}, - { name: "Summary" }, - { name: "Total Goals", status: data.total }, - { name: "Completed Goals", status: data.completed }, - { name: "Expired Goals", status: data.expired }, - { name: "In Progress Goals", status: data.inProgress } - ); - - return { fields, data: rows }; - } - - private prepareGoalsByCategoryData(data: any): { - fields: string[]; - data: any[]; - } { - const fields = [ - "category", - "totalGoals", - "totalAmount", - "completedAmount", - "progress", - ]; - const rows: any[] = []; - - data.categories.forEach((category: any) => { - rows.push({ - category: category.name, - totalGoals: category.totalGoals, - totalAmount: category.totalAmount, - completedAmount: category.completedAmount, - progress: category.progress, - }); - - category.goals.forEach((goal: any) => { - rows.push({ - category: ` ${goal.name}`, - totalGoals: "", - totalAmount: goal.targetAmount, - completedAmount: goal.currentAmount, - progress: goal.progress, - }); - }); - - rows.push({}); - }); - - return { fields, data: rows }; - } - - private prepareContributionsByGoalData(data: any): { - fields: string[]; - data: any[]; - } { - const fields = ["date", "amount", "transactionId"]; - const rows = [ - { date: "Goal Name", amount: data.goalName }, - {}, - ...data.contributions.map((contribution: any) => ({ - date: contribution.date, - amount: contribution.amount, - transactionId: contribution.transactionId, - })), - {}, - { date: "Total Contributions", amount: data.totalContributions }, - { date: "Average Contribution", amount: data.averageContribution }, - { date: "Last Contribution", amount: data.lastContributionDate }, - ]; - - return { fields, data: rows }; - } - - private prepareSavingsComparisonData(data: any): { - fields: string[]; - data: any[]; - } { - const fields = ["date", "plannedAmount", "actualAmount", "difference"]; - const rows = [ - { date: "Goal Name", plannedAmount: data.goalName }, - {}, - ...data.deviations.map((deviation: any) => ({ - date: deviation.date, - plannedAmount: deviation.plannedAmount, - actualAmount: deviation.actualAmount, - difference: deviation.difference, - })), - ]; - - return { fields, data: rows }; - } - - private prepareSavingsSummaryData(data: any): { - fields: string[]; - data: any[]; - } { - const fields = ["metric", "value"]; - const rows = [ - { metric: "Total Goals", value: data.totalGoals }, - { metric: "Total Target Amount", value: data.totalTargetAmount }, - { metric: "Total Current Amount", value: data.totalCurrentAmount }, - { metric: "Overall Progress (%)", value: data.overallProgress }, - { metric: "Completed Goals", value: data.completedGoals }, - { metric: "Expired Goals", value: data.expiredGoals }, - { metric: "In Progress Goals", value: data.inProgressGoals }, - { metric: "Average Contribution", value: data.averageContribution }, - { metric: "Last Contribution Date", value: data.lastContributionDate }, - {}, - { metric: "Category Breakdown" }, - ]; - - data.categoryBreakdown.forEach((category: any) => { - rows.push( - { metric: category.categoryName }, - { metric: " Total Goals", value: category.totalGoals }, - { metric: " Total Amount", value: category.totalAmount }, - { metric: " Progress (%)", value: category.progress }, - {} - ); - }); - - return { fields, data: rows }; - } } diff --git a/src/features/reports/infrastructure/services/csv/csv-budget-formatters.ts b/src/features/reports/infrastructure/services/csv/csv-budget-formatters.ts new file mode 100644 index 0000000..81d10e1 --- /dev/null +++ b/src/features/reports/infrastructure/services/csv/csv-budget-formatters.ts @@ -0,0 +1,58 @@ +export function prepareBudgetPerformanceData(data: any): { + fields: string[]; + data: any[]; +} { + const fields = [ + "nombreCategoria", + "mes", + "montoLimite", + "montoActual", + "porcentaje", + "estado", + ]; + + const rows = [ + { + nombreCategoria: "Resumen", + mes: "Total Presupuestos", + montoLimite: data.totalBudgets, + }, + { + nombreCategoria: "Resumen", + mes: "Cantidad Excedidos", + montoLimite: data.exceededCount, + }, + { + nombreCategoria: "Resumen", + mes: "Cantidad Advertencia", + montoLimite: data.warningCount, + }, + { + nombreCategoria: "Resumen", + mes: "Cantidad Buenos", + montoLimite: data.goodCount, + }, + {}, + { nombreCategoria: "Detalles Presupuestos" }, + ]; + + data.budgets?.forEach((budget: any) => { + const statusLabel = + budget.status === "exceeded" + ? "EXCEDIDO" + : budget.status === "warning" + ? "ADVERTENCIA" + : "BUENO"; + + rows.push({ + nombreCategoria: budget.categoryName || "Sin categoría", + mes: budget.month || "N/A", + montoLimite: budget.limitAmount, + montoActual: budget.currentAmount, + porcentaje: budget.percentage, + estado: statusLabel, + } as any); + }); + + return { fields, data: rows }; +} diff --git a/src/features/reports/infrastructure/services/csv/csv-goal-formatters.ts b/src/features/reports/infrastructure/services/csv/csv-goal-formatters.ts new file mode 100644 index 0000000..cd355bc --- /dev/null +++ b/src/features/reports/infrastructure/services/csv/csv-goal-formatters.ts @@ -0,0 +1,152 @@ +import { Parser } from "json2csv"; +import { formatDate, formatDateOnly } from "./date-formatter"; + +export function prepareGoalsByStatusData(data: any): { + fields: string[]; + data: any[]; +} { + const fields = [ + "nombre", + "estado", + "montoObjetivo", + "montoActual", + "progreso", + "fechaLimite", + ]; + const rows = data.goals.map((goal: any) => ({ + nombre: goal.name, + estado: goal.status, + montoObjetivo: goal.targetAmount, + montoActual: goal.currentAmount, + progreso: goal.progress, + fechaLimite: formatDateOnly(goal.deadline), + })); + + rows.push( + {}, + { nombre: "Resumen" }, + { nombre: "Total Metas", estado: data.total }, + { nombre: "Metas Completadas", estado: data.completed }, + { nombre: "Metas Expiradas", estado: data.expired }, + { nombre: "Metas en Progreso", estado: data.inProgress } + ); + + return { fields, data: rows }; +} + +export function prepareGoalsByCategoryData(data: any): { + fields: string[]; + data: any[]; +} { + const fields = [ + "categoria", + "totalMetas", + "montoTotal", + "montoCompletado", + "progreso", + ]; + const rows: any[] = []; + + data.categories.forEach((category: any) => { + rows.push({ + categoria: category.name, + totalMetas: category.totalGoals, + montoTotal: category.totalAmount, + montoCompletado: category.completedAmount, + progreso: category.progress, + }); + + category.goals.forEach((goal: any) => { + rows.push({ + categoria: ` ${goal.name}`, + totalMetas: "", + montoTotal: goal.targetAmount, + montoCompletado: goal.currentAmount, + progreso: goal.progress, + }); + }); + + rows.push({}); + }); + + return { fields, data: rows }; +} + +export function prepareContributionsByGoalData(data: any): { + fields: string[]; + data: any[]; +} { + const fields = ["fecha", "monto", "idTransaccion"]; + const rows = [ + { fecha: "Nombre de Meta", monto: data.goalName }, + {}, + ...data.contributions.map((contribution: any) => ({ + fecha: formatDate(contribution.date), + monto: contribution.amount, + idTransaccion: contribution.transactionId, + })), + {}, + { fecha: "Total Contribuciones", monto: data.totalContributions }, + { fecha: "Contribución Promedio", monto: data.averageContribution }, + { + fecha: "Última Contribución", + monto: formatDate(data.lastContributionDate), + }, + ]; + + return { fields, data: rows }; +} + +export function prepareSavingsComparisonData(data: any): { + fields: string[]; + data: any[]; +} { + const fields = ["fecha", "montoPlanificado", "montoReal", "diferencia"]; + const rows = [ + { fecha: "Nombre de Meta", montoPlanificado: data.goalName }, + {}, + ...data.deviations.map((deviation: any) => ({ + fecha: formatDate(deviation.date), + montoPlanificado: deviation.plannedAmount, + montoReal: deviation.actualAmount, + diferencia: deviation.difference, + })), + ]; + + return { fields, data: rows }; +} + +export function prepareSavingsSummaryData(data: any): { + fields: string[]; + data: any[]; +} { + const fields = ["metrica", "valor"]; + const rows = [ + { metrica: "Total Metas", valor: data.totalGoals }, + { metrica: "Monto Total Objetivo", valor: data.totalTargetAmount }, + { metrica: "Monto Total Actual", valor: data.totalCurrentAmount }, + { metrica: "Progreso General (%)", valor: data.overallProgress }, + { metrica: "Metas Completadas", valor: data.completedGoals }, + { metrica: "Metas Expiradas", valor: data.expiredGoals }, + { metrica: "Metas en Progreso", valor: data.inProgressGoals }, + { metrica: "Contribución Promedio", valor: data.averageContribution }, + { + metrica: "Fecha Última Contribución", + valor: formatDate(data.lastContributionDate), + }, + {}, + { metrica: "Desglose por Categoría" }, + ]; + + data.categoryBreakdown.forEach((category: any) => { + rows.push( + { metrica: category.categoryName }, + { metrica: " Total Metas", valor: category.totalGoals }, + { metrica: " Monto Total", valor: category.totalAmount }, + { metrica: " Progreso (%)", valor: category.progress }, + {} + ); + }); + + return { fields, data: rows }; +} diff --git a/src/features/reports/infrastructure/services/csv/csv-overview-formatters.ts b/src/features/reports/infrastructure/services/csv/csv-overview-formatters.ts new file mode 100644 index 0000000..afdaa19 --- /dev/null +++ b/src/features/reports/infrastructure/services/csv/csv-overview-formatters.ts @@ -0,0 +1,109 @@ +import { formatDateOnly } from "./date-formatter"; + +export function prepareFinancialOverviewData(data: any): { + fields: string[]; + data: any[]; +} { + const fields = ["seccion", "metrica", "valor", "detalles"]; + + const rows = [ + { + seccion: "Período", + metrica: "Fecha Inicio", + valor: formatDateOnly(data.period?.startDate), + }, + { + seccion: "Período", + metrica: "Fecha Fin", + valor: formatDateOnly(data.period?.endDate), + }, + {}, + { + seccion: "Resumen", + metrica: "Total Ingresos", + valor: data.summary?.totalIncome, + }, + { + seccion: "Resumen", + metrica: "Total Gastos", + valor: data.summary?.totalExpense, + }, + { + seccion: "Resumen", + metrica: "Balance Neto", + valor: data.summary?.netBalance, + }, + { + seccion: "Resumen", + metrica: "Tasa de Ahorro (%)", + valor: data.summary?.savingsRate, + }, + {}, + { seccion: "Metas", metrica: "Total", valor: data.goals?.total }, + { seccion: "Metas", metrica: "Completadas", valor: data.goals?.completed }, + { seccion: "Metas", metrica: "En Progreso", valor: data.goals?.inProgress }, + { + seccion: "Metas", + metrica: "Total Ahorrado", + valor: data.goals?.totalSaved, + }, + { + seccion: "Metas", + metrica: "Total Objetivo", + valor: data.goals?.totalTarget, + }, + { + seccion: "Metas", + metrica: "Progreso General (%)", + valor: data.goals?.overallProgress, + }, + {}, + { seccion: "Presupuestos", metrica: "Total", valor: data.budgets?.total }, + { + seccion: "Presupuestos", + metrica: "Excedidos", + valor: data.budgets?.exceeded, + }, + { + seccion: "Presupuestos", + metrica: "Utilización Promedio (%)", + valor: data.budgets?.averageUtilization, + }, + {}, + { seccion: "Deudas", metrica: "Total", valor: data.debts?.total }, + { + seccion: "Deudas", + metrica: "Monto Total", + valor: data.debts?.totalAmount, + }, + { + seccion: "Deudas", + metrica: "Total Pendiente", + valor: data.debts?.totalPending, + }, + {}, + { seccion: "Categorías Top Gastos" }, + ]; + + data.topCategories?.expenses?.forEach((category: any) => { + rows.push({ + seccion: "Categorías Top Gastos", + metrica: category.name || "Sin nombre", + valor: category.amount, + detalles: `${category.percentage?.toFixed(1)}%`, + } as any); + }); + + rows.push({}, { seccion: "Categorías Top Ingresos" }); + + data.topCategories?.income?.forEach((category: any) => { + rows.push({ + seccion: "Categorías Top Ingresos", + metrica: category.name || "Sin nombre", + valor: category.amount, + detalles: `${category.percentage?.toFixed(1)}%`, + } as any); + }); + + return { fields, data: rows }; +} diff --git a/src/features/reports/infrastructure/services/csv/csv-transaction-formatters.ts b/src/features/reports/infrastructure/services/csv/csv-transaction-formatters.ts new file mode 100644 index 0000000..a4c2191 --- /dev/null +++ b/src/features/reports/infrastructure/services/csv/csv-transaction-formatters.ts @@ -0,0 +1,144 @@ +import { formatDate } from "./date-formatter"; + +export function prepareTransactionsSummaryData(data: any): { + fields: string[]; + data: any[]; +} { + const fields = ["fecha", "tipo", "categoria", "monto", "descripcion"]; + + const rows = [ + { fecha: "Resumen", tipo: "Total Ingresos", monto: data.totalIncome }, + { fecha: "Resumen", tipo: "Total Gastos", monto: data.totalExpense }, + { fecha: "Resumen", tipo: "Balance Neto", monto: data.netBalance }, + { + fecha: "Resumen", + tipo: "Cantidad Transacciones", + monto: data.transactionCount, + }, + { fecha: "Resumen", tipo: "Cantidad Ingresos", monto: data.incomeCount }, + { fecha: "Resumen", tipo: "Cantidad Gastos", monto: data.expenseCount }, + { fecha: "Resumen", tipo: "Ingreso Promedio", monto: data.averageIncome }, + { fecha: "Resumen", tipo: "Gasto Promedio", monto: data.averageExpense }, + {}, + { fecha: "Categorías Principales" }, + ]; + + if (data.topIncomeCategory) { + rows.push({ + fecha: "Categoría Top Ingreso", + tipo: data.topIncomeCategory.name, + monto: data.topIncomeCategory.amount, + }); + } + + if (data.topExpenseCategory) { + rows.push({ + fecha: "Categoría Top Gasto", + tipo: data.topExpenseCategory.name, + monto: data.topExpenseCategory.amount, + }); + } + + rows.push({}, { fecha: "Transacciones" }); + + data.transactions?.forEach((transaction: any) => { + rows.push({ + fecha: formatDate(transaction.date), + tipo: transaction.type === "INCOME" ? "Ingreso" : "Gasto", + categoria: transaction.category || "Sin categoría", + monto: transaction.amount, + descripcion: transaction.description || "", + } as any); + }); + + return { fields, data: rows }; +} + +export function prepareExpensesByCategoryData(data: any): { + fields: string[]; + data: any[]; +} { + const fields = ["categoria", "monto", "porcentaje", "cantidadTransacciones"]; + + const rows = [ + { + categoria: "Resumen", + monto: "Total Gastos", + porcentaje: data.totalExpenses, + }, + { + categoria: "Resumen", + monto: "Cantidad Categorías", + porcentaje: data.categoryCount, + }, + {}, + { categoria: "Categorías" }, + ]; + + data.categories?.forEach((category: any) => { + rows.push({ + categoria: category.name, + monto: category.amount, + porcentaje: category.percentage, + cantidadTransacciones: category.transactionCount, + } as any); + + category.transactions?.forEach((transaction: any) => { + rows.push({ + categoria: ` ${transaction.description || "Sin descripción"}`, + monto: transaction.amount, + porcentaje: "", + cantidadTransacciones: formatDate(transaction.date), + } as any); + }); + + rows.push({}); + }); + + return { fields, data: rows }; +} + +export function prepareMonthlyTrendData(data: any): { + fields: string[]; + data: any[]; +} { + const fields = [ + "mes", + "ingresos", + "gastos", + "balance", + "cantidadTransacciones", + ]; + + const rows = [ + { + mes: "Resumen", + ingresos: "Ingreso Mensual Promedio", + gastos: data.averageMonthlyIncome, + }, + { + mes: "Resumen", + ingresos: "Gasto Mensual Promedio", + gastos: data.averageMonthlyExpense, + }, + { + mes: "Resumen", + ingresos: "Tendencia", + gastos: data.trend?.toUpperCase(), + }, + {}, + { mes: "Datos Mensuales" }, + ]; + + data.months?.forEach((month: any) => { + rows.push({ + mes: month.month, + ingresos: month.income, + gastos: month.expense, + balance: month.balance, + cantidadTransacciones: month.transactionCount, + } as any); + }); + + return { fields, data: rows }; +} diff --git a/src/features/reports/infrastructure/services/csv/date-formatter.ts b/src/features/reports/infrastructure/services/csv/date-formatter.ts new file mode 100644 index 0000000..ae135d8 --- /dev/null +++ b/src/features/reports/infrastructure/services/csv/date-formatter.ts @@ -0,0 +1,41 @@ +export function formatDate(date: any): string { + if (!date) return ""; + + try { + const dateObj = new Date(date); + + if (isNaN(dateObj.getTime())) { + return String(date); + } + + return dateObj.toLocaleDateString("es-ES", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); + } catch (error) { + return String(date); + } +} + +export function formatDateOnly(date: any): string { + if (!date) return ""; + + try { + const dateObj = new Date(date); + + if (isNaN(dateObj.getTime())) { + return String(date); + } + + return dateObj.toLocaleDateString("es-ES", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + } catch (error) { + return String(date); + } +} diff --git a/src/features/reports/infrastructure/services/excel.service.ts b/src/features/reports/infrastructure/services/excel.service.ts index 4db9eb5..e8ca6d7 100644 --- a/src/features/reports/infrastructure/services/excel.service.ts +++ b/src/features/reports/infrastructure/services/excel.service.ts @@ -1,5 +1,19 @@ import { Report, ReportType } from "../../domain/entities/report.entity"; import { Workbook, Worksheet } from "exceljs"; +import { + formatGoalsByStatus, + formatGoalsByCategory, + formatContributionsByGoal, + formatSavingsComparison, + formatSavingsSummary, +} from "./excel/excel-goal-formatters"; +import { + formatTransactionsSummary, + formatExpensesByCategory, + formatMonthlyTrend, +} from "./excel/excel-transaction-formatters"; +import { formatBudgetPerformance } from "./excel/excel-budget-formatters"; +import { formatFinancialOverview } from "./excel/excel-overview-formatters"; export class ExcelService { async generateExcel(report: Report): Promise { @@ -8,19 +22,34 @@ export class ExcelService { switch (report.type) { case ReportType.GOALS_BY_STATUS: - this.formatGoalsByStatus(worksheet, report.data); + formatGoalsByStatus(worksheet, report.data); break; case ReportType.GOALS_BY_CATEGORY: - this.formatGoalsByCategory(worksheet, report.data); + formatGoalsByCategory(worksheet, report.data); break; case ReportType.CONTRIBUTIONS_BY_GOAL: - this.formatContributionsByGoal(worksheet, report.data); + formatContributionsByGoal(worksheet, report.data); break; case ReportType.SAVINGS_COMPARISON: - this.formatSavingsComparison(worksheet, report.data); + formatSavingsComparison(worksheet, report.data); break; case ReportType.SAVINGS_SUMMARY: - this.formatSavingsSummary(worksheet, report.data); + formatSavingsSummary(worksheet, report.data); + break; + case ReportType.TRANSACTIONS_SUMMARY: + formatTransactionsSummary(worksheet, report.data); + break; + case ReportType.EXPENSES_BY_CATEGORY: + formatExpensesByCategory(worksheet, report.data); + break; + case ReportType.MONTHLY_TREND: + formatMonthlyTrend(worksheet, report.data); + break; + case ReportType.BUDGET_PERFORMANCE: + formatBudgetPerformance(worksheet, report.data); + break; + case ReportType.FINANCIAL_OVERVIEW: + formatFinancialOverview(worksheet, report.data); break; default: throw new Error( @@ -30,126 +59,4 @@ export class ExcelService { return (await workbook.xlsx.writeBuffer()) as unknown as Buffer; } - - private formatGoalsByStatus(worksheet: Worksheet, data: any) { - worksheet.addRow([ - "Name", - "Status", - "Target Amount", - "Current Amount", - "Progress", - "Deadline", - ]); - - data.goals?.forEach((goal: any) => { - worksheet.addRow([ - goal.name, - goal.status, - goal.targetAmount, - goal.currentAmount, - goal.progress, - goal.deadline, - ]); - }); - - worksheet.addRow([]); - worksheet.addRow(["Summary"]); - worksheet.addRow(["Total Goals", data.total]); - worksheet.addRow(["Completed Goals", data.completed]); - worksheet.addRow(["Expired Goals", data.expired]); - worksheet.addRow(["In Progress Goals", data.inProgress]); - } - - private formatGoalsByCategory(worksheet: Worksheet, data: any) { - worksheet.addRow([ - "Category", - "Total Goals", - "Total Amount", - "Completed Amount", - "Progress", - ]); - - data.categories.forEach((category: any) => { - worksheet.addRow([ - category.name, - category.totalGoals, - category.totalAmount, - category.completedAmount, - category.progress, - ]); - - category.goals.forEach((goal: any) => { - worksheet.addRow([ - ` ${goal.name}`, - "", - goal.targetAmount, - goal.currentAmount, - goal.progress, - ]); - }); - - worksheet.addRow([]); - }); - } - - private formatContributionsByGoal(worksheet: Worksheet, data: any) { - worksheet.addRow(["Date", "Amount", "Transaction ID"]); - - worksheet.addRow(["Goal Name", data.goalName]); - worksheet.addRow([]); - - data.contributions.forEach((contribution: any) => { - worksheet.addRow([ - contribution.date, - contribution.amount, - contribution.transactionId, - ]); - }); - - worksheet.addRow([]); - worksheet.addRow(["Total Contributions", data.totalContributions]); - worksheet.addRow(["Average Contribution", data.averageContribution]); - worksheet.addRow(["Last Contribution", data.lastContributionDate]); - } - - private formatSavingsComparison(worksheet: Worksheet, data: any) { - worksheet.addRow(["Date", "Planned Amount", "Actual Amount", "Difference"]); - - worksheet.addRow(["Goal Name", data.goalName]); - worksheet.addRow([]); - - data.deviations.forEach((deviation: any) => { - worksheet.addRow([ - deviation.date, - deviation.plannedAmount, - deviation.actualAmount, - deviation.difference, - ]); - }); - } - - private formatSavingsSummary(worksheet: Worksheet, data: any) { - worksheet.addRow(["Metric", "Value"]); - - worksheet.addRow(["Total Goals", data.totalGoals]); - worksheet.addRow(["Total Target Amount", data.totalTargetAmount]); - worksheet.addRow(["Total Current Amount", data.totalCurrentAmount]); - worksheet.addRow(["Overall Progress (%)", data.overallProgress]); - worksheet.addRow(["Completed Goals", data.completedGoals]); - worksheet.addRow(["Expired Goals", data.expiredGoals]); - worksheet.addRow(["In Progress Goals", data.inProgressGoals]); - worksheet.addRow(["Average Contribution", data.averageContribution]); - worksheet.addRow(["Last Contribution Date", data.lastContributionDate]); - - worksheet.addRow([]); - worksheet.addRow(["Category Breakdown"]); - - data.categoryBreakdown.forEach((category: any) => { - worksheet.addRow([category.categoryName]); - worksheet.addRow([" Total Goals", category.totalGoals]); - worksheet.addRow([" Total Amount", category.totalAmount]); - worksheet.addRow([" Progress (%)", category.progress]); - worksheet.addRow([]); - }); - } } diff --git a/src/features/reports/infrastructure/services/excel/excel-budget-formatters.ts b/src/features/reports/infrastructure/services/excel/excel-budget-formatters.ts new file mode 100644 index 0000000..82b15d3 --- /dev/null +++ b/src/features/reports/infrastructure/services/excel/excel-budget-formatters.ts @@ -0,0 +1,38 @@ +import { Worksheet } from "exceljs"; + +export function formatBudgetPerformance(worksheet: Worksheet, data: any) { + worksheet.addRow(["Resumen"]); + worksheet.addRow(["Total Presupuestos", data.totalBudgets]); + worksheet.addRow(["Cantidad Excedidos", data.exceededCount]); + worksheet.addRow(["Cantidad Advertencia", data.warningCount]); + worksheet.addRow(["Cantidad Buenos", data.goodCount]); + worksheet.addRow([]); + + worksheet.addRow(["Detalles Presupuestos"]); + worksheet.addRow([ + "Categoría", + "Mes", + "Monto Límite", + "Monto Actual", + "Porcentaje", + "Estado", + ]); + + data.budgets?.forEach((budget: any) => { + const statusLabel = + budget.status === "exceeded" + ? "EXCEDIDO" + : budget.status === "warning" + ? "ADVERTENCIA" + : "BUENO"; + + worksheet.addRow([ + budget.categoryName || "Sin categoría", + budget.month || "N/A", + budget.limitAmount, + budget.currentAmount, + budget.percentage, + statusLabel, + ]); + }); +} diff --git a/src/features/reports/infrastructure/services/excel/excel-goal-formatters.ts b/src/features/reports/infrastructure/services/excel/excel-goal-formatters.ts new file mode 100644 index 0000000..e1caee4 --- /dev/null +++ b/src/features/reports/infrastructure/services/excel/excel-goal-formatters.ts @@ -0,0 +1,130 @@ +import { Worksheet } from "exceljs"; +import { formatDate, formatDateOnly } from "../csv/date-formatter"; + +export function formatGoalsByStatus(worksheet: Worksheet, data: any) { + worksheet.addRow([ + "Nombre", + "Estado", + "Monto Objetivo", + "Monto Actual", + "Progreso", + "Fecha Límite", + ]); + + data.goals?.forEach((goal: any) => { + worksheet.addRow([ + goal.name, + goal.status, + goal.targetAmount, + goal.currentAmount, + goal.progress, + formatDateOnly(goal.deadline), + ]); + }); + + worksheet.addRow([]); + worksheet.addRow(["Resumen"]); + worksheet.addRow(["Total Metas", data.total]); + worksheet.addRow(["Metas Completadas", data.completed]); + worksheet.addRow(["Metas Expiradas", data.expired]); + worksheet.addRow(["Metas en Progreso", data.inProgress]); +} + +export function formatGoalsByCategory(worksheet: Worksheet, data: any) { + worksheet.addRow([ + "Categoría", + "Total Metas", + "Monto Total", + "Monto Completado", + "Progreso", + ]); + + data.categories.forEach((category: any) => { + worksheet.addRow([ + category.name, + category.totalGoals, + category.totalAmount, + category.completedAmount, + category.progress, + ]); + + category.goals.forEach((goal: any) => { + worksheet.addRow([ + ` ${goal.name}`, + "", + goal.targetAmount, + goal.currentAmount, + goal.progress, + ]); + }); + + worksheet.addRow([]); + }); +} + +export function formatContributionsByGoal(worksheet: Worksheet, data: any) { + worksheet.addRow(["Fecha", "Monto", "ID Transacción"]); + + worksheet.addRow(["Nombre de Meta", data.goalName]); + worksheet.addRow([]); + + data.contributions.forEach((contribution: any) => { + worksheet.addRow([ + formatDate(contribution.date), + contribution.amount, + contribution.transactionId, + ]); + }); + + worksheet.addRow([]); + worksheet.addRow(["Total Contribuciones", data.totalContributions]); + worksheet.addRow(["Contribución Promedio", data.averageContribution]); + worksheet.addRow([ + "Última Contribución", + formatDate(data.lastContributionDate), + ]); +} + +export function formatSavingsComparison(worksheet: Worksheet, data: any) { + worksheet.addRow(["Fecha", "Monto Planificado", "Monto Real", "Diferencia"]); + + worksheet.addRow(["Nombre de Meta", data.goalName]); + worksheet.addRow([]); + + data.deviations.forEach((deviation: any) => { + worksheet.addRow([ + formatDate(deviation.date), + deviation.plannedAmount, + deviation.actualAmount, + deviation.difference, + ]); + }); +} + +export function formatSavingsSummary(worksheet: Worksheet, data: any) { + worksheet.addRow(["Métrica", "Valor"]); + + worksheet.addRow(["Total Metas", data.totalGoals]); + worksheet.addRow(["Monto Total Objetivo", data.totalTargetAmount]); + worksheet.addRow(["Monto Total Actual", data.totalCurrentAmount]); + worksheet.addRow(["Progreso General (%)", data.overallProgress]); + worksheet.addRow(["Metas Completadas", data.completedGoals]); + worksheet.addRow(["Metas Expiradas", data.expiredGoals]); + worksheet.addRow(["Metas en Progreso", data.inProgressGoals]); + worksheet.addRow(["Contribución Promedio", data.averageContribution]); + worksheet.addRow([ + "Fecha Última Contribución", + formatDate(data.lastContributionDate), + ]); + + worksheet.addRow([]); + worksheet.addRow(["Desglose por Categoría"]); + + data.categoryBreakdown.forEach((category: any) => { + worksheet.addRow([category.categoryName]); + worksheet.addRow([" Total Metas", category.totalGoals]); + worksheet.addRow([" Monto Total", category.totalAmount]); + worksheet.addRow([" Progreso (%)", category.progress]); + worksheet.addRow([]); + }); +} diff --git a/src/features/reports/infrastructure/services/excel/excel-overview-formatters.ts b/src/features/reports/infrastructure/services/excel/excel-overview-formatters.ts new file mode 100644 index 0000000..c509e19 --- /dev/null +++ b/src/features/reports/infrastructure/services/excel/excel-overview-formatters.ts @@ -0,0 +1,68 @@ +import { Worksheet } from "exceljs"; +import { formatDateOnly } from "../csv/date-formatter"; + +export function formatFinancialOverview(worksheet: Worksheet, data: any) { + worksheet.addRow(["Período"]); + worksheet.addRow(["Fecha Inicio", formatDateOnly(data.period?.startDate)]); + worksheet.addRow(["Fecha Fin", formatDateOnly(data.period?.endDate)]); + worksheet.addRow([]); + + worksheet.addRow(["Resumen"]); + worksheet.addRow(["Total Ingresos", data.summary?.totalIncome]); + worksheet.addRow(["Total Gastos", data.summary?.totalExpense]); + worksheet.addRow(["Balance Neto", data.summary?.netBalance]); + worksheet.addRow(["Tasa de Ahorro (%)", data.summary?.savingsRate]); + worksheet.addRow([]); + + worksheet.addRow(["Metas"]); + worksheet.addRow(["Total", data.goals?.total]); + worksheet.addRow(["Completadas", data.goals?.completed]); + worksheet.addRow(["En Progreso", data.goals?.inProgress]); + worksheet.addRow(["Total Ahorrado", data.goals?.totalSaved]); + worksheet.addRow(["Total Objetivo", data.goals?.totalTarget]); + worksheet.addRow(["Progreso General (%)", data.goals?.overallProgress]); + worksheet.addRow([]); + + worksheet.addRow(["Presupuestos"]); + worksheet.addRow(["Total", data.budgets?.total]); + worksheet.addRow(["Excedidos", data.budgets?.exceeded]); + worksheet.addRow([ + "Utilización Promedio (%)", + data.budgets?.averageUtilization, + ]); + worksheet.addRow([]); + + worksheet.addRow(["Deudas"]); + worksheet.addRow(["Total", data.debts?.total]); + worksheet.addRow(["Monto Total", data.debts?.totalAmount]); + worksheet.addRow(["Total Pendiente", data.debts?.totalPending]); + worksheet.addRow([]); + + if (data.topCategories?.expenses && data.topCategories.expenses.length > 0) { + worksheet.addRow(["Categorías Top Gastos"]); + worksheet.addRow(["Categoría", "Monto", "Porcentaje"]); + + data.topCategories.expenses.forEach((category: any) => { + worksheet.addRow([ + category.name || "Sin nombre", + category.amount, + category.percentage, + ]); + }); + + worksheet.addRow([]); + } + + if (data.topCategories?.income && data.topCategories.income.length > 0) { + worksheet.addRow(["Categorías Top Ingresos"]); + worksheet.addRow(["Categoría", "Monto", "Porcentaje"]); + + data.topCategories.income.forEach((category: any) => { + worksheet.addRow([ + category.name || "Sin nombre", + category.amount, + category.percentage, + ]); + }); + } +} diff --git a/src/features/reports/infrastructure/services/excel/excel-transaction-formatters.ts b/src/features/reports/infrastructure/services/excel/excel-transaction-formatters.ts new file mode 100644 index 0000000..fa3cdae --- /dev/null +++ b/src/features/reports/infrastructure/services/excel/excel-transaction-formatters.ts @@ -0,0 +1,111 @@ +import { Worksheet } from "exceljs"; +import { formatDate } from "../csv/date-formatter"; + +export function formatTransactionsSummary(worksheet: Worksheet, data: any) { + worksheet.addRow(["Resumen"]); + worksheet.addRow(["Total Ingresos", data.totalIncome]); + worksheet.addRow(["Total Gastos", data.totalExpense]); + worksheet.addRow(["Balance Neto", data.netBalance]); + worksheet.addRow(["Cantidad Transacciones", data.transactionCount]); + worksheet.addRow(["Cantidad Ingresos", data.incomeCount]); + worksheet.addRow(["Cantidad Gastos", data.expenseCount]); + worksheet.addRow(["Ingreso Promedio", data.averageIncome]); + worksheet.addRow(["Gasto Promedio", data.averageExpense]); + worksheet.addRow([]); + + if (data.topIncomeCategory) { + worksheet.addRow([ + "Categoría Top Ingreso", + data.topIncomeCategory.name, + data.topIncomeCategory.amount, + ]); + } + + if (data.topExpenseCategory) { + worksheet.addRow([ + "Categoría Top Gasto", + data.topExpenseCategory.name, + data.topExpenseCategory.amount, + ]); + } + + worksheet.addRow([]); + worksheet.addRow(["Transacciones"]); + worksheet.addRow(["Fecha", "Tipo", "Categoría", "Monto", "Descripción"]); + + data.transactions?.forEach((transaction: any) => { + worksheet.addRow([ + formatDate(transaction.date), + transaction.type === "INCOME" ? "Ingreso" : "Gasto", + transaction.category || "Sin categoría", + transaction.amount, + transaction.description || "", + ]); + }); +} + +export function formatExpensesByCategory(worksheet: Worksheet, data: any) { + worksheet.addRow(["Resumen"]); + worksheet.addRow(["Total Gastos", data.totalExpenses]); + worksheet.addRow(["Cantidad Categorías", data.categoryCount]); + worksheet.addRow([]); + + worksheet.addRow(["Categorías"]); + worksheet.addRow([ + "Categoría", + "Monto", + "Porcentaje", + "Cantidad Transacciones", + ]); + + data.categories?.forEach((category: any) => { + worksheet.addRow([ + category.name, + category.amount, + category.percentage, + category.transactionCount, + ]); + + if (category.transactions && category.transactions.length > 0) { + worksheet.addRow(["Transacciones"]); + worksheet.addRow(["Descripción", "Monto", "Fecha"]); + + category.transactions.forEach((transaction: any) => { + worksheet.addRow([ + transaction.description || "Sin descripción", + transaction.amount, + formatDate(transaction.date), + ]); + }); + + worksheet.addRow([]); + } + }); +} + +export function formatMonthlyTrend(worksheet: Worksheet, data: any) { + worksheet.addRow(["Resumen"]); + worksheet.addRow(["Ingreso Mensual Promedio", data.averageMonthlyIncome]); + worksheet.addRow(["Gasto Mensual Promedio", data.averageMonthlyExpense]); + worksheet.addRow(["Tendencia", data.trend?.toUpperCase()]); + worksheet.addRow([]); + + worksheet.addRow(["Datos Mensuales"]); + worksheet.addRow([ + "Mes", + "Ingresos", + "Gastos", + "Balance", + "Cantidad Transacciones", + ]); + + data.months?.forEach((month: any) => { + worksheet.addRow([ + month.month, + month.income, + month.expense, + month.balance, + month.transactionCount, + ]); + }); +} diff --git a/src/features/reports/infrastructure/services/pdf.service.ts b/src/features/reports/infrastructure/services/pdf.service.ts index 6d5502c..2580e9e 100644 --- a/src/features/reports/infrastructure/services/pdf.service.ts +++ b/src/features/reports/infrastructure/services/pdf.service.ts @@ -1,6 +1,11 @@ import { Report, ReportType } from "../../domain/entities/report.entity"; import PDFDocument from "pdfkit"; -import { addModernHeader, addModernFooter, addReportTitle } from "./pdf/pdf-layout"; +import { + addModernHeader, + addModernFooter, + addReportTitle, +} from "./pdf/pdf-layout"; +import { DIMENSIONS, COLORS } from "./pdf/pdf-utils"; import { formatGoalsByStatus, formatGoalsByCategory, @@ -47,45 +52,80 @@ export class PDFService { addReportTitle(doc, this.getReportTypeLabel(report.type)); doc.moveDown(1); + const reportTitle = this.getReportTypeLabel(report.type); + switch (report.type) { case ReportType.GOALS_BY_STATUS: - formatGoalsByStatus(doc, report.data); + formatGoalsByStatus(doc, report.data, reportTitle); break; case ReportType.GOALS_BY_CATEGORY: - formatGoalsByCategory(doc, report.data); + formatGoalsByCategory(doc, report.data, reportTitle); break; case ReportType.CONTRIBUTIONS_BY_GOAL: - formatContributionsByGoal(doc, report.data); + formatContributionsByGoal(doc, report.data, reportTitle); break; case ReportType.SAVINGS_COMPARISON: - formatSavingsComparison(doc, report.data); + formatSavingsComparison(doc, report.data, reportTitle); break; case ReportType.SAVINGS_SUMMARY: - formatSavingsSummary(doc, report.data); + formatSavingsSummary(doc, report.data, reportTitle); break; case ReportType.TRANSACTIONS_SUMMARY: - formatTransactionsSummary(doc, report.data); + formatTransactionsSummary(doc, report.data, reportTitle); break; case ReportType.EXPENSES_BY_CATEGORY: - formatExpensesByCategory(doc, report.data); + formatExpensesByCategory(doc, report.data, reportTitle); break; case ReportType.MONTHLY_TREND: - formatMonthlyTrend(doc, report.data); + formatMonthlyTrend(doc, report.data, reportTitle); break; case ReportType.BUDGET_PERFORMANCE: - formatBudgetPerformance(doc, report.data); + formatBudgetPerformance(doc, report.data, reportTitle); break; case ReportType.FINANCIAL_OVERVIEW: - formatFinancialOverview(doc, report.data); + formatFinancialOverview(doc, report.data, reportTitle); break; default: - throw new Error(`Unsupported report type for PDF export: ${report.type}`); + throw new Error( + `Unsupported report type for PDF export: ${report.type}` + ); } - const pageCount = doc.bufferedPageRange().count; + const pageRange = doc.bufferedPageRange(); + const pageCount = pageRange.count; + for (let i = 0; i < pageCount; i++) { doc.switchToPage(i); - addModernFooter(doc, i + 1, pageCount); + + const savedY = doc.y; + const savedX = doc.x; + const footerY = DIMENSIONS.PAGE_HEIGHT - DIMENSIONS.FOOTER_HEIGHT; + + doc + .rect(0, footerY, DIMENSIONS.PAGE_WIDTH, 2) + .fillColor(COLORS.MEDIUM_GRAY) + .fill(); + + doc + .fontSize(8) + .fillColor(COLORS.TEXT) + .font("Helvetica"); + + const textWidth = doc.widthOfString(`© ${new Date().getFullYear()} FoppyAI. Todos los derechos reservados.`); + const textX = (DIMENSIONS.PAGE_WIDTH - textWidth) / 2; + + doc.text( + `© ${new Date().getFullYear()} FoppyAI. Todos los derechos reservados.`, + textX, + footerY + 15, + { + lineBreak: false, + continued: false + } + ); + + doc.x = savedX; + doc.y = savedY; } doc.end(); diff --git a/src/features/reports/infrastructure/services/pdf/pdf-budget-formatters.ts b/src/features/reports/infrastructure/services/pdf/pdf-budget-formatters.ts index 54a199b..f4e2c47 100644 --- a/src/features/reports/infrastructure/services/pdf/pdf-budget-formatters.ts +++ b/src/features/reports/infrastructure/services/pdf/pdf-budget-formatters.ts @@ -1,116 +1,186 @@ import PDFDocument from "pdfkit"; import { BudgetPerformanceReport } from "../../../domain/entities/report.entity"; -import { addMetricCard, drawProgressBar } from "./pdf-components"; +import { + addSectionTitle, + addMetricCard, + drawProgressBar, +} from "./pdf-components"; import { drawTable } from "./pdf-table"; -import { formatCurrency } from "./pdf-utils"; +import { + formatCurrency, + COLORS, + DIMENSIONS, + checkPageBreak, +} from "./pdf-utils"; import { addNewPage } from "./pdf-layout"; export function formatBudgetPerformance( doc: typeof PDFDocument.prototype, - data: BudgetPerformanceReport + data: BudgetPerformanceReport, + reportTitle: string = "Rendimiento de Presupuestos" ): void { - const pageWidth = doc.page.width; - const margin = 50; - const contentWidth = pageWidth - margin * 2; - const cardWidth = (contentWidth - 30) / 4; + if (!data) { + doc.fontSize(12).fillColor(COLORS.TEXT).text("No hay datos disponibles"); + return; + } + + addSectionTitle(doc, "Rendimiento de Presupuestos", reportTitle); + + const cardY = doc.y; + const cardWidth = 110; + const cardSpacing = 15; addMetricCard( doc, - "Total Budgets", - `${data.totalBudgets}`, - margin, - doc.y, + "Total Presupuestos", + data.totalBudgets?.toString() || "0", + DIMENSIONS.MARGIN, + cardY, cardWidth, - "#3b82f6" + COLORS.SECONDARY ); addMetricCard( doc, - "Exceeded", - `${data.exceededCount}`, - margin + cardWidth + 10, - doc.y - 80, + "Excedidos", + data.exceededCount?.toString() || "0", + DIMENSIONS.MARGIN + cardWidth + cardSpacing, + cardY, cardWidth, - "#ef4444" + COLORS.DANGER ); addMetricCard( doc, - "Warning", - `${data.warningCount}`, - margin + (cardWidth + 10) * 2, - doc.y - 80, + "Advertencia", + data.warningCount?.toString() || "0", + DIMENSIONS.MARGIN + 2 * (cardWidth + cardSpacing), + cardY, cardWidth, - "#f59e0b" + COLORS.WARNING ); addMetricCard( doc, - "Good", - `${data.goodCount}`, - margin + (cardWidth + 10) * 3, - doc.y - 80, + "Buenos", + data.goodCount?.toString() || "0", + DIMENSIONS.MARGIN + 3 * (cardWidth + cardSpacing), + cardY, cardWidth, - "#10b981" + COLORS.ACCENT ); + doc.y = cardY + 70; doc.moveDown(1); - if (data.budgets.length > 0) { - doc.fontSize(14).fillColor("#1f2937").text("Budget Details", margin, doc.y); - doc.moveDown(0.5); + if (data.budgets && data.budgets.length > 0) { + addSectionTitle(doc, "Detalle de Presupuestos", reportTitle); data.budgets.forEach((budget, index) => { - if (doc.y > 650) addNewPage(doc); + if (checkPageBreak(doc, 120)) { + addNewPage(doc, reportTitle); + } - doc - .fontSize(12) - .fillColor("#1f2937") - .text(`${budget.categoryName} (${budget.month})`, margin, doc.y); - doc.moveDown(0.3); - - doc.fontSize(10).fillColor("#6b7280"); - doc.text( - `Limit: ${formatCurrency( - budget.limitAmount - )} | Current: ${formatCurrency(budget.currentAmount)}`, - margin, - doc.y - ); - doc.moveDown(0.5); + if (index > 0) { + doc.moveDown(1); + } - const color = + const statusColor = + budget.status === "exceeded" + ? COLORS.DANGER + : budget.status === "warning" + ? COLORS.WARNING + : COLORS.ACCENT; + const statusLabel = budget.status === "exceeded" - ? "#ef4444" + ? "EXCEDIDO" : budget.status === "warning" - ? "#f59e0b" - : "#10b981"; + ? "ADVERTENCIA" + : "BUENO"; + + doc + .fontSize(13) + .fillColor(COLORS.PRIMARY) + .font("Helvetica-Bold") + .text( + budget.categoryName || "Sin categoría", + DIMENSIONS.MARGIN, + doc.y, + { continued: true } + ) + .fontSize(9) + .fillColor(statusColor) + .text(` [${statusLabel}]`, { continued: false }); + + doc.moveDown(0.5); + + const col1X = DIMENSIONS.MARGIN; + const col2X = DIMENSIONS.MARGIN + 250; + const rowY = doc.y; + + doc + .fontSize(10) + .fillColor(COLORS.TEXT) + .font("Helvetica") + .text(`Límite: ${formatCurrency(budget.limitAmount || 0)}`, col1X, rowY) + .text(`Mes: ${budget.month || "N/A"}`, col2X, rowY); + + doc.moveDown(0.8); + const row2Y = doc.y; + + doc + .text( + `Actual: ${formatCurrency(budget.currentAmount || 0)}`, + col1X, + row2Y + ) + .text(`Uso: ${(budget.percentage || 0).toFixed(1)}%`, col2X, row2Y); - drawProgressBar(doc, margin, doc.y, contentWidth, budget.percentage); doc.moveDown(1); - if (index < data.budgets.length - 1) { - doc.moveDown(0.5); - } + drawProgressBar( + doc, + DIMENSIONS.MARGIN, + doc.y, + 450, + budget.percentage || 0 + ); + + doc.moveDown(1.5); }); - if (doc.y > 600) addNewPage(doc); + if (checkPageBreak(doc, 200)) { + addNewPage(doc, reportTitle); + } - doc.fontSize(14).fillColor("#1f2937").text("Summary Table", margin, doc.y); - doc.moveDown(0.5); + addSectionTitle(doc, "Resumen en Tabla", reportTitle); const budgetData = data.budgets.map((b) => [ - b.categoryName, - b.month, - formatCurrency(b.limitAmount), - formatCurrency(b.currentAmount), - `${b.percentage.toFixed(1)}%`, - b.status.toUpperCase(), + b.categoryName || "Sin categoría", + b.month || "N/A", + formatCurrency(b.limitAmount || 0), + formatCurrency(b.currentAmount || 0), + `${(b.percentage || 0).toFixed(1)}%`, + b.status === "exceeded" + ? "EXCEDIDO" + : b.status === "warning" + ? "ADVERTENCIA" + : "BUENO", ]); - const columnWidths = [120, 80, 100, 100, 80, 80]; + const columnWidths = [140, 80, 100, 100, 80, 100]; drawTable( doc, - ["Category", "Month", "Limit", "Current", "Used", "Status"], + ["Categoría", "Mes", "Límite", "Actual", "Uso", "Estado"], budgetData, - columnWidths + columnWidths, + reportTitle ); + } else { + doc + .fontSize(11) + .fillColor(COLORS.TEXT) + .text( + "No se encontraron presupuestos para mostrar", + DIMENSIONS.MARGIN, + doc.y + ); } } diff --git a/src/features/reports/infrastructure/services/pdf/pdf-components.ts b/src/features/reports/infrastructure/services/pdf/pdf-components.ts index 35b85d4..7ddbed6 100644 --- a/src/features/reports/infrastructure/services/pdf/pdf-components.ts +++ b/src/features/reports/infrastructure/services/pdf/pdf-components.ts @@ -2,9 +2,9 @@ import PDFDocument from "pdfkit"; import { COLORS, DIMENSIONS, SPACING, checkPageBreak } from "./pdf-utils"; import { addNewPage } from "./pdf-layout"; -export function addSectionTitle(doc: typeof PDFDocument, title: string) { +export function addSectionTitle(doc: typeof PDFDocument, title: string, reportTitle?: string) { if (checkPageBreak(doc, 40)) { - addNewPage(doc); + addNewPage(doc, reportTitle); } doc diff --git a/src/features/reports/infrastructure/services/pdf/pdf-goal-formatters.ts b/src/features/reports/infrastructure/services/pdf/pdf-goal-formatters.ts index bbf6fa9..84a9fc9 100644 --- a/src/features/reports/infrastructure/services/pdf/pdf-goal-formatters.ts +++ b/src/features/reports/infrastructure/services/pdf/pdf-goal-formatters.ts @@ -8,40 +8,79 @@ import { getStatusColor, checkPageBreak, } from "./pdf-utils"; -import { addSectionTitle, addMetricCard, drawProgressBar } from "./pdf-components"; +import { + addSectionTitle, + addMetricCard, + drawProgressBar, +} from "./pdf-components"; import { drawTable } from "./pdf-table"; import { addNewPage } from "./pdf-layout"; -export function formatGoalsByStatus(doc: typeof PDFDocument, data: any) { +export function formatGoalsByStatus(doc: typeof PDFDocument, data: any, reportTitle: string = "Metas por Estado") { if (!data || !data.goals) { doc.fontSize(12).fillColor(COLORS.TEXT).text("No hay datos disponibles"); return; } - addSectionTitle(doc, "Resumen General"); + addSectionTitle(doc, "Resumen General", reportTitle); const cardY = doc.y; const cardWidth = 110; const cardSpacing = 15; - addMetricCard(doc, "Total Metas", data.total?.toString() || "0", DIMENSIONS.MARGIN, cardY, cardWidth, COLORS.SECONDARY); - addMetricCard(doc, "Completadas", data.completed?.toString() || "0", DIMENSIONS.MARGIN + cardWidth + cardSpacing, cardY, cardWidth, COLORS.ACCENT); - addMetricCard(doc, "En Progreso", data.inProgress?.toString() || "0", DIMENSIONS.MARGIN + 2 * (cardWidth + cardSpacing), cardY, cardWidth, COLORS.WARNING); - addMetricCard(doc, "Expiradas", data.expired?.toString() || "0", DIMENSIONS.MARGIN + 3 * (cardWidth + cardSpacing), cardY, cardWidth, COLORS.DANGER); + addMetricCard( + doc, + "Total Metas", + data.total?.toString() || "0", + DIMENSIONS.MARGIN, + cardY, + cardWidth, + COLORS.SECONDARY + ); + addMetricCard( + doc, + "Completadas", + data.completed?.toString() || "0", + DIMENSIONS.MARGIN + cardWidth + cardSpacing, + cardY, + cardWidth, + COLORS.ACCENT + ); + addMetricCard( + doc, + "En Progreso", + data.inProgress?.toString() || "0", + DIMENSIONS.MARGIN + 2 * (cardWidth + cardSpacing), + cardY, + cardWidth, + COLORS.WARNING + ); + addMetricCard( + doc, + "Expiradas", + data.expired?.toString() || "0", + DIMENSIONS.MARGIN + 3 * (cardWidth + cardSpacing), + cardY, + cardWidth, + COLORS.DANGER + ); doc.y = cardY + 70; doc.moveDown(1); - addSectionTitle(doc, "Detalle de Metas"); + addSectionTitle(doc, "Detalle de Metas", reportTitle); if (data.goals.length === 0) { - doc.fontSize(11).fillColor(COLORS.TEXT).text("No se encontraron metas para mostrar", DIMENSIONS.MARGIN, doc.y); + doc + .fontSize(11) + .fillColor(COLORS.TEXT) + .text("No se encontraron metas para mostrar", DIMENSIONS.MARGIN, doc.y); return; } data.goals.forEach((goal: any, index: number) => { if (checkPageBreak(doc, 120)) { - addNewPage(doc); + addNewPage(doc, reportTitle); } if (index > 0) { @@ -51,13 +90,16 @@ export function formatGoalsByStatus(doc: typeof PDFDocument, data: any) { const statusColor = getStatusColor(goal.status); const statusLabel = getStatusLabel(goal.status); - doc.fontSize(13) - .fillColor(COLORS.PRIMARY) - .font("Helvetica-Bold") - .text(goal.name || "Sin nombre", DIMENSIONS.MARGIN, doc.y, { continued: true }) - .fontSize(9) - .fillColor(statusColor) - .text(` [${statusLabel}]`, { continued: false }); + doc + .fontSize(13) + .fillColor(COLORS.PRIMARY) + .font("Helvetica-Bold") + .text(goal.name || "Sin nombre", DIMENSIONS.MARGIN, doc.y, { + continued: true, + }) + .fontSize(9) + .fillColor(statusColor) + .text(` [${statusLabel}]`, { continued: false }); doc.moveDown(0.5); @@ -65,17 +107,23 @@ export function formatGoalsByStatus(doc: typeof PDFDocument, data: any) { const col2X = DIMENSIONS.MARGIN + 250; const rowY = doc.y; - doc.fontSize(10) - .fillColor(COLORS.TEXT) - .font("Helvetica") - .text(`Meta: ${formatCurrency(goal.targetAmount || 0)}`, col1X, rowY) - .text(`Categoría: ${goal.categoryName || "Sin categoría"}`, col2X, rowY); + doc + .fontSize(10) + .fillColor(COLORS.TEXT) + .font("Helvetica") + .text(`Meta: ${formatCurrency(goal.targetAmount || 0)}`, col1X, rowY) + .text(`Categoría: ${goal.categoryName || "Sin categoría"}`, col2X, rowY); doc.moveDown(0.8); const row2Y = doc.y; - doc.text(`Ahorrado: ${formatCurrency(goal.currentAmount || 0)}`, col1X, row2Y) - .text(`Fecha límite: ${formatDate(goal.deadline)}`, col2X, row2Y); + doc + .text( + `Ahorrado: ${formatCurrency(goal.currentAmount || 0)}`, + col1X, + row2Y + ) + .text(`Fecha límite: ${formatDate(goal.deadline)}`, col2X, row2Y); doc.moveDown(1); @@ -85,7 +133,7 @@ export function formatGoalsByStatus(doc: typeof PDFDocument, data: any) { }); } -export function formatGoalsByCategory(doc: typeof PDFDocument, data: any) { +export function formatGoalsByCategory(doc: typeof PDFDocument, data: any, reportTitle: string = "Metas por Categoría") { if (!data || !data.categories) { doc.fontSize(12).fillColor(COLORS.TEXT).text("No hay datos disponibles"); return; @@ -97,32 +145,52 @@ export function formatGoalsByCategory(doc: typeof PDFDocument, data: any) { const cardWidth = 140; const cardSpacing = 20; - addMetricCard(doc, "Total Categorías", data.totalCategories?.toString() || "0", DIMENSIONS.MARGIN, cardY, cardWidth, COLORS.SECONDARY); - addMetricCard(doc, "Total Metas", data.totalGoals?.toString() || "0", DIMENSIONS.MARGIN + cardWidth + cardSpacing, cardY, cardWidth, COLORS.PRIMARY); + addMetricCard( + doc, + "Total Categorías", + data.totalCategories?.toString() || "0", + DIMENSIONS.MARGIN, + cardY, + cardWidth, + COLORS.SECONDARY + ); + addMetricCard( + doc, + "Total Metas", + data.totalGoals?.toString() || "0", + DIMENSIONS.MARGIN + cardWidth + cardSpacing, + cardY, + cardWidth, + COLORS.PRIMARY + ); doc.y = cardY + 70; doc.moveDown(1); - addSectionTitle(doc, "Detalle por Categoría"); + addSectionTitle(doc, "Detalle por Categoría", reportTitle); if (data.categories.length === 0) { - doc.fontSize(11).fillColor(COLORS.TEXT).text("No se encontraron categorías con metas", DIMENSIONS.MARGIN, doc.y); + doc + .fontSize(11) + .fillColor(COLORS.TEXT) + .text("No se encontraron categorías con metas", DIMENSIONS.MARGIN, doc.y); return; } data.categories.forEach((category: any, catIndex: number) => { if (checkPageBreak(doc, 200)) { - addNewPage(doc); + addNewPage(doc, reportTitle); } if (catIndex > 0) { doc.moveDown(1.5); } - doc.fontSize(14) - .fillColor(COLORS.SECONDARY) - .font("Helvetica-Bold") - .text(category.name || "Sin nombre", DIMENSIONS.MARGIN, doc.y); + doc + .fontSize(14) + .fillColor(COLORS.SECONDARY) + .font("Helvetica-Bold") + .text(category.name || "Sin nombre", DIMENSIONS.MARGIN, doc.y); doc.moveDown(0.5); @@ -131,12 +199,21 @@ export function formatGoalsByCategory(doc: typeof PDFDocument, data: any) { const col3X = DIMENSIONS.MARGIN + 350; const statsY = doc.y; - doc.fontSize(9) - .fillColor(COLORS.TEXT) - .font("Helvetica") - .text(`Metas: ${category.totalGoals || 0}`, col1X, statsY) - .text(`Total: ${formatCurrency(category.totalAmount || 0)}`, col2X, statsY) - .text(`Ahorrado: ${formatCurrency(category.completedAmount || 0)}`, col3X, statsY); + doc + .fontSize(9) + .fillColor(COLORS.TEXT) + .font("Helvetica") + .text(`Metas: ${category.totalGoals || 0}`, col1X, statsY) + .text( + `Total: ${formatCurrency(category.totalAmount || 0)}`, + col2X, + statsY + ) + .text( + `Ahorrado: ${formatCurrency(category.completedAmount || 0)}`, + col3X, + statsY + ); doc.moveDown(1); @@ -154,25 +231,26 @@ export function formatGoalsByCategory(doc: typeof PDFDocument, data: any) { getStatusLabel(goal.status || "inProgress"), ]); - drawTable(doc, headers, rows, [140, 80, 80, 70, 80]); + drawTable(doc, headers, rows, [140, 80, 80, 70, 80], reportTitle); } doc.moveDown(0.5); }); } -export function formatContributionsByGoal(doc: typeof PDFDocument, data: any) { +export function formatContributionsByGoal(doc: typeof PDFDocument, data: any, reportTitle: string = "Contribuciones por Meta") { if (!data) { doc.fontSize(12).fillColor(COLORS.TEXT).text("No hay datos disponibles"); return; } - addSectionTitle(doc, "Información de la Meta"); + addSectionTitle(doc, "Información de la Meta", reportTitle); - doc.fontSize(14) - .fillColor(COLORS.PRIMARY) - .font("Helvetica-Bold") - .text(data.goalName || "Sin nombre", DIMENSIONS.MARGIN, doc.y); + doc + .fontSize(14) + .fillColor(COLORS.PRIMARY) + .font("Helvetica-Bold") + .text(data.goalName || "Sin nombre", DIMENSIONS.MARGIN, doc.y); doc.moveDown(1); @@ -180,17 +258,44 @@ export function formatContributionsByGoal(doc: typeof PDFDocument, data: any) { const cardWidth = 150; const cardSpacing = 15; - addMetricCard(doc, "Total Contribuciones", formatCurrency(data.totalContributions || 0), DIMENSIONS.MARGIN, cardY, cardWidth, COLORS.ACCENT); - addMetricCard(doc, "Promedio", formatCurrency(data.averageContribution || 0), DIMENSIONS.MARGIN + cardWidth + cardSpacing, cardY, cardWidth, COLORS.SECONDARY); - addMetricCard(doc, "Última Contribución", data.lastContributionDate ? formatDate(data.lastContributionDate) : "N/A", DIMENSIONS.MARGIN + 2 * (cardWidth + cardSpacing), cardY, cardWidth, COLORS.PRIMARY); + addMetricCard( + doc, + "Total Contribuciones", + formatCurrency(data.totalContributions || 0), + DIMENSIONS.MARGIN, + cardY, + cardWidth, + COLORS.ACCENT + ); + addMetricCard( + doc, + "Promedio", + formatCurrency(data.averageContribution || 0), + DIMENSIONS.MARGIN + cardWidth + cardSpacing, + cardY, + cardWidth, + COLORS.SECONDARY + ); + addMetricCard( + doc, + "Última Contribución", + data.lastContributionDate ? formatDate(data.lastContributionDate) : "N/A", + DIMENSIONS.MARGIN + 2 * (cardWidth + cardSpacing), + cardY, + cardWidth, + COLORS.PRIMARY + ); doc.y = cardY + 70; doc.moveDown(1); - addSectionTitle(doc, "Historial de Contribuciones"); + addSectionTitle(doc, "Historial de Contribuciones", reportTitle); if (!data.contributions || data.contributions.length === 0) { - doc.fontSize(11).fillColor(COLORS.TEXT).text("No se encontraron contribuciones", DIMENSIONS.MARGIN, doc.y); + doc + .fontSize(11) + .fillColor(COLORS.TEXT) + .text("No se encontraron contribuciones", DIMENSIONS.MARGIN, doc.y); return; } @@ -201,26 +306,34 @@ export function formatContributionsByGoal(doc: typeof PDFDocument, data: any) { contrib.transactionId || "N/A", ]); - drawTable(doc, headers, rows, [150, 150, 150]); + drawTable(doc, headers, rows, [150, 150, 150], reportTitle); } -export function formatSavingsComparison(doc: typeof PDFDocument, data: any) { +export function formatSavingsComparison(doc: typeof PDFDocument, data: any, reportTitle: string = "Comparación de Ahorro") { if (!data) { doc.fontSize(12).fillColor(COLORS.TEXT).text("No hay datos disponibles"); return; } - addSectionTitle(doc, "Comparación de Ahorro"); + addSectionTitle(doc, "Comparación de Ahorro", reportTitle); - doc.fontSize(14) - .fillColor(COLORS.PRIMARY) - .font("Helvetica-Bold") - .text(data.goalName || "Sin nombre", DIMENSIONS.MARGIN, doc.y); + doc + .fontSize(14) + .fillColor(COLORS.PRIMARY) + .font("Helvetica-Bold") + .text(data.goalName || "Sin nombre", DIMENSIONS.MARGIN, doc.y); doc.moveDown(1.5); if (!data.deviations || data.deviations.length === 0) { - doc.fontSize(11).fillColor(COLORS.TEXT).text("No hay datos de comparación disponibles", DIMENSIONS.MARGIN, doc.y); + doc + .fontSize(11) + .fillColor(COLORS.TEXT) + .text( + "No hay datos de comparación disponibles", + DIMENSIONS.MARGIN, + doc.y + ); return; } @@ -236,10 +349,10 @@ export function formatSavingsComparison(doc: typeof PDFDocument, data: any) { ]; }); - drawTable(doc, headers, rows, [120, 120, 120, 120]); + drawTable(doc, headers, rows, [120, 120, 120, 120], reportTitle); } -export function formatSavingsSummary(doc: typeof PDFDocument, data: any) { +export function formatSavingsSummary(doc: typeof PDFDocument, data: any, reportTitle: string = "Resumen de Ahorro") { if (!data) { doc.fontSize(12).fillColor(COLORS.TEXT).text("No hay datos disponibles"); return; @@ -251,36 +364,99 @@ export function formatSavingsSummary(doc: typeof PDFDocument, data: any) { const cardWidth = 110; const cardSpacing = 12; - addMetricCard(doc, "Total Metas", data.totalGoals?.toString() || "0", DIMENSIONS.MARGIN, cardY, cardWidth, COLORS.SECONDARY); - addMetricCard(doc, "Completadas", data.completedGoals?.toString() || "0", DIMENSIONS.MARGIN + cardWidth + cardSpacing, cardY, cardWidth, COLORS.ACCENT); - addMetricCard(doc, "En Progreso", data.inProgressGoals?.toString() || "0", DIMENSIONS.MARGIN + 2 * (cardWidth + cardSpacing), cardY, cardWidth, COLORS.WARNING); - addMetricCard(doc, "Expiradas", data.expiredGoals?.toString() || "0", DIMENSIONS.MARGIN + 3 * (cardWidth + cardSpacing), cardY, cardWidth, COLORS.DANGER); + addMetricCard( + doc, + "Total Metas", + data.totalGoals?.toString() || "0", + DIMENSIONS.MARGIN, + cardY, + cardWidth, + COLORS.SECONDARY + ); + addMetricCard( + doc, + "Completadas", + data.completedGoals?.toString() || "0", + DIMENSIONS.MARGIN + cardWidth + cardSpacing, + cardY, + cardWidth, + COLORS.ACCENT + ); + addMetricCard( + doc, + "En Progreso", + data.inProgressGoals?.toString() || "0", + DIMENSIONS.MARGIN + 2 * (cardWidth + cardSpacing), + cardY, + cardWidth, + COLORS.WARNING + ); + addMetricCard( + doc, + "Expiradas", + data.expiredGoals?.toString() || "0", + DIMENSIONS.MARGIN + 3 * (cardWidth + cardSpacing), + cardY, + cardWidth, + COLORS.DANGER + ); doc.y = cardY + 70; doc.moveDown(1); - addSectionTitle(doc, "Métricas Financieras"); + addSectionTitle(doc, "Métricas Financieras", reportTitle); const metricsY = doc.y; - addMetricCard(doc, "Meta Total", formatCurrency(data.totalTargetAmount || 0), DIMENSIONS.MARGIN, metricsY, 150, COLORS.PRIMARY); - addMetricCard(doc, "Ahorrado Total", formatCurrency(data.totalCurrentAmount || 0), DIMENSIONS.MARGIN + 165, metricsY, 150, COLORS.ACCENT); - addMetricCard(doc, "Contribución Promedio", formatCurrency(data.averageContribution || 0), DIMENSIONS.MARGIN + 330, metricsY, 150, COLORS.SECONDARY); + addMetricCard( + doc, + "Meta Total", + formatCurrency(data.totalTargetAmount || 0), + DIMENSIONS.MARGIN, + metricsY, + 150, + COLORS.PRIMARY + ); + addMetricCard( + doc, + "Ahorrado Total", + formatCurrency(data.totalCurrentAmount || 0), + DIMENSIONS.MARGIN + 165, + metricsY, + 150, + COLORS.ACCENT + ); + addMetricCard( + doc, + "Contribución Promedio", + formatCurrency(data.averageContribution || 0), + DIMENSIONS.MARGIN + 330, + metricsY, + 150, + COLORS.SECONDARY + ); doc.y = metricsY + 70; doc.moveDown(1); - doc.fontSize(11) - .fillColor(COLORS.TEXT) - .font("Helvetica") - .text("Progreso General:", DIMENSIONS.MARGIN, doc.y); - + doc + .fontSize(11) + .fillColor(COLORS.TEXT) + .font("Helvetica") + .text("Progreso General:", DIMENSIONS.MARGIN, doc.y); + doc.moveDown(0.5); - drawProgressBar(doc, DIMENSIONS.MARGIN, doc.y, 450, data.overallProgress || 0); + drawProgressBar( + doc, + DIMENSIONS.MARGIN, + doc.y, + 450, + data.overallProgress || 0 + ); doc.moveDown(1.5); if (data.categoryBreakdown && data.categoryBreakdown.length > 0) { - addSectionTitle(doc, "Desglose por Categoría"); + addSectionTitle(doc, "Desglose por Categoría", reportTitle); const headers = ["Categoría", "Metas", "Monto Total", "Progreso"]; const rows = data.categoryBreakdown.map((cat: any) => [ @@ -290,6 +466,6 @@ export function formatSavingsSummary(doc: typeof PDFDocument, data: any) { `${(cat.progress || 0).toFixed(1)}%`, ]); - drawTable(doc, headers, rows, [180, 80, 120, 100]); + drawTable(doc, headers, rows, [160, 80, 120, 100], reportTitle); } } diff --git a/src/features/reports/infrastructure/services/pdf/pdf-layout.ts b/src/features/reports/infrastructure/services/pdf/pdf-layout.ts index 57ce513..07e5284 100644 --- a/src/features/reports/infrastructure/services/pdf/pdf-layout.ts +++ b/src/features/reports/infrastructure/services/pdf/pdf-layout.ts @@ -2,34 +2,39 @@ import PDFDocument from "pdfkit"; import { COLORS, DIMENSIONS } from "./pdf-utils"; export function addModernHeader(doc: typeof PDFDocument, reportTitle: string) { - doc.rect(0, 0, DIMENSIONS.PAGE_WIDTH, DIMENSIONS.HEADER_HEIGHT) - .fillColor(COLORS.PRIMARY) - .fill(); + doc + .rect(0, 0, DIMENSIONS.PAGE_WIDTH, DIMENSIONS.HEADER_HEIGHT) + .fillColor(COLORS.PRIMARY) + .fill(); - doc.fontSize(24) - .fillColor(COLORS.WHITE) - .font("Helvetica-Bold") - .text("FoppyAI", DIMENSIONS.MARGIN, 20, { align: "left" }); + doc + .fontSize(24) + .fillColor(COLORS.WHITE) + .font("Helvetica-Bold") + .text("FoppyAI", DIMENSIONS.MARGIN, 20, { align: "left" }); - doc.fontSize(10) - .fillColor(COLORS.WHITE) - .font("Helvetica") - .text("Sistema de Gestión Financiera Personal", DIMENSIONS.MARGIN, 48); + doc + .fontSize(10) + .fillColor(COLORS.WHITE) + .font("Helvetica") + .text("Sistema de Gestión Financiera Personal", DIMENSIONS.MARGIN, 48); const date = new Date().toLocaleDateString("es-ES", { day: "2-digit", month: "long", year: "numeric", }); - doc.fontSize(10) - .text(date, DIMENSIONS.PAGE_WIDTH - DIMENSIONS.MARGIN - 150, 25, { - width: 150, - align: "right" - }); + doc + .fontSize(10) + .text(date, DIMENSIONS.PAGE_WIDTH - DIMENSIONS.MARGIN - 150, 25, { + width: 150, + align: "right", + }); - doc.rect(0, DIMENSIONS.HEADER_HEIGHT - 5, DIMENSIONS.PAGE_WIDTH, 5) - .fillColor(COLORS.SECONDARY) - .fill(); + doc + .rect(0, DIMENSIONS.HEADER_HEIGHT - 5, DIMENSIONS.PAGE_WIDTH, 5) + .fillColor(COLORS.SECONDARY) + .fill(); doc.y = DIMENSIONS.HEADER_HEIGHT + 20; } @@ -41,37 +46,40 @@ export function addModernFooter( ) { const footerY = DIMENSIONS.PAGE_HEIGHT - DIMENSIONS.FOOTER_HEIGHT; - doc.rect(0, footerY, DIMENSIONS.PAGE_WIDTH, 2) - .fillColor(COLORS.MEDIUM_GRAY) - .fill(); + doc + .rect(0, footerY, DIMENSIONS.PAGE_WIDTH, 2) + .fillColor(COLORS.MEDIUM_GRAY) + .fill(); - doc.fontSize(8) - .fillColor(COLORS.TEXT) - .font("Helvetica") - .text( - `© ${new Date().getFullYear()} FoppyAI. Todos los derechos reservados.`, - DIMENSIONS.MARGIN, - footerY + 15, - { align: "left" } - ); - - doc.fontSize(8) - .text( - `Página ${pageNum} de ${totalPages}`, - DIMENSIONS.PAGE_WIDTH - DIMENSIONS.MARGIN - 100, - footerY + 15, - { width: 100, align: "right" } - ); + doc + .fontSize(8) + .fillColor(COLORS.TEXT) + .font("Helvetica") + .text( + `© ${new Date().getFullYear()} FoppyAI. Todos los derechos reservados.`, + DIMENSIONS.MARGIN, + footerY + 15, + { + width: DIMENSIONS.PAGE_WIDTH - DIMENSIONS.MARGIN * 2, + align: "center" + } + ); } export function addReportTitle(doc: typeof PDFDocument, title: string) { - doc.fontSize(20) - .fillColor(COLORS.PRIMARY) - .font("Helvetica-Bold") - .text(title, DIMENSIONS.MARGIN, doc.y, { align: "left" }); + doc + .fontSize(20) + .fillColor(COLORS.PRIMARY) + .font("Helvetica-Bold") + .text(title, DIMENSIONS.MARGIN, doc.y, { align: "left" }); } -export function addNewPage(doc: typeof PDFDocument) { +export function addNewPage(doc: typeof PDFDocument, reportTitle?: string) { doc.addPage(); - doc.y = DIMENSIONS.HEADER_HEIGHT + 20; + + if (reportTitle) { + addModernHeader(doc, reportTitle); + } else { + doc.y = DIMENSIONS.HEADER_HEIGHT + 20; + } } diff --git a/src/features/reports/infrastructure/services/pdf/pdf-overview-formatters.ts b/src/features/reports/infrastructure/services/pdf/pdf-overview-formatters.ts index ba6864e..7f81b64 100644 --- a/src/features/reports/infrastructure/services/pdf/pdf-overview-formatters.ts +++ b/src/features/reports/infrastructure/services/pdf/pdf-overview-formatters.ts @@ -1,243 +1,272 @@ import PDFDocument from "pdfkit"; import { FinancialOverviewReport } from "../../../domain/entities/report.entity"; -import { addMetricCard, drawProgressBar } from "./pdf-components"; +import { + addSectionTitle, + addMetricCard, + drawProgressBar, +} from "./pdf-components"; import { drawTable } from "./pdf-table"; import { drawBarChart, drawPieChart } from "./pdf-charts"; -import { formatCurrency, formatDate } from "./pdf-utils"; +import { + formatCurrency, + formatDate, + COLORS, + DIMENSIONS, + checkPageBreak, +} from "./pdf-utils"; import { addNewPage } from "./pdf-layout"; export function formatFinancialOverview( doc: typeof PDFDocument.prototype, - data: FinancialOverviewReport + data: FinancialOverviewReport, + reportTitle: string = "Vista General Financiera" ): void { - const pageWidth = doc.page.width; - const margin = 50; - const contentWidth = pageWidth - margin * 2; - const cardWidth = (contentWidth - 30) / 4; + if (!data) { + doc.fontSize(12).fillColor(COLORS.TEXT).text("No hay datos disponibles"); + return; + } + + addSectionTitle(doc, "Resumen Financiero General", reportTitle); doc - .fontSize(12) - .fillColor("#6b7280") + .fontSize(10) + .fillColor(COLORS.TEXT) .text( - `Period: ${formatDate(data.period.startDate)} - ${formatDate( - data.period.endDate + `Período: ${formatDate(data.period?.startDate)} - ${formatDate( + data.period?.endDate )}`, - margin, + DIMENSIONS.MARGIN, doc.y ); doc.moveDown(1); - doc - .fontSize(14) - .fillColor("#1f2937") - .text("Financial Summary", margin, doc.y); - doc.moveDown(0.5); + const cardY = doc.y; + const cardWidth = 110; + const cardSpacing = 15; addMetricCard( doc, - "Total Income", - formatCurrency(data.summary.totalIncome), - margin, - doc.y, + "Total Ingresos", + formatCurrency(data.summary?.totalIncome || 0), + DIMENSIONS.MARGIN, + cardY, cardWidth, - "#10b981" + COLORS.ACCENT ); addMetricCard( doc, - "Total Expense", - formatCurrency(data.summary.totalExpense), - margin + cardWidth + 10, - doc.y - 80, + "Total Gastos", + formatCurrency(data.summary?.totalExpense || 0), + DIMENSIONS.MARGIN + cardWidth + cardSpacing, + cardY, cardWidth, - "#ef4444" + COLORS.DANGER ); addMetricCard( doc, - "Net Balance", - formatCurrency(data.summary.netBalance), - margin + (cardWidth + 10) * 2, - doc.y - 80, + "Balance Neto", + formatCurrency(data.summary?.netBalance || 0), + DIMENSIONS.MARGIN + 2 * (cardWidth + cardSpacing), + cardY, cardWidth, - "#3b82f6" + COLORS.SECONDARY ); addMetricCard( doc, - "Savings Rate", - `${data.summary.savingsRate.toFixed(1)}%`, - margin + (cardWidth + 10) * 3, - doc.y - 80, + "Tasa de Ahorro", + `${(data.summary?.savingsRate || 0).toFixed(1)}%`, + DIMENSIONS.MARGIN + 3 * (cardWidth + cardSpacing), + cardY, cardWidth, - "#8b5cf6" + COLORS.PRIMARY ); + doc.y = cardY + 70; doc.moveDown(1); - if (data.summary.totalIncome > 0 || data.summary.totalExpense > 0) { - doc - .fontSize(14) - .fillColor("#1f2937") - .text("Income vs Expense", margin, doc.y); - doc.moveDown(0.5); + if ( + (data.summary?.totalIncome || 0) > 0 || + (data.summary?.totalExpense || 0) > 0 + ) { + addSectionTitle(doc, "Ingresos vs Gastos", reportTitle); const chartData = [ - { label: "Income", value: data.summary.totalIncome, color: "#10b981" }, - { label: "Expense", value: data.summary.totalExpense, color: "#ef4444" }, + { + label: "Ingresos", + value: data.summary?.totalIncome || 0, + color: COLORS.ACCENT, + }, + { + label: "Gastos", + value: data.summary?.totalExpense || 0, + color: COLORS.DANGER, + }, ]; - drawBarChart(doc, chartData, "Income vs Expense", contentWidth, 150); + drawBarChart(doc, chartData, "Ingresos vs Gastos", 450, 150); doc.moveDown(2); } - if (doc.y > 600) addNewPage(doc); + if (checkPageBreak(doc, 200)) { + addNewPage(doc, reportTitle); + } + + addSectionTitle(doc, "Resumen de Metas", reportTitle); - doc.fontSize(14).fillColor("#1f2937").text("Goals Overview", margin, doc.y); - doc.moveDown(0.5); + const goalCardY = doc.y; + const goalCardWidth = 120; + const goalCardSpacing = 15; - const goalCardWidth = (contentWidth - 20) / 3; addMetricCard( doc, - "Total Goals", - `${data.goals.total}`, - margin, - doc.y, + "Total Metas", + (data.goals?.total || 0).toString(), + DIMENSIONS.MARGIN, + goalCardY, goalCardWidth, - "#3b82f6" + COLORS.SECONDARY ); addMetricCard( doc, - "Completed", - `${data.goals.completed}`, - margin + goalCardWidth + 10, - doc.y - 70, + "Completadas", + (data.goals?.completed || 0).toString(), + DIMENSIONS.MARGIN + goalCardWidth + goalCardSpacing, + goalCardY, goalCardWidth, - "#10b981" + COLORS.ACCENT ); addMetricCard( doc, - "In Progress", - `${data.goals.inProgress}`, - margin + (goalCardWidth + 10) * 2, - doc.y - 70, + "En Progreso", + (data.goals?.inProgress || 0).toString(), + DIMENSIONS.MARGIN + 2 * (goalCardWidth + goalCardSpacing), + goalCardY, goalCardWidth, - "#f59e0b" + COLORS.WARNING ); + doc.y = goalCardY + 70; doc.moveDown(0.8); doc .fontSize(10) - .fillColor("#6b7280") + .fillColor(COLORS.TEXT) .text( - `Saved: ${formatCurrency( - data.goals.totalSaved - )} / Target: ${formatCurrency(data.goals.totalTarget)}`, - margin, + `Ahorrado: ${formatCurrency( + data.goals?.totalSaved || 0 + )} / Meta: ${formatCurrency(data.goals?.totalTarget || 0)}`, + DIMENSIONS.MARGIN, doc.y ); doc.moveDown(0.5); - drawProgressBar(doc, margin, doc.y, contentWidth, data.goals.overallProgress); + drawProgressBar( + doc, + DIMENSIONS.MARGIN, + doc.y, + 450, + data.goals?.overallProgress || 0 + ); doc.moveDown(1); - doc.fontSize(14).fillColor("#1f2937").text("Budgets Overview", margin, doc.y); - doc.moveDown(0.5); + addSectionTitle(doc, "Resumen de Presupuestos", reportTitle); + + const budgetCardY = doc.y; + const budgetCardWidth = 120; + const budgetCardSpacing = 15; - const budgetCardWidth = (contentWidth - 20) / 3; addMetricCard( doc, - "Total Budgets", - `${data.budgets.total}`, - margin, - doc.y, + "Total Presupuestos", + (data.budgets?.total || 0).toString(), + DIMENSIONS.MARGIN, + budgetCardY, budgetCardWidth, - "#3b82f6" + COLORS.SECONDARY ); addMetricCard( doc, - "Exceeded", - `${data.budgets.exceeded}`, - margin + budgetCardWidth + 10, - doc.y - 70, + "Excedidos", + (data.budgets?.exceeded || 0).toString(), + DIMENSIONS.MARGIN + budgetCardWidth + budgetCardSpacing, + budgetCardY, budgetCardWidth, - "#ef4444" + COLORS.DANGER ); addMetricCard( doc, - "Avg Usage", - `${data.budgets.averageUtilization.toFixed(1)}%`, - margin + (budgetCardWidth + 10) * 2, - doc.y - 70, + "Uso Promedio", + `${(data.budgets?.averageUtilization || 0).toFixed(1)}%`, + DIMENSIONS.MARGIN + 2 * (budgetCardWidth + budgetCardSpacing), + budgetCardY, budgetCardWidth, - "#f59e0b" + COLORS.WARNING ); + doc.y = budgetCardY + 70; doc.moveDown(1); - if (doc.y > 600) addNewPage(doc); + if (checkPageBreak(doc, 200)) { + addNewPage(doc, reportTitle); + } - if (data.topCategories.expenses.length > 0) { - doc - .fontSize(14) - .fillColor("#1f2937") - .text("Top Expense Categories", margin, doc.y); - doc.moveDown(0.5); + if (data.topCategories?.expenses && data.topCategories.expenses.length > 0) { + addSectionTitle(doc, "Categorías de Gastos Principales", reportTitle); const expensePieData = data.topCategories.expenses.map((cat) => ({ - label: cat.name, - value: cat.amount, - percentage: cat.percentage, + label: cat.name || "Sin nombre", + value: cat.amount || 0, + percentage: cat.percentage || 0, })); - drawPieChart(doc, expensePieData, "Top Expense Categories", 180); + drawPieChart(doc, expensePieData, "Categorías de Gastos Principales", 180); doc.moveDown(2); } - if (doc.y > 600) addNewPage(doc); + if (checkPageBreak(doc, 200)) { + addNewPage(doc, reportTitle); + } - if (data.topCategories.expenses.length > 0) { - doc - .fontSize(14) - .fillColor("#1f2937") - .text("Top Expenses Breakdown", margin, doc.y); - doc.moveDown(0.5); + if (data.topCategories?.expenses && data.topCategories.expenses.length > 0) { + addSectionTitle(doc, "Desglose de Gastos Principales", reportTitle); const expenseData = data.topCategories.expenses.map((cat) => [ - cat.name, - formatCurrency(cat.amount), - `${cat.percentage.toFixed(1)}%`, + cat.name || "Sin nombre", + formatCurrency(cat.amount || 0), + `${(cat.percentage || 0).toFixed(1)}%`, ]); const columnWidths = [150, 120, 100]; drawTable( doc, - ["Category", "Amount", "Percentage"], + ["Categoría", "Monto", "Porcentaje"], expenseData, - columnWidths + columnWidths, + reportTitle ); doc.moveDown(1); } - if (data.topCategories.income.length > 0) { - if (doc.y > 600) addNewPage(doc); + if (data.topCategories?.income && data.topCategories.income.length > 0) { + if (checkPageBreak(doc, 200)) { + addNewPage(doc, reportTitle); + } - doc - .fontSize(14) - .fillColor("#1f2937") - .text("Top Income Breakdown", margin, doc.y); - doc.moveDown(0.5); + addSectionTitle(doc, "Desglose de Ingresos Principales", reportTitle); const incomeData = data.topCategories.income.map((cat) => [ - cat.name, - formatCurrency(cat.amount), - `${cat.percentage.toFixed(1)}%`, + cat.name || "Sin nombre", + formatCurrency(cat.amount || 0), + `${(cat.percentage || 0).toFixed(1)}%`, ]); const columnWidths = [150, 120, 100]; drawTable( doc, - ["Category", "Amount", "Percentage"], + ["Categoría", "Monto", "Porcentaje"], incomeData, - columnWidths + columnWidths, + reportTitle ); } } diff --git a/src/features/reports/infrastructure/services/pdf/pdf-table.ts b/src/features/reports/infrastructure/services/pdf/pdf-table.ts index 00db9f8..e993f96 100644 --- a/src/features/reports/infrastructure/services/pdf/pdf-table.ts +++ b/src/features/reports/infrastructure/services/pdf/pdf-table.ts @@ -6,14 +6,20 @@ export function drawTable( doc: typeof PDFDocument, headers: string[], rows: string[][], - columnWidths: number[] + columnWidths: number[], + reportTitle?: string ) { - const startX = DIMENSIONS.MARGIN; + const maxWidth = DIMENSIONS.PAGE_WIDTH - DIMENSIONS.MARGIN * 2; const totalWidth = columnWidths.reduce((a, b) => a + b, 0); + + const startX = totalWidth < maxWidth + ? DIMENSIONS.MARGIN + (maxWidth - totalWidth) / 2 + : DIMENSIONS.MARGIN; + const tableHeight = TABLE.HEADER_HEIGHT + rows.length * TABLE.ROW_HEIGHT; if (checkPageBreak(doc, tableHeight)) { - addNewPage(doc); + addNewPage(doc, reportTitle); } let currentY = doc.y; @@ -33,7 +39,7 @@ export function drawTable( currentY + TABLE.CELL_PADDING, { width: columnWidths[i] - 2 * TABLE.CELL_PADDING, - align: "left", + align: "center", } ); x += columnWidths[i]; @@ -54,6 +60,9 @@ export function drawTable( x = startX; row.forEach((cell, colIndex) => { + const isNumeric = /^[\$\d,.-]+%?$/.test(cell) || cell === "N/A"; + const align = isNumeric && colIndex > 0 ? "right" : "left"; + doc.fontSize(9) .fillColor(COLORS.TEXT) .font("Helvetica") @@ -63,7 +72,7 @@ export function drawTable( currentY + TABLE.CELL_PADDING, { width: columnWidths[colIndex] - 2 * TABLE.CELL_PADDING, - align: colIndex === 0 ? "left" : "right", + align: align, } ); x += columnWidths[colIndex]; diff --git a/src/features/reports/infrastructure/services/pdf/pdf-transaction-formatters.ts b/src/features/reports/infrastructure/services/pdf/pdf-transaction-formatters.ts index 70b48b2..afbf682 100644 --- a/src/features/reports/infrastructure/services/pdf/pdf-transaction-formatters.ts +++ b/src/features/reports/infrastructure/services/pdf/pdf-transaction-formatters.ts @@ -12,114 +12,119 @@ import { addNewPage } from "./pdf-layout"; export function formatTransactionsSummary( doc: typeof PDFDocument.prototype, - data: TransactionsSummaryReport + data: TransactionsSummaryReport, + reportTitle: string = "Resumen de Transacciones" ): void { const pageWidth = doc.page.width; const margin = 50; const contentWidth = pageWidth - margin * 2; const cardWidth = (contentWidth - 20) / 2; + const row1Y = doc.y; addMetricCard( doc, - "Total Income", + "Total Ingresos", formatCurrency(data.totalIncome), margin, - doc.y, + row1Y, cardWidth, "#10b981" ); addMetricCard( doc, - "Total Expense", + "Total Gastos", formatCurrency(data.totalExpense), margin + cardWidth + 20, - doc.y - 80, + row1Y, cardWidth, "#ef4444" ); - doc.moveDown(1); + doc.y = row1Y + 80; + + const row2Y = doc.y; addMetricCard( doc, - "Net Balance", + "Balance Neto", formatCurrency(data.netBalance), margin, - doc.y, + row2Y, cardWidth, "#3b82f6" ); addMetricCard( doc, - "Transactions", + "Transacciones", `${data.transactionCount}`, margin + cardWidth + 20, - doc.y - 80, + row2Y, cardWidth, "#8b5cf6" ); - doc.moveDown(1); + doc.y = row2Y + 80; if (data.totalIncome > 0 || data.totalExpense > 0) { doc .fontSize(14) .fillColor("#1f2937") - .text("Income vs Expense", margin, doc.y); - doc.moveDown(0.5); + .text("Ingresos vs Gastos", margin, doc.y); + doc.moveDown(1); const chartData = [ - { label: "Income", value: data.totalIncome, color: "#10b981" }, - { label: "Expense", value: data.totalExpense, color: "#ef4444" }, + { label: "Ingresos", value: data.totalIncome, color: "#10b981" }, + { label: "Gastos", value: data.totalExpense, color: "#ef4444" }, ]; - drawBarChart(doc, chartData, "Income vs Expense", contentWidth, 150); + drawBarChart(doc, chartData, "Ingresos vs Gastos", contentWidth, 150); doc.moveDown(2); } if (data.topIncomeCategory || data.topExpenseCategory) { - doc.fontSize(14).fillColor("#1f2937").text("Top Categories", margin, doc.y); - doc.moveDown(0.5); + doc.fontSize(14).fillColor("#1f2937").text("Categorías Principales", margin, doc.y); + doc.moveDown(1); const topCategoriesData = []; if (data.topIncomeCategory) { topCategoriesData.push([ - "Top Income", + "Mayor Ingreso", data.topIncomeCategory.name, formatCurrency(data.topIncomeCategory.amount), ]); } if (data.topExpenseCategory) { topCategoriesData.push([ - "Top Expense", + "Mayor Gasto", data.topExpenseCategory.name, formatCurrency(data.topExpenseCategory.amount), ]); } - const columnWidths = [100, 200, 150]; + const columnWidths = [120, 200, 150]; drawTable( doc, - ["Type", "Category", "Amount"], + ["Tipo", "Categoría", "Monto"], topCategoriesData, - columnWidths + columnWidths, + reportTitle ); doc.moveDown(1); } if (data.transactions.length > 0) { - if (doc.y > 600) addNewPage(doc); + if (doc.y > 600) addNewPage(doc, reportTitle); doc .fontSize(14) .fillColor("#1f2937") - .text("Transaction Details", margin, doc.y); - doc.moveDown(0.5); + .text("Detalle de Transacciones", margin, doc.y); + doc.moveDown(1); const transactionsData = data.transactions .slice(0, 20) .map((t) => [ formatDate(t.date), - t.type, + t.type === "INCOME" ? "Ingreso" : "Gasto", t.category || "N/A", formatCurrency(t.amount), ]); @@ -127,49 +132,52 @@ export function formatTransactionsSummary( const columnWidths = [100, 80, 150, 120]; drawTable( doc, - ["Date", "Type", "Category", "Amount"], + ["Fecha", "Tipo", "Categoría", "Monto"], transactionsData, - columnWidths + columnWidths, + reportTitle ); } } export function formatExpensesByCategory( doc: typeof PDFDocument.prototype, - data: ExpensesByCategoryReport + data: ExpensesByCategoryReport, + reportTitle: string = "Gastos por Categoría" ): void { const pageWidth = doc.page.width; const margin = 50; const contentWidth = pageWidth - margin * 2; const cardWidth = (contentWidth - 20) / 2; + const rowY = doc.y; addMetricCard( doc, - "Total Expenses", + "Total Gastos", formatCurrency(data.totalExpenses), margin, - doc.y, + rowY, cardWidth, "#ef4444" ); addMetricCard( doc, - "Categories", + "Categorías", `${data.categoryCount}`, margin + cardWidth + 20, - doc.y - 80, + rowY, cardWidth, "#3b82f6" ); - doc.moveDown(1); + doc.y = rowY + 80; if (data.categories.length > 0) { doc .fontSize(14) .fillColor("#1f2937") - .text("Expense Distribution", margin, doc.y); - doc.moveDown(0.5); + .text("Distribución de Gastos", margin, doc.y); + doc.moveDown(1); const pieData = data.categories.slice(0, 10).map((cat) => ({ label: cat.name, @@ -177,16 +185,16 @@ export function formatExpensesByCategory( percentage: cat.percentage, })); - drawPieChart(doc, pieData, "Expense Distribution", 200); + drawPieChart(doc, pieData, "Distribución de Gastos", 200); doc.moveDown(2); - if (doc.y > 600) addNewPage(doc); + if (doc.y > 600) addNewPage(doc, reportTitle); doc .fontSize(14) .fillColor("#1f2937") - .text("Category Breakdown", margin, doc.y); - doc.moveDown(0.5); + .text("Desglose por Categoría", margin, doc.y); + doc.moveDown(1); const categoryData = data.categories.map((cat) => [ cat.name, @@ -198,91 +206,86 @@ export function formatExpensesByCategory( const columnWidths = [150, 120, 100, 100]; drawTable( doc, - ["Category", "Amount", "Percentage", "Transactions"], + ["Categoría", "Monto", "Porcentaje", "Transacciones"], categoryData, - columnWidths + columnWidths, + reportTitle ); } } export function formatMonthlyTrend( doc: typeof PDFDocument.prototype, - data: MonthlyTrendReport + data: MonthlyTrendReport, + reportTitle: string = "Tendencia Mensual" ): void { const pageWidth = doc.page.width; const margin = 50; const contentWidth = pageWidth - margin * 2; const cardWidth = (contentWidth - 30) / 3; + const rowY = doc.y; addMetricCard( doc, - "Avg Monthly Income", + "Ingreso Mensual Promedio", formatCurrency(data.averageMonthlyIncome), margin, - doc.y, + rowY, cardWidth, "#10b981" ); addMetricCard( doc, - "Avg Monthly Expense", + "Gasto Mensual Promedio", formatCurrency(data.averageMonthlyExpense), margin + cardWidth + 15, - doc.y - 80, + rowY, cardWidth, "#ef4444" ); addMetricCard( doc, - "Trend", - data.trend.toUpperCase(), + "Tendencia", + data.trend === "increasing" ? "CRECIENTE" : data.trend === "decreasing" ? "DECRECIENTE" : "ESTABLE", margin + (cardWidth + 15) * 2, - doc.y - 80, + rowY, cardWidth, "#3b82f6" ); - doc.moveDown(1); + doc.y = rowY + 80; if (data.months.length > 0) { doc .fontSize(14) .fillColor("#1f2937") - .text("Monthly Financial Trend", margin, doc.y); - doc.moveDown(0.5); - - const lineData = data.months.map((m) => ({ - label: m.month, - income: m.income, - expense: m.expense, - balance: m.balance, - })); + .text("Tendencia Financiera Mensual", margin, doc.y); + doc.moveDown(1); - // Convert lineData to the expected format const formattedLineData = data.months.map((m) => ({ label: m.month, values: [ - { name: "Income", value: m.income, color: "#10b981" }, - { name: "Expense", value: m.expense, color: "#ef4444" }, + { name: "Ingresos", value: m.income, color: "#10b981" }, + { name: "Gastos", value: m.expense, color: "#ef4444" }, { name: "Balance", value: m.balance, color: "#3b82f6" }, ], })); drawLineChart( doc, formattedLineData, - "Monthly Financial Trend", + "Tendencia Financiera Mensual", contentWidth, 180 ); doc.moveDown(2); - if (doc.y > 600) addNewPage(doc); + if (doc.y > 600) addNewPage(doc, reportTitle); doc .fontSize(14) .fillColor("#1f2937") - .text("Monthly Details", margin, doc.y); - doc.moveDown(0.5); + .text("Detalle Mensual", margin, doc.y); + doc.moveDown(1); const monthData = data.months.map((m) => [ m.month, @@ -295,9 +298,10 @@ export function formatMonthlyTrend( const columnWidths = [100, 120, 120, 120, 100]; drawTable( doc, - ["Month", "Income", "Expense", "Balance", "Transactions"], + ["Mes", "Ingresos", "Gastos", "Balance", "Transacciones"], monthData, - columnWidths + columnWidths, + reportTitle ); } } From 1adeb9330be738c6b1c2bec1415d686ea35329f3 Mon Sep 17 00:00:00 2001 From: devjaes Date: Sun, 12 Oct 2025 02:02:23 -0500 Subject: [PATCH 4/4] refactor: update debt and user repository methods for consistency and clarity - Refactored debt and user repository methods to improve code readability and maintainability. - Standardized naming conventions for properties, changing `registrationDate` to `registration_date` across relevant interfaces and implementations. - Enhanced the mapping functions to ensure consistent data transformation from database records to entity models. - Updated method signatures to reflect the new property names, ensuring compatibility with existing functionality. --- .../adapters/debt.repository.ts | 482 +++++++++--------- src/features/users/domain/entities/IUser.ts | 4 +- .../domain/ports/user-repository.port.ts | 20 +- .../adapters/user-api.adapter.ts | 36 +- .../adapters/user.repository.ts | 282 +++++----- 5 files changed, 415 insertions(+), 409 deletions(-) diff --git a/src/features/debts/infrastructure/adapters/debt.repository.ts b/src/features/debts/infrastructure/adapters/debt.repository.ts index ff17b6d..1530cab 100644 --- a/src/features/debts/infrastructure/adapters/debt.repository.ts +++ b/src/features/debts/infrastructure/adapters/debt.repository.ts @@ -5,242 +5,248 @@ import { IDebt } from "@/debts/domain/entities/IDebt"; import { IDebtRepository } from "@/debts/domain/ports/debt-repository.port"; export class PgDebtRepository implements IDebtRepository { - private db = DatabaseConnection.getInstance().db; - private static instance: PgDebtRepository; - - private constructor() {} - - public static getInstance(): PgDebtRepository { - if (!PgDebtRepository.instance) { - PgDebtRepository.instance = new PgDebtRepository(); - } - return PgDebtRepository.instance; - } - - async findAll(): Promise { - const result = await this.db - .select({ - id: debts.id, - user_id: debts.user_id, - description: debts.description, - original_amount: debts.original_amount, - pending_amount: debts.pending_amount, - due_date: debts.due_date, - paid: debts.paid, - creditor_id: debts.creditor_id, - category_id: debts.category_id, - category: { - id: categories.id, - name: categories.name, - description: categories.description, - }, - }) - .from(debts) - .leftJoin(categories, eq(debts.category_id, categories.id)); - return result.map(this.mapToEntity); - } - - async findById(id: number): Promise { - const result = await this.db - .select({ - id: debts.id, - user_id: debts.user_id, - description: debts.description, - original_amount: debts.original_amount, - pending_amount: debts.pending_amount, - due_date: debts.due_date, - paid: debts.paid, - creditor_id: debts.creditor_id, - category_id: debts.category_id, - category: { - id: categories.id, - name: categories.name, - description: categories.description, - }, - }) - .from(debts) - .leftJoin(categories, eq(debts.category_id, categories.id)) - .where(eq(debts.id, id)); - - return result[0] ? this.mapToEntity(result[0]) : null; - } - - async findByUserId(userId: number): Promise { - const result = await this.db - .select({ - id: debts.id, - user_id: debts.user_id, - description: debts.description, - original_amount: debts.original_amount, - pending_amount: debts.pending_amount, - due_date: debts.due_date, - paid: debts.paid, - creditor_id: debts.creditor_id, - category_id: debts.category_id, - category: { - id: categories.id, - name: categories.name, - description: categories.description, - }, - }) - .from(debts) - .leftJoin(categories, eq(debts.category_id, categories.id)) - .where(eq(debts.user_id, userId)); - - return result.map(this.mapToEntity); - } - - async findByCreditorId(creditorId: number): Promise { - const result = await this.db - .select({ - id: debts.id, - user_id: debts.user_id, - description: debts.description, - original_amount: debts.original_amount, - pending_amount: debts.pending_amount, - due_date: debts.due_date, - paid: debts.paid, - creditor_id: debts.creditor_id, - category_id: debts.category_id, - category: { - id: categories.id, - name: categories.name, - description: categories.description, - }, - }) - .from(debts) - .leftJoin(categories, eq(debts.category_id, categories.id)) - .where(eq(debts.creditor_id, creditorId)); - - return result.map(this.mapToEntity); - } - - async findByStatus(paid: boolean): Promise { - const result = await this.db - .select({ - id: debts.id, - user_id: debts.user_id, - description: debts.description, - original_amount: debts.original_amount, - pending_amount: debts.pending_amount, - due_date: debts.due_date, - paid: debts.paid, - creditor_id: debts.creditor_id, - category_id: debts.category_id, - category: { - id: categories.id, - name: categories.name, - description: categories.description, - }, - }) - .from(debts) - .leftJoin(categories, eq(debts.category_id, categories.id)) - .where(eq(debts.paid, paid)); - - return result.map(this.mapToEntity); - } - - async create(debtData: Omit): Promise { - - let debtCategoryId = debtData.categoryId || null; - - if (!debtCategoryId) { - const category = await this.db.select().from(categories).where(eq(categories.name, "Otros")).limit(1); - debtCategoryId = category[0].id; - } - - const result = await this.db - .insert(debts) - .values({ - user_id: debtData.userId, - description: debtData.description, - original_amount: debtData.originalAmount.toString(), - pending_amount: debtData.pendingAmount.toString(), - due_date: debtData.dueDate, - paid: debtData.paid, - creditor_id: debtData.creditorId || null, - category_id: debtCategoryId, - }) - .returning(); - - return this.mapToEntity(result[0]); - } - - async update(id: number, debtData: Partial): Promise { - const updateData: Record = {}; - - if (debtData.description !== undefined) - updateData.description = debtData.description; - if (debtData.pendingAmount !== undefined) - updateData.pending_amount = debtData.pendingAmount.toString(); - if (debtData.dueDate !== undefined) - updateData.due_date = debtData.dueDate; - if (debtData.paid !== undefined) updateData.paid = debtData.paid; - - const result = await this.db - .update(debts) - .set(updateData) - .where(eq(debts.id, id)) - .returning(); - - return this.mapToEntity(result[0]); - } - - async delete(id: number): Promise { - const result = await this.db - .delete(debts) - .where(eq(debts.id, id)) - .returning(); - - return result.length > 0; - } - - async updatePendingAmount(id: number, amount: number): Promise { - const debt = await this.findById(id); - if (!debt) throw new Error("Debt not found"); - - const newAmount = debt.pendingAmount - amount; - const paid = newAmount <= 0; - - const result = await this.db - .update(debts) - .set({ - pending_amount: newAmount.toString(), - paid, - }) - .where(eq(debts.id, id)) - .returning(); - - return this.mapToEntity(result[0]); - } - - private mapToEntity(raw: any): IDebt { - return { - id: raw.id, - userId: raw.user_id, - description: raw.description, - originalAmount: Number(raw.original_amount), - pendingAmount: Number(raw.pending_amount), - dueDate: raw.due_date, - paid: raw.paid, - creditorId: raw.creditor_id, - categoryId: raw.category_id, - category: raw.category ? { - id: raw.category.id, - name: raw.category.name, - description: raw.category.description, - } : null, - creditor: raw.creditor ? { - id: raw.creditor.id, - name: raw.creditor.name, - email: raw.creditor.email, - username: raw.creditor.username, - passwordHash: raw.creditor.password_hash, - registrationDate: raw.creditor.registration_date, - active: raw.creditor.active, - } : null, - createdAt: raw.created_at, - updatedAt: raw.updated_at, - }; - } + private db = DatabaseConnection.getInstance().db; + private static instance: PgDebtRepository; + + private constructor() {} + + public static getInstance(): PgDebtRepository { + if (!PgDebtRepository.instance) { + PgDebtRepository.instance = new PgDebtRepository(); + } + return PgDebtRepository.instance; + } + + async findAll(): Promise { + const result = await this.db + .select({ + id: debts.id, + user_id: debts.user_id, + description: debts.description, + original_amount: debts.original_amount, + pending_amount: debts.pending_amount, + due_date: debts.due_date, + paid: debts.paid, + creditor_id: debts.creditor_id, + category_id: debts.category_id, + category: { + id: categories.id, + name: categories.name, + description: categories.description, + }, + }) + .from(debts) + .leftJoin(categories, eq(debts.category_id, categories.id)); + return result.map(this.mapToEntity); + } + + async findById(id: number): Promise { + const result = await this.db + .select({ + id: debts.id, + user_id: debts.user_id, + description: debts.description, + original_amount: debts.original_amount, + pending_amount: debts.pending_amount, + due_date: debts.due_date, + paid: debts.paid, + creditor_id: debts.creditor_id, + category_id: debts.category_id, + category: { + id: categories.id, + name: categories.name, + description: categories.description, + }, + }) + .from(debts) + .leftJoin(categories, eq(debts.category_id, categories.id)) + .where(eq(debts.id, id)); + + return result[0] ? this.mapToEntity(result[0]) : null; + } + + async findByUserId(userId: number): Promise { + const result = await this.db + .select({ + id: debts.id, + user_id: debts.user_id, + description: debts.description, + original_amount: debts.original_amount, + pending_amount: debts.pending_amount, + due_date: debts.due_date, + paid: debts.paid, + creditor_id: debts.creditor_id, + category_id: debts.category_id, + category: { + id: categories.id, + name: categories.name, + description: categories.description, + }, + }) + .from(debts) + .leftJoin(categories, eq(debts.category_id, categories.id)) + .where(eq(debts.user_id, userId)); + + return result.map(this.mapToEntity); + } + + async findByCreditorId(creditorId: number): Promise { + const result = await this.db + .select({ + id: debts.id, + user_id: debts.user_id, + description: debts.description, + original_amount: debts.original_amount, + pending_amount: debts.pending_amount, + due_date: debts.due_date, + paid: debts.paid, + creditor_id: debts.creditor_id, + category_id: debts.category_id, + category: { + id: categories.id, + name: categories.name, + description: categories.description, + }, + }) + .from(debts) + .leftJoin(categories, eq(debts.category_id, categories.id)) + .where(eq(debts.creditor_id, creditorId)); + + return result.map(this.mapToEntity); + } + + async findByStatus(paid: boolean): Promise { + const result = await this.db + .select({ + id: debts.id, + user_id: debts.user_id, + description: debts.description, + original_amount: debts.original_amount, + pending_amount: debts.pending_amount, + due_date: debts.due_date, + paid: debts.paid, + creditor_id: debts.creditor_id, + category_id: debts.category_id, + category: { + id: categories.id, + name: categories.name, + description: categories.description, + }, + }) + .from(debts) + .leftJoin(categories, eq(debts.category_id, categories.id)) + .where(eq(debts.paid, paid)); + + return result.map(this.mapToEntity); + } + + async create(debtData: Omit): Promise { + let debtCategoryId = debtData.categoryId || null; + + if (!debtCategoryId) { + const category = await this.db + .select() + .from(categories) + .where(eq(categories.name, "Otros")) + .limit(1); + debtCategoryId = category[0].id; + } + + const result = await this.db + .insert(debts) + .values({ + user_id: debtData.userId, + description: debtData.description, + original_amount: debtData.originalAmount.toString(), + pending_amount: debtData.pendingAmount.toString(), + due_date: debtData.dueDate, + paid: debtData.paid, + creditor_id: debtData.creditorId || null, + category_id: debtCategoryId, + }) + .returning(); + + return this.mapToEntity(result[0]); + } + + async update(id: number, debtData: Partial): Promise { + const updateData: Record = {}; + + if (debtData.description !== undefined) + updateData.description = debtData.description; + if (debtData.pendingAmount !== undefined) + updateData.pending_amount = debtData.pendingAmount.toString(); + if (debtData.dueDate !== undefined) updateData.due_date = debtData.dueDate; + if (debtData.paid !== undefined) updateData.paid = debtData.paid; + + const result = await this.db + .update(debts) + .set(updateData) + .where(eq(debts.id, id)) + .returning(); + + return this.mapToEntity(result[0]); + } + + async delete(id: number): Promise { + const result = await this.db + .delete(debts) + .where(eq(debts.id, id)) + .returning(); + + return result.length > 0; + } + + async updatePendingAmount(id: number, amount: number): Promise { + const debt = await this.findById(id); + if (!debt) throw new Error("Debt not found"); + + const newAmount = debt.pendingAmount - amount; + const paid = newAmount <= 0; + + const result = await this.db + .update(debts) + .set({ + pending_amount: newAmount.toString(), + paid, + }) + .where(eq(debts.id, id)) + .returning(); + + return this.mapToEntity(result[0]); + } + + private mapToEntity(raw: any): IDebt { + return { + id: raw.id, + userId: raw.user_id, + description: raw.description, + originalAmount: Number(raw.original_amount), + pendingAmount: Number(raw.pending_amount), + dueDate: raw.due_date, + paid: raw.paid, + creditorId: raw.creditor_id, + categoryId: raw.category_id, + category: raw.category + ? { + id: raw.category.id, + name: raw.category.name, + description: raw.category.description, + } + : null, + creditor: raw.creditor + ? { + id: raw.creditor.id, + name: raw.creditor.name, + email: raw.creditor.email, + username: raw.creditor.username, + passwordHash: raw.creditor.password_hash, + registration_date: raw.creditor.registration_date, + active: raw.creditor.active, + } + : null, + createdAt: raw.created_at, + updatedAt: raw.updated_at, + }; + } } diff --git a/src/features/users/domain/entities/IUser.ts b/src/features/users/domain/entities/IUser.ts index c5ac2aa..dadd97a 100644 --- a/src/features/users/domain/entities/IUser.ts +++ b/src/features/users/domain/entities/IUser.ts @@ -4,8 +4,8 @@ export interface IUser { username: string; email: string; passwordHash: string; - registrationDate: Date; + registration_date: Date; active: boolean; recoveryToken?: string | null; recoveryTokenExpires?: Date | null; -} \ No newline at end of file +} diff --git a/src/features/users/domain/ports/user-repository.port.ts b/src/features/users/domain/ports/user-repository.port.ts index 5d4d3b6..af7f5ab 100644 --- a/src/features/users/domain/ports/user-repository.port.ts +++ b/src/features/users/domain/ports/user-repository.port.ts @@ -1,14 +1,14 @@ import { IUser } from "../entities/IUser"; export interface IUserRepository { - findAll(): Promise; - findById(id: number): Promise; - findByEmail(email: string): Promise; - findByUsername(IUsername: string): Promise; - findByRecoveryToken(token: string): Promise; - create(IUser: Omit): Promise; - update(id: number, IUser: Partial): Promise; - delete(id: number): Promise; - setRecoveryToken(id: number, token: string, expires: Date): Promise; - clearRecoveryToken(id: number): Promise; + findAll(): Promise; + findById(id: number): Promise; + findByEmail(email: string): Promise; + findByUsername(IUsername: string): Promise; + findByRecoveryToken(token: string): Promise; + create(IUser: Omit): Promise; + update(id: number, IUser: Partial): Promise; + delete(id: number): Promise; + setRecoveryToken(id: number, token: string, expires: Date): Promise; + clearRecoveryToken(id: number): Promise; } diff --git a/src/features/users/infrastructure/adapters/user-api.adapter.ts b/src/features/users/infrastructure/adapters/user-api.adapter.ts index 106df04..080b441 100644 --- a/src/features/users/infrastructure/adapters/user-api.adapter.ts +++ b/src/features/users/infrastructure/adapters/user-api.adapter.ts @@ -3,23 +3,23 @@ import { IUser } from "@/users/domain/entities/IUser"; import { z } from "zod"; export class UserApiAdapter { - static toApiResponse(user: IUser): z.infer { - return { - id: user.id, - name: user.name, - email: user.email, - active: user.active, - username: user.username, - password_hash: user.passwordHash, - registration_date: user.registrationDate, - recovery_token: user.recoveryToken || null, - recovery_token_expires: user.recoveryTokenExpires || null, - }; - } + static toApiResponse(user: IUser): z.infer { + return { + id: user.id, + name: user.name, + email: user.email, + active: user.active, + username: user.username, + password_hash: user.passwordHash, + registration_date: user.registration_date, + recovery_token: user.recoveryToken || null, + recovery_token_expires: user.recoveryTokenExpires || null, + }; + } - static toApiResponseList( - users: IUser[] - ): z.infer[] { - return users.map(this.toApiResponse); - } + static toApiResponseList( + users: IUser[] + ): z.infer[] { + return users.map(this.toApiResponse); + } } diff --git a/src/features/users/infrastructure/adapters/user.repository.ts b/src/features/users/infrastructure/adapters/user.repository.ts index 8ea318d..1cc65fd 100644 --- a/src/features/users/infrastructure/adapters/user.repository.ts +++ b/src/features/users/infrastructure/adapters/user.repository.ts @@ -5,145 +5,145 @@ import { IUserRepository } from "@/users/domain/ports/user-repository.port"; import { IUser } from "@/users/domain/entities/IUser"; export class PgUserRepository implements IUserRepository { - private db = DatabaseConnection.getInstance().db; - private static instance: PgUserRepository; - - private constructor() {} - - public static getInstance(): PgUserRepository { - if (!PgUserRepository.instance) { - PgUserRepository.instance = new PgUserRepository(); - } - return PgUserRepository.instance; - } - - async findAll(): Promise { - const result = await this.db.select().from(users); - return result.map((raw) => this.mapToEntity(raw)); - } - - async findAllActive(): Promise { - const result = await this.db - .select() - .from(users) - .where(eq(users.active, true)); - return result.map((raw) => this.mapToEntity(raw)); - } - - async findById(id: number): Promise { - const result = await this.db.select().from(users).where(eq(users.id, id)); - return result[0] ? this.mapToEntity(result[0]) : null; - } - - async findByEmail(email: string): Promise { - const result = await this.db - .select() - .from(users) - .where(eq(users.email, email)); - return result[0] ? this.mapToEntity(result[0]) : null; - } - - async findByUsername(username: string): Promise { - const result = await this.db - .select() - .from(users) - .where(eq(users.username, username)); - return result[0] ? this.mapToEntity(result[0]) : null; - } - - async create( - userData: Omit - ): Promise { - const result = await this.db - .insert(users) - .values({ - name: userData.name, - username: userData.username, - email: userData.email, - password_hash: userData.passwordHash, - active: userData.active, - }) - .returning(); - - return this.mapToEntity(result[0]); - } - - async findByRecoveryToken(token: string): Promise { - const result = await this.db - .select() - .from(users) - .where(eq(users.recovery_token, token)); - - return result[0] ? this.mapToEntity(result[0]) : null; - } - - async update(id: number, userData: Partial): Promise { - const result = await this.db - .update(users) - .set({ - name: userData.name, - username: userData.username, - email: userData.email, - password_hash: userData.passwordHash, - active: userData.active, - }) - .where(eq(users.id, id)) - .returning(); - - return this.mapToEntity(result[0]); - } - - async delete(id: number): Promise { - const result = await this.db - .update(users) - .set({ active: false }) - .where(eq(users.id, id)) - .returning(); - - return result.length > 0; - } - - async setRecoveryToken( - id: number, - token: string, - expires: Date - ): Promise { - const result = await this.db - .update(users) - .set({ - recovery_token: token, - recovery_token_expires: expires, - }) - .where(eq(users.id, id)) - .returning(); - - return result.length > 0; - } - - async clearRecoveryToken(id: number): Promise { - const result = await this.db - .update(users) - .set({ - recovery_token: null, - recovery_token_expires: null, - }) - .where(eq(users.id, id)) - .returning(); - - return result.length > 0; - } - - private mapToEntity(raw: any): IUser { - return { - id: raw.id, - name: raw.name, - username: raw.username, - email: raw.email, - passwordHash: raw.password_hash, - registrationDate: raw.registration_date, - active: raw.active, - recoveryToken: raw.recovery_token, - recoveryTokenExpires: raw.recovery_token_expires, - }; - } + private db = DatabaseConnection.getInstance().db; + private static instance: PgUserRepository; + + private constructor() {} + + public static getInstance(): PgUserRepository { + if (!PgUserRepository.instance) { + PgUserRepository.instance = new PgUserRepository(); + } + return PgUserRepository.instance; + } + + async findAll(): Promise { + const result = await this.db.select().from(users); + return result.map((raw) => this.mapToEntity(raw)); + } + + async findAllActive(): Promise { + const result = await this.db + .select() + .from(users) + .where(eq(users.active, true)); + return result.map((raw) => this.mapToEntity(raw)); + } + + async findById(id: number): Promise { + const result = await this.db.select().from(users).where(eq(users.id, id)); + return result[0] ? this.mapToEntity(result[0]) : null; + } + + async findByEmail(email: string): Promise { + const result = await this.db + .select() + .from(users) + .where(eq(users.email, email)); + return result[0] ? this.mapToEntity(result[0]) : null; + } + + async findByUsername(username: string): Promise { + const result = await this.db + .select() + .from(users) + .where(eq(users.username, username)); + return result[0] ? this.mapToEntity(result[0]) : null; + } + + async create( + userData: Omit + ): Promise { + const result = await this.db + .insert(users) + .values({ + name: userData.name, + username: userData.username, + email: userData.email, + password_hash: userData.passwordHash, + active: userData.active, + }) + .returning(); + + return this.mapToEntity(result[0]); + } + + async findByRecoveryToken(token: string): Promise { + const result = await this.db + .select() + .from(users) + .where(eq(users.recovery_token, token)); + + return result[0] ? this.mapToEntity(result[0]) : null; + } + + async update(id: number, userData: Partial): Promise { + const result = await this.db + .update(users) + .set({ + name: userData.name, + username: userData.username, + email: userData.email, + password_hash: userData.passwordHash, + active: userData.active, + }) + .where(eq(users.id, id)) + .returning(); + + return this.mapToEntity(result[0]); + } + + async delete(id: number): Promise { + const result = await this.db + .update(users) + .set({ active: false }) + .where(eq(users.id, id)) + .returning(); + + return result.length > 0; + } + + async setRecoveryToken( + id: number, + token: string, + expires: Date + ): Promise { + const result = await this.db + .update(users) + .set({ + recovery_token: token, + recovery_token_expires: expires, + }) + .where(eq(users.id, id)) + .returning(); + + return result.length > 0; + } + + async clearRecoveryToken(id: number): Promise { + const result = await this.db + .update(users) + .set({ + recovery_token: null, + recovery_token_expires: null, + }) + .where(eq(users.id, id)) + .returning(); + + return result.length > 0; + } + + private mapToEntity(raw: any): IUser { + return { + id: raw.id, + name: raw.name, + username: raw.username, + email: raw.email, + passwordHash: raw.password_hash, + registration_date: raw.registration_date, + active: raw.active, + recoveryToken: raw.recovery_token, + recoveryTokenExpires: raw.recovery_token_expires, + }; + } }