diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..57a05ba Binary files /dev/null and b/bun.lockb differ diff --git a/drizzle.config.ts b/drizzle.config.ts index b62f4f7..0147787 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -35,7 +35,7 @@ if (process.env.DATABASE_URL) { const isTestEnv = process.env.NODE_ENV === "test"; const connectionString = - (isTestEnv && process.env.TEST_DATABASE_URL) || env.DATABASE_URL; + (isTestEnv && process.env.TEST_DATABASE_URL) || process.env.DATABASE_URL; if (isTestEnv && !process.env.TEST_DATABASE_URL) { console.warn( diff --git a/package.json b/package.json index ecdf1b8..456ee08 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@getbrevo/brevo": "^2.2.0", "@hono/zod-openapi": "^0.18.3", "@langchain/core": "^0.3.77", + "@openrouter/sdk": "^0.1.27", "@scalar/hono-api-reference": "^0.5.162", "@types/json2csv": "^5.0.7", "@types/uuid": "^10.0.0", diff --git a/src/core/infrastructure/cron/daily-recommendations.cron.ts b/src/core/infrastructure/cron/daily-recommendations.cron.ts index 4011ae9..56a9e54 100644 --- a/src/core/infrastructure/cron/daily-recommendations.cron.ts +++ b/src/core/infrastructure/cron/daily-recommendations.cron.ts @@ -11,7 +11,8 @@ const orchestrator = RecommendationOrchestratorService.getInstance( ); const dailyRecommendationsJob = new cron.CronJob( - "0 6 * * *", + // "0 6 * * *", + "*/5 * * * *", // TODO: Change back to "0 6 * * *" for production (runs every 5 minutes for testing) async () => { console.log("Running daily recommendations job..."); diff --git a/src/core/infrastructure/scripts/plans.seed.ts b/src/core/infrastructure/scripts/plans.seed.ts index 8eb560f..87b8302 100644 --- a/src/core/infrastructure/scripts/plans.seed.ts +++ b/src/core/infrastructure/scripts/plans.seed.ts @@ -33,7 +33,6 @@ async function seedPlans() { "Entrada de voz", "Acceso ilimitado a IA", "Soporte básico", - "Acceso a la app móvil", "Informes exportables" ] }, @@ -47,7 +46,6 @@ async function seedPlans() { "Entrada de voz", "Acceso ilimitado a IA", "Soporte básico", - "Acceso a la app móvil", "Informes exportables" ] }, diff --git a/src/features/ai-agents/infrastructure/adapters/openai-llm.adapter.ts b/src/features/ai-agents/infrastructure/adapters/openai-llm.adapter.ts index 0b74053..aa028a5 100644 --- a/src/features/ai-agents/infrastructure/adapters/openai-llm.adapter.ts +++ b/src/features/ai-agents/infrastructure/adapters/openai-llm.adapter.ts @@ -2,13 +2,15 @@ import { ILLMService } from "../../domain/ports/transcription.port"; export class OpenAILLMAdapter implements ILLMService { private apiKey: string; - private apiUrl = 'https://api.openai.com/v1/completions'; + private apiUrl = "https://api.openai.com/v1/completions"; constructor(apiKey: string) { this.apiKey = apiKey; } - async classifyIntent(transcription: string): Promise<{ intent: string; confidence: number }> { + async classifyIntent( + transcription: string + ): Promise<{ intent: string; confidence: number }> { const prompt = ` Analiza el siguiente texto y determina la intención del usuario. Las opciones son: - CREATE_TRANSACTION: Para crear gastos o ingresos @@ -26,20 +28,23 @@ Responde SOLO con un JSON en este formato: const response = await this.callOpenAI(prompt); const result = JSON.parse(response); return { - intent: result.intent || 'UNKNOWN', - confidence: result.confidence || 0.5 + intent: result.intent || "UNKNOWN", + confidence: result.confidence || 0.5, }; } catch (error) { - console.error('Error classifying intent:', error); - return { intent: 'UNKNOWN', confidence: 0 }; + console.error("Error classifying intent:", error); + return { intent: "UNKNOWN", confidence: 0 }; } } - async extractData(transcription: string, intent: string): Promise<{ data: Record; confidence: number }> { - let prompt = 'category_id=1,comida;2,viajes;3,education;4,otro\n'; + async extractData( + transcription: string, + intent: string + ): Promise<{ data: Record; confidence: number }> { + let prompt = "category_id=1,comida;2,viajes;3,education;4,otro\n"; switch (intent) { - case 'CREATE_TRANSACTION': + case "CREATE_TRANSACTION": prompt = ` Extrae los datos de transacción del siguiente texto: "${transcription}" @@ -56,7 +61,7 @@ Responde SOLO con un JSON con estos campos: `; break; - case 'CREATE_GOAL': + case "CREATE_GOAL": prompt = ` Extrae los datos de meta de ahorro del siguiente texto: "${transcription}" @@ -72,7 +77,7 @@ Responde SOLO con un JSON con estos campos: `; break; - case 'CREATE_BUDGET': + case "CREATE_BUDGET": prompt = ` Extrae los datos de presupuesto del siguiente texto: "${transcription}" @@ -97,39 +102,41 @@ Responde SOLO con un JSON con estos campos: const result = JSON.parse(response); const confidence = result.confidence || 0.8; delete result.confidence; - + return { data: result, - confidence + confidence, }; } catch (error) { - console.error('Error extracting data:', error); + console.error("Error extracting data:", error); return { data: {}, confidence: 0 }; } } private async callOpenAI(prompt: string): Promise { const fullPrompt = `Eres un asistente especializado en finanzas personales. Responde siempre con JSON válido.\n\n${prompt}`; - + const response = await fetch(this.apiUrl, { - method: 'POST', + method: "POST", headers: { - 'Authorization': `Bearer ${this.apiKey}`, - 'Content-Type': 'application/json', + Authorization: `Bearer ${this.apiKey}`, + "Content-Type": "application/json", }, body: JSON.stringify({ - model: 'gpt-3.5-turbo-instruct', + model: "gpt-4.1-nano", prompt: fullPrompt, temperature: 0.3, - max_tokens: 400 + max_tokens: 400, }), }); if (!response.ok) { - throw new Error(`OpenAI API error: ${response.status} ${response.statusText}`); + throw new Error( + `OpenAI API error: ${response.status} ${response.statusText}` + ); } const result = await response.json(); - return result.choices[0]?.text?.trim() || '{}'; + return result.choices[0]?.text?.trim() || "{}"; } -} \ No newline at end of file +} diff --git a/src/features/ai-agents/infrastructure/adapters/openrouter-llm.adapter.ts b/src/features/ai-agents/infrastructure/adapters/openrouter-llm.adapter.ts new file mode 100644 index 0000000..5441774 --- /dev/null +++ b/src/features/ai-agents/infrastructure/adapters/openrouter-llm.adapter.ts @@ -0,0 +1,147 @@ +import { ILLMService } from "../../domain/ports/transcription.port"; + +export class OpenRouterLLMAdapter implements ILLMService { + private apiKey: string; + private apiUrl = 'https://openrouter.ai/api/v1/chat/completions'; + + constructor(apiKey: string) { + this.apiKey = apiKey; + } + + async classifyIntent(transcription: string): Promise<{ intent: string; confidence: number }> { + const prompt = ` +Analiza el siguiente texto y determina la intención del usuario. Las opciones son: +- CREATE_TRANSACTION: Para crear gastos o ingresos +- CREATE_GOAL: Para crear metas de ahorro +- CREATE_BUDGET: Para crear presupuestos +- UNKNOWN: Si no es claro + +Texto: "${transcription}" + +Responde SOLO con un JSON en este formato: +{"intent": "CREATE_TRANSACTION", "confidence": 0.95} +`; + + try { + const response = await this.callOpenRouter(prompt); + const result = JSON.parse(response); + return { + intent: result.intent || 'UNKNOWN', + confidence: result.confidence || 0.5 + }; + } catch (error) { + console.error('Error classifying intent:', error); + return { intent: 'UNKNOWN', confidence: 0 }; + } + } + + async extractData(transcription: string, intent: string): Promise<{ data: Record; confidence: number }> { + let prompt = 'category_id=1,comida;2,viajes;3,education;4,otro\n'; + + switch (intent) { + case 'CREATE_TRANSACTION': + prompt = ` +Extrae los datos de transacción del siguiente texto: +"${transcription}" + +Responde SOLO con un JSON con estos campos: +{ + "amount": número, + "type": "INCOME" o "EXPENSE", + "description": "descripción", + "category_id": "nombre de categoría si se menciona", + "payment_method": "método de pago si se menciona", + "confidence": 0.0-1.0 +} +`; + break; + + case 'CREATE_GOAL': + prompt = ` +Extrae los datos de meta de ahorro del siguiente texto: +"${transcription}" + +Responde SOLO con un JSON con estos campos: +{ + "name": "nombre de la meta", + "target_amount": número, + "end_date": "fecha en formato ISO si se menciona", + "contribution_amount": número si se menciona, + "confidence": 0.0-1.0 +} +`; + break; + + case 'CREATE_BUDGET': + prompt = ` +Extrae los datos de presupuesto del siguiente texto: +"${transcription}" + +Responde SOLO con un JSON con estos campos: +{ + "limit_amount": número, + "category": "nombre de categoría", + "month": "mes/año si se menciona", + "confidence": 0.0-1.0, + "category_id": id de la categoria segun corresponda +} +`; + break; + + default: + return { data: {}, confidence: 0 }; + } + + try { + const response = await this.callOpenRouter(prompt); + const result = JSON.parse(response); + const confidence = result.confidence || 0.8; + delete result.confidence; + + return { + data: result, + confidence + }; + } catch (error) { + console.error('Error extracting data:', error); + return { data: {}, confidence: 0 }; + } + } + + private async callOpenRouter(prompt: string): Promise { + const systemPrompt = 'Eres un asistente especializado en finanzas personales. Responde siempre con JSON válido.'; + + const response = await fetch(this.apiUrl, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + 'HTTP-Referer': 'https://foppy.app', // Optional: your app URL + 'X-Title': 'Foppy Finance Assistant', // Optional: your app name + }, + body: JSON.stringify({ + model: 'x-ai/grok-4.1-fast:free', // Free Grok model from X.AI + messages: [ + { + role: 'system', + content: systemPrompt + }, + { + role: 'user', + content: prompt + } + ], + temperature: 0.3, + max_tokens: 400 + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`OpenRouter API error: ${response.status} ${response.statusText} - ${errorText}`); + } + + const result = await response.json(); + return result.choices[0]?.message?.content?.trim() || '{}'; + } +} diff --git a/src/features/ai-agents/infrastructure/controllers/voice-command.controller.ts b/src/features/ai-agents/infrastructure/controllers/voice-command.controller.ts index 97b6eb1..ea3e9ac 100644 --- a/src/features/ai-agents/infrastructure/controllers/voice-command.controller.ts +++ b/src/features/ai-agents/infrastructure/controllers/voice-command.controller.ts @@ -6,15 +6,16 @@ import { GoalAgentService } from "../../application/services/goal-agent.service" import { BudgetAgentService } from "../../application/services/budget-agent.service"; import { ValidationAgentService } from "../../application/services/validation-agent.service"; import { OpenAITranscriptionAdapter } from "../adapters/openai-transcription.adapter"; -import { OpenAILLMAdapter } from "../adapters/openai-llm.adapter"; +import { OpenRouterLLMAdapter } from "../adapters/openrouter-llm.adapter"; import { verifyToken } from "@/shared/utils/jwt.util"; const app = new Hono(); const OPENAI_API_KEY = process.env.OPENAI_API_KEY || ''; +const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY || ''; const transcriptionService = new OpenAITranscriptionAdapter(OPENAI_API_KEY); -const llmService = new OpenAILLMAdapter(OPENAI_API_KEY); +const llmService = new OpenRouterLLMAdapter(OPENROUTER_API_KEY); const transactionAgent = new TransactionAgentService(); const goalAgent = new GoalAgentService(); const budgetAgent = new BudgetAgentService(); @@ -52,52 +53,52 @@ app.post("/voice-command", async (c) => { }, 401); } - return c.json({ - "success": true, - "intent": "CREATE_GOAL", - "extractedData": { - "user_id": 1, - "name": "meta de ahorro para comida", - "target_amount": 600, - "current_amount": 0, - "end_date": "2026-10-09T21:58:33.460Z", - "category_id": null, - "contribution_frequency": null, - "contribution_amount": null - }, - "confidence": 0.95, - "message": "He identificado una meta de ahorro: \"meta de ahorro para comida\" con objetivo de $600" -}); +// return c.json({ +// "success": true, +// "intent": "CREATE_GOAL", +// "extractedData": { +// "user_id": 1, +// "name": "meta de ahorro para comida", +// "target_amount": 600, +// "current_amount": 0, +// "end_date": "2026-10-09T21:58:33.460Z", +// "category_id": null, +// "contribution_frequency": null, +// "contribution_amount": null +// }, +// "confidence": 0.95, +// "message": "He identificado una meta de ahorro: \"meta de ahorro para comida\" con objetivo de $600" +// }); + + const user = payload as { id: number; email: string }; + const body = await c.req?.formData(); + const audioFile = body?.get("audio") as File; + + if (!audioFile) { + return c.json({ + success: false, + message: "No se recibió archivo de audio" + }, 400); + } - // const user = payload as { id: number; email: string }; - // const body = await c.req?.formData(); - // const audioFile = body?.get("audio") as File; - - // if (!audioFile) { - // return c.json({ - // success: false, - // message: "No se recibió archivo de audio" - // }, 400); - // } - - // const audioBlob = new Blob([await audioFile.arrayBuffer()], { - // type: audioFile.type || 'audio/wav' - // }); - - // const result = await processVoiceCommandUseCase.execute({ - // audioBlob, - // userId: user.id - // }); - - // return c.json({ - // success: result.success, - // intent: result.intent, - // extractedData: result.extractedData, - // confidence: result.confidence, - // message: result.message, - // validationErrors: result.validationErrors, - // suggestedCorrections: result.suggestedCorrections - // }); + const audioBlob = new Blob([await audioFile.arrayBuffer()], { + type: audioFile.type || 'audio/wav' + }); + + const result = await processVoiceCommandUseCase.execute({ + audioBlob, + userId: user.id + }); + + return c.json({ + success: result.success, + intent: result.intent, + extractedData: result.extractedData, + confidence: result.confidence, + message: result.message, + validationErrors: result.validationErrors, + suggestedCorrections: result.suggestedCorrections + }); } catch (error) { console.error("Error processing voice command:", error); diff --git a/src/features/recommendations/application/services/budget-suggestion.service.ts b/src/features/recommendations/application/services/budget-suggestion.service.ts index edb0384..67e786b 100644 --- a/src/features/recommendations/application/services/budget-suggestion.service.ts +++ b/src/features/recommendations/application/services/budget-suggestion.service.ts @@ -108,6 +108,10 @@ export class BudgetSuggestionService implements IAnalysisService { new Date(this.getCurrentMonthString()) ); + console.log( + `[BudgetSuggestion] User ${userId}: Found ${recentExpenses.length} expenses in last 3 months, ${currentMonthBudgets.length} budgets this month` + ); + const budgetedCategoryIds = new Set( currentMonthBudgets.map((b) => b.categoryId).filter((id) => id !== null) ); @@ -146,6 +150,9 @@ export class BudgetSuggestionService implements IAnalysisService { } if (categoriesNeedingBudget.length === 0) { + console.log( + `[BudgetSuggestion] User ${userId}: No categories needing budget (all categories either have budgets or spend <$${this.MIN_AVERAGE_SPENDING}/month)` + ); return null; } @@ -153,12 +160,24 @@ export class BudgetSuggestionService implements IAnalysisService { (a, b) => b.averageMonthlySpending - a.averageMonthlySpending ); + console.log( + `[BudgetSuggestion] User ${userId}: Category "${categoriesNeedingBudget[0].categoryName}" needs budget (avg: $${categoriesNeedingBudget[0].averageMonthlySpending}/month)` + ); + return categoriesNeedingBudget[0]; } private async getAISuggestion( category: CategoryWithoutBudget ): Promise<{ suggestedBudget: number; reasoning: string }> { + // Use fallback logic if no API key is configured + if (!this.OPENAI_API_KEY || this.OPENAI_API_KEY === "") { + console.log( + "OpenAI API key not configured, using fallback logic for budget suggestion" + ); + return this.getFallbackSuggestion(category); + } + const prompt = `El usuario gasta en promedio $${category.averageMonthlySpending.toFixed( 2 )}/mes en "${ @@ -188,7 +207,7 @@ Responde SOLO con un JSON válido en este formato: "Content-Type": "application/json", }, body: JSON.stringify({ - model: "gpt-4", + model: "gpt-4.1-nano", messages: [ { role: "system", @@ -208,6 +227,8 @@ Responde SOLO con un JSON válido en este formato: ); if (!response.ok) { + const errorBody = await response.text(); + console.error(`OpenAI API error: ${response.status} - ${errorBody}`); throw new Error(`OpenAI API error: ${response.status}`); } @@ -220,23 +241,29 @@ Responde SOLO con un JSON válido en este formato: return JSON.parse(content); } catch (error) { - console.error("Error calling OpenAI API:", error); - - const suggestedBudget = Math.ceil(category.averageMonthlySpending * 1.15); - - return { - suggestedBudget, - reasoning: `Basado en tu gasto promedio de $${category.averageMonthlySpending.toFixed( - 2 - )}, te sugerimos un presupuesto de $${suggestedBudget.toFixed( - 2 - )} mensual que incluye un 15% de flexibilidad para imprevistos en ${ - category.categoryName - }.`, - }; + console.error("Error calling OpenAI API, using fallback:", error); + return this.getFallbackSuggestion(category); } } + private getFallbackSuggestion(category: CategoryWithoutBudget): { + suggestedBudget: number; + reasoning: string; + } { + const suggestedBudget = Math.ceil(category.averageMonthlySpending * 1.15); + + return { + suggestedBudget, + reasoning: `Basado en tu gasto promedio de $${category.averageMonthlySpending.toFixed( + 2 + )}, te sugerimos un presupuesto de $${suggestedBudget.toFixed( + 2 + )} mensual que incluye un 15% de flexibilidad para imprevistos en ${ + category.categoryName + }.`, + }; + } + private calculatePriority(averageSpending: number): RecommendationPriority { if (averageSpending >= 500) { return RecommendationPriority.HIGH; diff --git a/src/features/recommendations/application/services/debt-reminder.service.ts b/src/features/recommendations/application/services/debt-reminder.service.ts index c8e099d..71ba9c8 100644 --- a/src/features/recommendations/application/services/debt-reminder.service.ts +++ b/src/features/recommendations/application/services/debt-reminder.service.ts @@ -94,6 +94,7 @@ export class DebtReminderService implements IAnalysisService { ); if (activeDebts.length === 0) { + console.log(`[DebtReminder] User ${userId}: No active debts found`); return null; } @@ -106,12 +107,18 @@ export class DebtReminderService implements IAnalysisService { }); if (upcomingDebts.length === 0) { + console.log( + `[DebtReminder] User ${userId}: No debts due within ${this.DAYS_AHEAD_THRESHOLD} days` + ); return null; } const availableBalance = await this.calculateAvailableBalance(userId); if (availableBalance <= 0) { + console.log( + `[DebtReminder] User ${userId}: No available balance to pay debts` + ); return null; } @@ -126,10 +133,7 @@ export class DebtReminderService implements IAnalysisService { const suggestedPayment = canPayFull ? debt.pendingAmount - : Math.min( - Math.floor(availableBalance * 0.5), - debt.pendingAmount - ); + : Math.min(Math.floor(availableBalance * 0.5), debt.pendingAmount); if (suggestedPayment > 0) { opportunities.push({ @@ -143,11 +147,18 @@ export class DebtReminderService implements IAnalysisService { } if (opportunities.length === 0) { + console.log( + `[DebtReminder] User ${userId}: No suitable payment opportunities found` + ); return null; } opportunities.sort((a, b) => a.daysUntilDue - b.daysUntilDue); + console.log( + `[DebtReminder] User ${userId}: Found payment opportunity for "${opportunities[0].debt.description}" (due in ${opportunities[0].daysUntilDue} days)` + ); + return opportunities[0]; } @@ -166,7 +177,13 @@ export class DebtReminderService implements IAnalysisService { private async getAISuggestion( opportunity: DebtOpportunity ): Promise<{ suggestedPayment: number; reasoning: string }> { - const prompt = `El usuario tiene disponible $${opportunity.availableBalance.toFixed(2)} este mes. La deuda "${opportunity.debt.description}" de $${opportunity.debt.pendingAmount.toFixed(2)} vence en ${opportunity.daysUntilDue} días. + const prompt = `El usuario tiene disponible $${opportunity.availableBalance.toFixed( + 2 + )} este mes. La deuda "${ + opportunity.debt.description + }" de $${opportunity.debt.pendingAmount.toFixed(2)} vence en ${ + opportunity.daysUntilDue + } días. ${ opportunity.canPayFull @@ -178,7 +195,11 @@ Sugiere cuánto debería pagar ahora considerando: 1. El monto disponible 2. Los días restantes hasta el vencimiento 3. Mantener un colchón de emergencia (no usar todo el balance disponible) -4. ${opportunity.canPayFull ? "Beneficios de liquidar la deuda completa" : "Reducir el saldo pendiente para evitar intereses"} +4. ${ + opportunity.canPayFull + ? "Beneficios de liquidar la deuda completa" + : "Reducir el saldo pendiente para evitar intereses" + } Proporciona un razonamiento motivador (2-3 oraciones) sobre por qué es importante hacer este pago ahora. @@ -188,6 +209,29 @@ Responde SOLO con un JSON válido en este formato: "reasoning": "explicación breve y motivadora" }`; + // Use fallback logic if no API key is configured + if (!this.OPENAI_API_KEY || this.OPENAI_API_KEY === "") { + console.log("OpenAI API key not configured, using fallback logic"); + const suggestedPayment = opportunity.canPayFull + ? opportunity.debt.pendingAmount + : Math.floor(opportunity.availableBalance * 0.4); + + return { + suggestedPayment, + reasoning: opportunity.canPayFull + ? `Tienes suficiente saldo para liquidar esta deuda de $${opportunity.debt.pendingAmount.toFixed( + 2 + )}. Pagarla ahora te liberará de esta obligación y evitará posibles cargos por intereses.` + : `Tienes disponible $${opportunity.availableBalance.toFixed( + 2 + )}. Considera hacer un pago de $${suggestedPayment.toFixed( + 2 + )} para reducir tu deuda de $${opportunity.debt.pendingAmount.toFixed( + 2 + )} que vence en ${opportunity.daysUntilDue} días.`, + }; + } + try { const response = await fetch( "https://api.openai.com/v1/chat/completions", @@ -198,7 +242,7 @@ Responde SOLO con un JSON válido en este formato: "Content-Type": "application/json", }, body: JSON.stringify({ - model: "gpt-4", + model: "gpt-3.5-turbo", messages: [ { role: "system", @@ -218,6 +262,8 @@ Responde SOLO con un JSON válido en este formato: ); if (!response.ok) { + const errorBody = await response.text(); + console.error(`OpenAI API error: ${response.status} - ${errorBody}`); throw new Error(`OpenAI API error: ${response.status}`); } @@ -230,7 +276,7 @@ Responde SOLO con un JSON válido en este formato: return JSON.parse(content); } catch (error) { - console.error("Error calling OpenAI API:", error); + console.error("Error calling OpenAI API, using fallback:", error); const suggestedPayment = opportunity.canPayFull ? opportunity.debt.pendingAmount @@ -239,8 +285,12 @@ Responde SOLO con un JSON válido en este formato: return { suggestedPayment, reasoning: opportunity.canPayFull - ? `Tienes suficiente saldo para liquidar esta deuda de $${opportunity.debt.pendingAmount.toFixed(2)}. Pagarla ahora te liberará de esta obligación y evitará posibles cargos por intereses.` - : `Con tu saldo actual, puedes hacer un pago de $${suggestedPayment.toFixed(2)} que reducirá significativamente tu deuda. Esto te acercará a liquidarla y minimizará los intereses acumulados.`, + ? `Tienes suficiente saldo para liquidar esta deuda de $${opportunity.debt.pendingAmount.toFixed( + 2 + )}. Pagarla ahora te liberará de esta obligación y evitará posibles cargos por intereses.` + : `Con tu saldo actual, puedes hacer un pago de $${suggestedPayment.toFixed( + 2 + )} que reducirá significativamente tu deuda. Esto te acercará a liquidarla y minimizará los intereses acumulados.`, }; } } diff --git a/src/features/recommendations/application/services/goal-optimization.service.ts b/src/features/recommendations/application/services/goal-optimization.service.ts index 9ed0f4c..4955337 100644 --- a/src/features/recommendations/application/services/goal-optimization.service.ts +++ b/src/features/recommendations/application/services/goal-optimization.service.ts @@ -104,9 +104,14 @@ export class GoalOptimizationService implements IAnalysisService { ); if (userGoals.length === 0) { + console.log(`[GoalOptimization] User ${userId}: No active goals found`); return null; } + console.log( + `[GoalOptimization] User ${userId}: Analyzing ${userGoals.length} active goal(s)` + ); + const goalAnalyses: GoalAnalysis[] = []; for (const goal of userGoals) { @@ -160,20 +165,41 @@ export class GoalOptimizationService implements IAnalysisService { } if (goalAnalyses.length === 0) { + console.log( + `[GoalOptimization] User ${userId}: No unrealistic goals found (all goals have realismRatio <= ${this.UNREALISTIC_THRESHOLD})` + ); return null; } goalAnalyses.sort((a, b) => b.realismRatio - a.realismRatio); + console.log( + `[GoalOptimization] User ${userId}: Found unrealistic goal "${ + goalAnalyses[0].goal.name + }" (realismRatio: ${goalAnalyses[0].realismRatio.toFixed(2)})` + ); + return goalAnalyses[0]; } - private async getAISuggestion( - analysis: GoalAnalysis - ): Promise<{ suggestion: "extend" | "reduce"; newValue: number; reasoning: string }> { - const prompt = `La meta "${analysis.goal.name}" del usuario requiere ahorrar $${analysis.dailyRequiredContribution.toFixed(2)}/día durante los próximos ${analysis.daysRemaining} días para alcanzar $${analysis.amountRemaining.toFixed(2)} faltantes. - -Su contribución promedio histórica es de $${analysis.averageContribution.toFixed(2)}/día, lo cual hace esta meta ${Math.round(analysis.realismRatio)}x más exigente de lo que ha logrado mantener. + private async getAISuggestion(analysis: GoalAnalysis): Promise<{ + suggestion: "extend" | "reduce"; + newValue: number; + reasoning: string; + }> { + const prompt = `La meta "${ + analysis.goal.name + }" del usuario requiere ahorrar $${analysis.dailyRequiredContribution.toFixed( + 2 + )}/día durante los próximos ${ + analysis.daysRemaining + } días para alcanzar $${analysis.amountRemaining.toFixed(2)} faltantes. + +Su contribución promedio histórica es de $${analysis.averageContribution.toFixed( + 2 + )}/día, lo cual hace esta meta ${Math.round( + analysis.realismRatio + )}x más exigente de lo que ha logrado mantener. Sugiere: 1. Extender la fecha límite (¿cuántos días más necesitaría manteniendo su ritmo actual?) @@ -188,6 +214,29 @@ Responde SOLO con un JSON válido en este formato: "reasoning": "explicación breve y motivadora" }`; + // Use fallback logic if no API key is configured + if (!this.OPENAI_API_KEY || this.OPENAI_API_KEY === "") { + console.log("OpenAI API key not configured, using fallback logic"); + const daysNeeded = Math.ceil( + analysis.amountRemaining / analysis.averageContribution + ); + const additionalDays = daysNeeded - analysis.daysRemaining; + + return { + suggestion: additionalDays > 0 ? "extend" : "reduce", + newValue: + additionalDays > 0 + ? additionalDays + : Math.ceil( + analysis.goal.currentAmount / analysis.averageContribution + ), + reasoning: + additionalDays > 0 + ? `Basado en tu ritmo actual de ahorro, necesitarías ${daysNeeded} días más para alcanzar tu meta. Considera extender la fecha límite para que sea más alcanzable.` + : `Con tu ritmo actual, estás en excelente camino. Podrías considerar reducir el monto objetivo para cumplirlo antes de la fecha límite.`, + }; + } + try { const response = await fetch( "https://api.openai.com/v1/chat/completions", @@ -198,7 +247,7 @@ Responde SOLO con un JSON válido en este formato: "Content-Type": "application/json", }, body: JSON.stringify({ - model: "gpt-4", + model: "gpt-4.1-nano", messages: [ { role: "system", @@ -218,6 +267,8 @@ Responde SOLO con un JSON válido en este formato: ); if (!response.ok) { + const errorBody = await response.text(); + console.error(`OpenAI API error: ${response.status} - ${errorBody}`); throw new Error(`OpenAI API error: ${response.status}`); } @@ -230,7 +281,7 @@ Responde SOLO con un JSON válido en este formato: return JSON.parse(content); } catch (error) { - console.error("Error calling OpenAI API:", error); + console.error("Error calling OpenAI API, using fallback:", error); const daysNeeded = Math.ceil( analysis.amountRemaining / analysis.averageContribution @@ -242,9 +293,7 @@ Responde SOLO con un JSON válido en este formato: newValue: additionalDays > 0 ? additionalDays - : Math.floor( - analysis.averageContribution * analysis.daysRemaining - ), + : Math.floor(analysis.averageContribution * analysis.daysRemaining), reasoning: "Tu ritmo de ahorro actual sugiere que necesitas ajustar tu meta para hacerla más alcanzable. Esto te ayudará a mantener la motivación y el progreso constante.", }; @@ -265,7 +314,9 @@ Responde SOLO con un JSON válido en este formato: ); const extendedDate = new Date(analysis.goal.endDate); - extendedDate.setDate(extendedDate.getDate() + (daysNeeded - analysis.daysRemaining)); + extendedDate.setDate( + extendedDate.getDate() + (daysNeeded - analysis.daysRemaining) + ); return { targetAmount: realisticAmount, diff --git a/src/features/recommendations/application/services/recommendation-orchestrator.service.ts b/src/features/recommendations/application/services/recommendation-orchestrator.service.ts index c971192..5c807ee 100644 --- a/src/features/recommendations/application/services/recommendation-orchestrator.service.ts +++ b/src/features/recommendations/application/services/recommendation-orchestrator.service.ts @@ -93,6 +93,26 @@ export class RecommendationOrchestratorService return null; } + // Additional check: verify if there's already a recommendation with the same title/type in the last 23 hours + const recentThreshold = new Date(); + recentThreshold.setHours(recentThreshold.getHours() - 23); + + const pendingRecommendations = + await this.recommendationRepository.findPendingByUserId(userId); + const duplicateExists = pendingRecommendations.some( + (rec) => + rec.type === randomType && + rec.title === analysisResult.title && + rec.createdAt >= recentThreshold + ); + + if (duplicateExists) { + console.log( + `Duplicate recommendation detected for user ${userId} with type ${randomType} and title "${analysisResult.title}". Skipping creation.` + ); + return null; + } + const recommendation = await this.recommendationRepository.create({ userId, type: randomType, @@ -144,16 +164,27 @@ export class RecommendationOrchestratorService private async checkRecentRecommendation(userId: number): Promise { try { - const todayStart = new Date(); - todayStart.setHours(0, 0, 0, 0); + // Check for recommendations in the last 23 hours (to prevent duplicates even if running frequently) + const recentThreshold = new Date(); + recentThreshold.setHours(recentThreshold.getHours() - 23); const pendingRecommendations = await this.recommendationRepository.findPendingByUserId(userId); const hasRecentRecommendation = pendingRecommendations.some( - (rec) => rec.createdAt >= todayStart + (rec) => rec.createdAt >= recentThreshold ); + if (hasRecentRecommendation) { + console.log( + `User ${userId} has ${ + pendingRecommendations.filter( + (rec) => rec.createdAt >= recentThreshold + ).length + } recommendation(s) from the last 23 hours` + ); + } + return hasRecentRecommendation; } catch (error) { console.error( @@ -174,6 +205,25 @@ export class RecommendationOrchestratorService recommendation: Recommendation ): Promise { try { + // Check if there's already a similar notification in the last 23 hours to prevent duplicates + const recentThreshold = new Date(); + recentThreshold.setHours(recentThreshold.getHours() - 23); + + const existingNotifications = + await this.notificationRepository.findRecentByUserTypeAndTitle( + recommendation.userId, + NotificationType.SUGGESTION, + recommendation.title, + recentThreshold + ); + + if (existingNotifications.length > 0) { + console.log( + `Skipping notification creation for recommendation ${recommendation.id} - similar notification already exists` + ); + return; + } + await this.notificationRepository.create({ userId: recommendation.userId, title: recommendation.title, @@ -182,6 +232,10 @@ export class RecommendationOrchestratorService type: NotificationType.SUGGESTION, expiresAt: recommendation.expiresAt || undefined, }); + + console.log( + `Created notification for recommendation ${recommendation.id}` + ); } catch (error) { console.error( `Error creating notification for recommendation ${recommendation.id}:`, diff --git a/src/features/recommendations/application/services/spending-analysis.service.ts b/src/features/recommendations/application/services/spending-analysis.service.ts index d031f50..c266a9d 100644 --- a/src/features/recommendations/application/services/spending-analysis.service.ts +++ b/src/features/recommendations/application/services/spending-analysis.service.ts @@ -89,11 +89,7 @@ export class SpendingAnalysisService implements IAnalysisService { ): Promise { const now = new Date(); const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1); - const threeMonthsAgo = new Date( - now.getFullYear(), - now.getMonth() - 3, - 1 - ); + const threeMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 3, 1); const previousMonthStart = new Date( now.getFullYear(), now.getMonth() - 1, @@ -116,6 +112,10 @@ export class SpendingAnalysisService implements IAnalysisService { endDate: previousMonthStart.toISOString(), }); + console.log( + `[SpendingAnalysis] User ${userId}: Found ${currentMonthExpenses.length} expenses this month, ${previousThreeMonthsExpenses.length} in previous 3 months` + ); + const currentMonthByCategory = this.groupByCategory(currentMonthExpenses); const previousMonthsByCategory = this.groupByCategory( previousThreeMonthsExpenses @@ -147,6 +147,9 @@ export class SpendingAnalysisService implements IAnalysisService { } if (categoryAnalysis.length === 0) { + console.log( + `[SpendingAnalysis] User ${userId}: No unusual spending patterns detected (no category with >${this.THRESHOLD_PERCENTAGE}% increase)` + ); return null; } @@ -154,14 +157,18 @@ export class SpendingAnalysisService implements IAnalysisService { (a, b) => b.percentageIncrease - a.percentageIncrease ); + console.log( + `[SpendingAnalysis] User ${userId}: Found unusual spending in ${categoryAnalysis[0].categoryName} (+${categoryAnalysis[0].percentageIncrease}%)` + ); + return categoryAnalysis[0]; } - private groupByCategory( - transactions: any[] - ): Record { + private groupByCategory(transactions: any[]): Record { return transactions.reduce((acc, tx) => { - const key = `${tx.categoryId || "null"}|${tx.category?.name || "Sin categoría"}`; + const key = `${tx.categoryId || "null"}|${ + tx.category?.name || "Sin categoría" + }`; acc[key] = (acc[key] || 0) + tx.amount; return acc; }, {} as Record); @@ -170,7 +177,13 @@ export class SpendingAnalysisService implements IAnalysisService { private async getAIInsight( spending: CategorySpending ): Promise<{ insight: string; suggestion: string }> { - const prompt = `El usuario ha gastado $${spending.currentMonthTotal.toFixed(2)} en "${spending.categoryName}" este mes, lo cual es ${spending.percentageIncrease}% más que su promedio de 3 meses de $${spending.threeMonthAverage.toFixed(2)}. + const prompt = `El usuario ha gastado $${spending.currentMonthTotal.toFixed( + 2 + )} en "${spending.categoryName}" este mes, lo cual es ${ + spending.percentageIncrease + }% más que su promedio de 3 meses de $${spending.threeMonthAverage.toFixed( + 2 + )}. Proporciona: 1. Un análisis breve (2-3 oraciones) sobre este patrón de gasto @@ -182,6 +195,16 @@ Responde SOLO con un JSON válido en este formato: "suggestion": "sugerencia accionable" }`; + // Use fallback logic if no API key is configured + if (!this.OPENAI_API_KEY || this.OPENAI_API_KEY === "") { + console.log("OpenAI API key not configured, using fallback logic"); + return { + insight: `Has incrementado tu gasto en ${spending.categoryName} en un ${spending.percentageIncrease}% comparado con tu promedio mensual. Este cambio significativo merece atención.`, + suggestion: + "Revisa tus transacciones recientes en esta categoría para identificar gastos innecesarios y considera establecer un presupuesto mensual.", + }; + } + try { const response = await fetch( "https://api.openai.com/v1/chat/completions", @@ -192,7 +215,7 @@ Responde SOLO con un JSON válido en este formato: "Content-Type": "application/json", }, body: JSON.stringify({ - model: "gpt-4", + model: "gpt-4.1-nano", messages: [ { role: "system", @@ -212,6 +235,8 @@ Responde SOLO con un JSON válido en este formato: ); if (!response.ok) { + const errorBody = await response.text(); + console.error(`OpenAI API error: ${response.status} - ${errorBody}`); throw new Error(`OpenAI API error: ${response.status}`); } @@ -224,7 +249,7 @@ Responde SOLO con un JSON válido en este formato: return JSON.parse(content); } catch (error) { - console.error("Error calling OpenAI API:", error); + console.error("Error calling OpenAI API, using fallback:", error); return { insight: `Has incrementado tu gasto en ${spending.categoryName} en un ${spending.percentageIncrease}% comparado con tu promedio mensual. Este cambio significativo merece atención.`, diff --git a/src/features/recommendations/infrastructure/adapters/pg-recommendation.repository.ts b/src/features/recommendations/infrastructure/adapters/pg-recommendation.repository.ts index 58c9e26..ab45c9a 100644 --- a/src/features/recommendations/infrastructure/adapters/pg-recommendation.repository.ts +++ b/src/features/recommendations/infrastructure/adapters/pg-recommendation.repository.ts @@ -1,6 +1,6 @@ import { db } from "@/db"; import { recommendations } from "@/schema"; -import { eq, and, gt, lt } from "drizzle-orm"; +import { eq, and, gt, lt, inArray } from "drizzle-orm"; import { IRecommendationRepository } from "../../domain/ports/recommendation.repository.interface"; import { Recommendation, @@ -65,13 +65,18 @@ export class PgRecommendationRepository implements IRecommendationRepository { async findPendingByUserId(userId: number): Promise { const now = new Date(); + // Return recommendations that are PENDING or VIEWED (exclude DISMISSED and ACTED) + // This allows users to see recommendations they've already viewed until they dismiss or act on them const results = await db .select() .from(recommendations) .where( and( eq(recommendations.user_id, userId), - eq(recommendations.status, RecommendationStatus.PENDING), + inArray(recommendations.status, [ + RecommendationStatus.PENDING, + RecommendationStatus.VIEWED, + ]), gt(recommendations.expires_at, now) ) ) diff --git a/src/features/recommendations/infrastructure/clients/openai.client.ts b/src/features/recommendations/infrastructure/clients/openai.client.ts index ce2e2d9..5bf5c7a 100644 --- a/src/features/recommendations/infrastructure/clients/openai.client.ts +++ b/src/features/recommendations/infrastructure/clients/openai.client.ts @@ -21,7 +21,7 @@ export class OpenAIClient { try { const response = await this.client.chat.completions.create( { - model: "gpt-4", + model: "gpt-4.1-nano", messages: [ { role: "system",