Skip to content

Commit da656c1

Browse files
committed
feat: Introduce Vertex AI projection feature in the dashboard, enhancing financial insights with trend analysis, savings rate, and reserve months. Update consulting report to include AI-generated conclusions and improve email notification settings for user actions.
1 parent 8b4cd70 commit da656c1

53 files changed

Lines changed: 1063 additions & 65 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Modules/Core/app/Http/Controllers/CoreController.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,9 @@ public function dashboard()
133133
$monthlyCapacity = $this->financialHealthService->calculateMonthlyCapacity($user);
134134
$incomeBreakdown = $this->financialHealthService->getIncomeBreakdown($user);
135135

136+
// Projection data for Vertex AI card (PRO)
137+
$projectionData = $this->financialHealthService->getProjectionData($user);
138+
136139
return view('core::dashboard', compact(
137140
'accounts',
138141
'totalBalance',
@@ -148,7 +151,8 @@ public function dashboard()
148151
'categoryData',
149152
'recentTransactions',
150153
'monthlyCapacity',
151-
'incomeBreakdown'
154+
'incomeBreakdown',
155+
'projectionData'
152156
));
153157
}
154158

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Modules\Core\Http\Controllers;
6+
7+
use App\Http\Controllers\Controller;
8+
use Illuminate\Http\JsonResponse;
9+
use Illuminate\Http\Request;
10+
use Illuminate\Support\Facades\RateLimiter;
11+
use Modules\Core\Services\FinancialHealthService;
12+
use Modules\Core\Services\GeminiService;
13+
use Modules\Core\Services\SettingService;
14+
15+
class ProjectionController extends Controller
16+
{
17+
public function __construct(
18+
protected FinancialHealthService $financialHealth,
19+
protected GeminiService $geminiService,
20+
protected SettingService $settingService
21+
) {
22+
$this->middleware(['auth', 'verified', 'pro']);
23+
}
24+
25+
/**
26+
* Analyze 1-year patrimony projection via Gemini. Rate limited 5 req/min.
27+
*/
28+
public function analyze(Request $request): JsonResponse
29+
{
30+
$user = auth()->user();
31+
$key = 'projection_analyze_' . $user->id;
32+
33+
if (RateLimiter::tooManyAttempts($key, 5)) {
34+
return response()->json([
35+
'error' => 'Aguarde um momento antes de analisar novamente.',
36+
], 429);
37+
}
38+
39+
RateLimiter::hit($key, 60);
40+
41+
$projectionData = $this->financialHealth->getProjectionData($user);
42+
43+
$useGemini = (bool) ($this->settingService->get('gemini_enabled') ?? false) && $this->geminiService->isAvailable();
44+
if (! $useGemini) {
45+
return response()->json([
46+
'projection' => $projectionData['trend_summary'] ?? 'Analise seus relatórios para acompanhar a evolução.',
47+
]);
48+
}
49+
50+
$contextData = [
51+
'reserve_months' => $projectionData['reserve_months'],
52+
'savings_rate' => $projectionData['savings_rate'],
53+
'balance' => $projectionData['balance'],
54+
'monthly_income' => $projectionData['monthly_income'],
55+
'monthly_expense' => $projectionData['monthly_expense'],
56+
];
57+
58+
$projection = $this->geminiService->generateOneYearProjection($contextData);
59+
60+
if ($projection === null || trim($projection) === '') {
61+
return response()->json([
62+
'projection' => $projectionData['trend_summary'] ?? 'Não foi possível gerar a projeção. Tente novamente.',
63+
]);
64+
}
65+
66+
return response()->json(['projection' => trim($projection)]);
67+
}
68+
}

Modules/Core/app/Http/Controllers/ReportsController.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace Modules\Core\Http\Controllers;
66

77
use App\Http\Controllers\Controller;
8+
use App\Models\SupportAuditLog;
89
use Carbon\Carbon;
910
use Illuminate\Http\Request;
1011
use Modules\Core\Models\Account;
@@ -364,6 +365,19 @@ public function viewConsulting(Request $request)
364365
$request
365366
);
366367

368+
if (! empty($consultingData['generated_with_ai'])) {
369+
SupportAuditLog::create([
370+
'agent_id' => $user->id,
371+
'user_id' => $user->id,
372+
'action' => 'report.consulting.ai_generated',
373+
'metadata' => [
374+
'period' => now()->format('Y-m'),
375+
'financial_score' => $consultingData['financial_score'] ?? 0,
376+
],
377+
'ip_address' => $request->ip(),
378+
]);
379+
}
380+
367381
$templateData = $this->templateService->getTemplateData();
368382

369383
return view('core::documents.consulting-report', compact('consultingData', 'templateData'));

Modules/Core/app/Services/FinancialHealthService.php

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,49 @@ public function getMonthlyExpensesAverage(User $user, int $months = 3): float
396396
return $sum > 0 ? round($sum / $months, 2) : 0.0;
397397
}
398398

399+
/**
400+
* Projection data for Vertex AI card: reserve months, savings rate, trend summary.
401+
*
402+
* @return array{reserve_months: float, savings_rate: float, monthly_surplus: float, months_until_reserve_depleted: float|null, trend_summary: string, balance: float, monthly_income: float, monthly_expense: float}
403+
*/
404+
public function getProjectionData(User $user): array
405+
{
406+
$snapshot = $this->getUserFinancialSnapshot($user);
407+
$income = (float) ($snapshot['monthly_income'] ?? 0);
408+
$expense = (float) ($snapshot['monthly_expenses'] ?? 0);
409+
$balance = (float) ($snapshot['account_balance'] ?? 0);
410+
$surplus = $income - $expense;
411+
412+
$reserveMonths = $expense > 0 ? round($balance / $expense, 1) : 0.0;
413+
$savingsRate = $income > 0 ? max(0.0, ($surplus / $income) * 100) : 0.0;
414+
415+
$monthsUntilDepleted = null;
416+
if ($surplus < 0 && $balance > 0) {
417+
$monthsUntilDepleted = round($balance / abs($surplus), 1);
418+
}
419+
420+
if ($surplus < 0 && $monthsUntilDepleted !== null) {
421+
$trendSummary = sprintf('Se continuar gastando assim, sua reserva acaba em aproximadamente %.0f meses.', $monthsUntilDepleted);
422+
} elseif ($savingsRate >= 20) {
423+
$trendSummary = sprintf('Com taxa de poupança de %.0f%%, seu patrimônio tende a crescer. Mantenha a disciplina!', $savingsRate);
424+
} elseif ($savingsRate > 0) {
425+
$trendSummary = sprintf('Reserva de %.1f meses de gastos. Taxa de poupança de %.0f%% — há espaço para melhorar.', $reserveMonths, $savingsRate);
426+
} else {
427+
$trendSummary = sprintf('Sua reserva cobre %.1f meses de gastos. Considere reduzir despesas para recompor.', $reserveMonths);
428+
}
429+
430+
return [
431+
'reserve_months' => $reserveMonths,
432+
'savings_rate' => round($savingsRate, 1),
433+
'monthly_surplus' => round($surplus, 2),
434+
'months_until_reserve_depleted' => $monthsUntilDepleted,
435+
'trend_summary' => $trendSummary,
436+
'balance' => $balance,
437+
'monthly_income' => $income,
438+
'monthly_expense' => $expense,
439+
];
440+
}
441+
399442
/**
400443
* Reserve months: account_balance / average_monthly_expenses.
401444
* Returns 0 if no meaningful expenses (anti-gaming: no infinite reserve).

Modules/Core/app/Services/GeminiService.php

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,8 @@ protected function buildPrompt(array $contextData, string $trigger, bool $isPro)
142142

143143
$system = "Você é o Vertex Bot - um mentor financeiro amigável e acolhedor. Dê uma dica personalizada em português do Brasil. ";
144144
$system .= "NUNCA use nome, CPF ou dados pessoais. Responda apenas com o texto da dica, sem introduções.\n\n";
145+
$system .= "VARIE sempre o tema: não repita a mesma ideia (ex.: evitar ficar só em 'reserva de emergência' e 'rotativo'). ";
146+
$system .= "Alternar entre: controle de gastos, orçamento por categoria, metas, lazer sem culpa, assinaturas, parcelas, regra 50/30/20, pequenas economias, comparação de preços, etc.\n\n";
145147

146148
if ($isLowScore) {
147149
$system .= "IMPORTANTE - usuário com score baixo (provavelmente leigo em finanças): ";
@@ -176,4 +178,180 @@ protected function buildPrompt(array $contextData, string $trigger, bool $isPro)
176178

177179
return $system . $context . "\nDica:";
178180
}
181+
182+
/**
183+
* Generate a 3-paragraph strategic conclusion for the consulting PDF.
184+
* Formal, technical style. Cites "Regra 50/30/20 da Vertex". Returns null on failure.
185+
*
186+
* @param array{budget_analysis: array, financial_score: int, metrics?: array} $contextData
187+
*/
188+
public function generateConsultingConclusion(array $contextData): ?string
189+
{
190+
$apiKey = $this->getApiKey();
191+
if (! $apiKey) {
192+
return null;
193+
}
194+
195+
$prompt = $this->buildConsultingConclusionPrompt($contextData);
196+
197+
try {
198+
$url = self::API_BASE . '/models/' . self::MODEL . ':generateContent?key=' . $apiKey;
199+
$response = $this->http->post($url, [
200+
RequestOptions::JSON => [
201+
'contents' => [
202+
[
203+
'parts' => [
204+
['text' => $prompt],
205+
],
206+
],
207+
],
208+
'generationConfig' => [
209+
'maxOutputTokens' => 512,
210+
'temperature' => 0.6,
211+
'topP' => 0.9,
212+
],
213+
],
214+
]);
215+
216+
$body = json_decode((string) $response->getBody(), true);
217+
$text = $body['candidates'][0]['content']['parts'][0]['text'] ?? null;
218+
219+
if ($text === null || trim($text) === '') {
220+
return null;
221+
}
222+
223+
return trim($text);
224+
} catch (GuzzleException $e) {
225+
Log::warning('Gemini API consulting conclusion failed', ['message' => $e->getMessage()]);
226+
227+
return null;
228+
} catch (\Throwable $e) {
229+
Log::warning('Gemini API consulting conclusion error', ['message' => $e->getMessage()]);
230+
231+
return null;
232+
}
233+
}
234+
235+
/**
236+
* Build prompt for consulting conclusion. Token-optimized, formal.
237+
*
238+
* @param array{budget_analysis: array, financial_score: int, metrics?: array} $contextData
239+
*/
240+
protected function buildConsultingConclusionPrompt(array $contextData): string
241+
{
242+
$score = $contextData['financial_score'] ?? 0;
243+
$budget = $contextData['budget_analysis'] ?? [];
244+
$pillars = $budget['pillars'] ?? [];
245+
$metrics = $contextData['metrics'] ?? [];
246+
$savingsPct = $budget['savings_pct'] ?? 0;
247+
$income = $metrics['income'] ?? 0;
248+
$expense = $metrics['expense'] ?? 0;
249+
$balance = $metrics['account_balance'] ?? 0;
250+
251+
$essential = $pillars['essential'] ?? [];
252+
$lifestyle = $pillars['lifestyle'] ?? [];
253+
$financial = $pillars['financial'] ?? [];
254+
255+
$deviations = [];
256+
if (($essential['status'] ?? '') === 'over') {
257+
$deviations[] = 'Essencial acima da meta (' . ($essential['deviation'] ?? 0) . '%)';
258+
}
259+
if (($essential['status'] ?? '') === 'under') {
260+
$deviations[] = 'Essencial abaixo da meta';
261+
}
262+
if (($lifestyle['status'] ?? '') === 'over') {
263+
$deviations[] = 'Estilo de vida acima da meta (' . ($lifestyle['deviation'] ?? 0) . '%)';
264+
}
265+
if (($financial['status'] ?? '') === 'under') {
266+
$deviations[] = 'Financeiro abaixo da meta';
267+
}
268+
if ($savingsPct < 20) {
269+
$deviations[] = 'Taxa de poupança ' . round($savingsPct, 1) . '% (meta 20%)';
270+
}
271+
272+
$devText = empty($deviations) ? 'Aderência adequada à regra' : implode('; ', $deviations);
273+
274+
$system = "Você é o CFO da Vertex. Escreva a Conclusão Estratégica do Especialista para um relatório financeiro. ";
275+
$system .= "3 parágrafos, formal e técnico. Use metodologia Regra 50/30/20 da Vertex. ";
276+
$system .= "Cite oportunidades baseadas nos desvios encontrados. Sem nome ou dados pessoais. Responda apenas o texto.\n\n";
277+
278+
$context = "Contexto:\n";
279+
$context .= "- Score: {$score}/100 | Poupança real: " . round($savingsPct, 1) . "%\n";
280+
$context .= "- Desvios: {$devText}\n";
281+
$context .= "- Renda: R\$ " . number_format($income, 2, ',', '.') . " | Despesas: R\$ " . number_format($expense, 2, ',', '.') . " | Saldo: R\$ " . number_format($balance, 2, ',', '.') . "\n";
282+
283+
return $system . $context . "\nConclusão estratégica:";
284+
}
285+
286+
/**
287+
* Generate 1-year patrimony projection based on current savings rate.
288+
*
289+
* @param array{reserve_months: float, savings_rate: float, balance: float, monthly_income: float, monthly_expense: float} $contextData
290+
*/
291+
public function generateOneYearProjection(array $contextData): ?string
292+
{
293+
$apiKey = $this->getApiKey();
294+
if (! $apiKey) {
295+
return null;
296+
}
297+
298+
$prompt = $this->buildProjectionPrompt($contextData);
299+
300+
try {
301+
$url = self::API_BASE . '/models/' . self::MODEL . ':generateContent?key=' . $apiKey;
302+
$response = $this->http->post($url, [
303+
RequestOptions::JSON => [
304+
'contents' => [
305+
[
306+
'parts' => [
307+
['text' => $prompt],
308+
],
309+
],
310+
],
311+
'generationConfig' => [
312+
'maxOutputTokens' => 256,
313+
'temperature' => 0.6,
314+
'topP' => 0.9,
315+
],
316+
],
317+
]);
318+
319+
$body = json_decode((string) $response->getBody(), true);
320+
$text = $body['candidates'][0]['content']['parts'][0]['text'] ?? null;
321+
322+
if ($text === null || trim($text) === '') {
323+
return null;
324+
}
325+
326+
return trim($text);
327+
} catch (GuzzleException $e) {
328+
Log::warning('Gemini API projection failed', ['message' => $e->getMessage()]);
329+
330+
return null;
331+
} catch (\Throwable $e) {
332+
Log::warning('Gemini API projection error', ['message' => $e->getMessage()]);
333+
334+
return null;
335+
}
336+
}
337+
338+
/**
339+
* @param array{reserve_months: float, savings_rate: float, balance: float, monthly_income: float, monthly_expense: float} $contextData
340+
*/
341+
protected function buildProjectionPrompt(array $contextData): string
342+
{
343+
$reserveMonths = $contextData['reserve_months'] ?? 0;
344+
$savingsRate = $contextData['savings_rate'] ?? 0;
345+
$balance = $contextData['balance'] ?? 0;
346+
$income = $contextData['monthly_income'] ?? 0;
347+
$expense = $contextData['monthly_expense'] ?? 0;
348+
349+
$system = "Você é o Vertex Bot CFO. Projete o patrimônio do usuário em 1 ano com base nos dados atuais. ";
350+
$system .= "2 a 4 frases, português Brasil. Cite a Regra 50/30/20 da Vertex. Sem nome ou PII. Apenas o texto.\n\n";
351+
352+
$context = "Saldo: R\$ " . number_format($balance, 2, ',', '.') . " | Reserva: " . round($reserveMonths, 1) . " meses | ";
353+
$context .= "Taxa poupança: " . round($savingsRate, 1) . "% | Renda: R\$ " . number_format($income, 2, ',', '.') . " | Despesas: R\$ " . number_format($expense, 2, ',', '.') . "\n";
354+
355+
return $system . $context . "\nProjeção em 1 ano:";
356+
}
179357
}

0 commit comments

Comments
 (0)