Skip to content

Commit 03639bb

Browse files
committed
feat: Enhance financial health features by adding new methods for user risk assessment, improving dashboard insights, and refining gamification rules for better user engagement.
1 parent e7eeb10 commit 03639bb

90 files changed

Lines changed: 3448 additions & 513 deletions

File tree

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/Services/FinancialHealthService.php

Lines changed: 165 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use Illuminate\Support\Facades\DB;
1111
use Modules\Core\Models\Account;
1212
use Modules\Core\Models\RecurringTransaction;
13+
use Modules\Core\Models\Ticket;
1314
use Modules\Core\Models\Transaction;
1415

1516
class FinancialHealthService
@@ -41,6 +42,42 @@ public function getUserFinancialSnapshot(User $user): array
4142
];
4243
}
4344

45+
/**
46+
* Count users with open/pending tickets and support access who have negative free cashflow.
47+
* Used by Support Dashboard for "Usuários em Risco Financeiro" card.
48+
*
49+
* @param int $maxUsers Limit for performance (default 50)
50+
*/
51+
public function getUsersAtFinancialRiskCount(int $maxUsers = 50): int
52+
{
53+
$userIds = Ticket::query()
54+
->whereIn('status', ['open', 'pending'])
55+
->distinct()
56+
->pluck('user_id')
57+
->take($maxUsers)
58+
->toArray();
59+
60+
if (empty($userIds)) {
61+
return 0;
62+
}
63+
64+
$usersWithAccess = User::query()
65+
->whereIn('id', $userIds)
66+
->whereNotNull('support_access_expires_at')
67+
->where('support_access_expires_at', '>', now())
68+
->get();
69+
70+
$count = 0;
71+
foreach ($usersWithAccess as $user) {
72+
$snapshot = $this->getUserFinancialSnapshot($user);
73+
if (($snapshot['free_cashflow'] ?? 0) < 0) {
74+
$count++;
75+
}
76+
}
77+
78+
return $count;
79+
}
80+
4481
/**
4582
* Batch monthly income for multiple users (avoids N+1 in lists).
4683
*/
@@ -199,15 +236,89 @@ public function getBudgetHealthAnalysis(User $user): array
199236
];
200237
}
201238

239+
/**
240+
* Days since last modification of any baseline income (for anti-gaming).
241+
* Returns 999 if never modified or no baseline; 0 if modified today.
242+
*/
243+
public function getBaselineStableDays(User $user): int
244+
{
245+
$lastModified = RecurringTransaction::where('user_id', $user->id)
246+
->where('type', 'income')
247+
->where('is_baseline', true)
248+
->max('updated_at');
249+
250+
if (! $lastModified) {
251+
return 999;
252+
}
253+
254+
return (int) now()->startOfDay()->diffInDays(Carbon::parse($lastModified)->startOfDay(), false);
255+
}
256+
257+
/**
258+
* Average monthly income from actual Transaction records (real receipts).
259+
* Used to detect baseline inflation vs. realized income.
260+
*/
261+
public function getRealizedMonthlyIncomeAverage(User $user, int $months = 3): float
262+
{
263+
if ($months < 1) {
264+
return 0.0;
265+
}
266+
267+
$totals = [];
268+
for ($i = 0; $i < $months; $i++) {
269+
$start = now()->subMonths($i)->startOfMonth();
270+
$end = now()->subMonths($i)->endOfMonth();
271+
$total = (float) Transaction::where('user_id', $user->id)
272+
->where('type', 'income')
273+
->where('status', 'completed')
274+
->whereBetween('date', [$start, $end])
275+
->sum('amount');
276+
$totals[] = $total;
277+
}
278+
279+
$sum = array_sum($totals);
280+
return $sum > 0 ? round($sum / $months, 2) : 0.0;
281+
}
282+
283+
/**
284+
* Income to use for medal evaluation (anti-gaming: prevents temporary baseline inflation).
285+
* Uses min(baseline, realized) when baseline was recently changed or suspiciously higher than realized.
286+
*/
287+
public function getEffectiveIncomeForMedals(User $user, Carbon $start, Carbon $end): float
288+
{
289+
$baseline = $this->getBaselineIncome($user);
290+
if ($baseline <= 0) {
291+
return (float) Transaction::where('user_id', $user->id)
292+
->where('type', 'income')
293+
->where('status', 'completed')
294+
->whereBetween('date', [$start, $end])
295+
->sum('amount');
296+
}
297+
298+
$stableDays = $this->getBaselineStableDays($user);
299+
$realizedAvg = $this->getRealizedMonthlyIncomeAverage($user, 3);
300+
301+
if ($stableDays < 30 && $realizedAvg > 0) {
302+
return min($baseline, $realizedAvg);
303+
}
304+
305+
if ($realizedAvg > 0 && $baseline > $realizedAvg * 1.15) {
306+
return min($baseline, $realizedAvg);
307+
}
308+
309+
return $baseline;
310+
}
311+
202312
/**
203313
* 50/30/20 breakdown: percentages of income in each pillar.
204314
* Uses type_group (essential, lifestyle, financial). Returns want_pct/savings_pct for RuleEngine compatibility.
315+
* When $overrideIncome > 0, uses it instead of baseline (for medal anti-gaming).
205316
*
206317
* @return array{essential_pct: float, want_pct: float, savings_pct: float, lifestyle_pct: float, financial_pct: float}
207318
*/
208-
public function get503020Breakdown(User $user, Carbon $start, Carbon $end): array
319+
public function get503020Breakdown(User $user, Carbon $start, Carbon $end, ?float $overrideIncome = null): array
209320
{
210-
$income = $this->getBaselineIncome($user);
321+
$income = $overrideIncome > 0 ? $overrideIncome : $this->getBaselineIncome($user);
211322
if ($income <= 0) {
212323
$income = (float) Transaction::where('user_id', $user->id)
213324
->where('type', 'income')
@@ -260,21 +371,67 @@ public function get503020Breakdown(User $user, Carbon $start, Carbon $end): arra
260371
}
261372

262373
/**
263-
* Reserve months: account_balance / monthly_expenses (0 if no expenses).
374+
* Average monthly expenses over the last N months.
375+
* Used for reserve calculation to avoid gaming (e.g. zero expenses in current month).
376+
*/
377+
public function getMonthlyExpensesAverage(User $user, int $months = 3): float
378+
{
379+
if ($months < 1) {
380+
return 0.0;
381+
}
382+
383+
$totals = [];
384+
for ($i = 0; $i < $months; $i++) {
385+
$start = now()->subMonths($i)->startOfMonth();
386+
$end = now()->subMonths($i)->endOfMonth();
387+
$total = (float) Transaction::where('user_id', $user->id)
388+
->where('type', 'expense')
389+
->where('status', 'completed')
390+
->whereBetween('date', [$start, $end])
391+
->sum('amount');
392+
$totals[] = $total;
393+
}
394+
395+
$sum = array_sum($totals);
396+
return $sum > 0 ? round($sum / $months, 2) : 0.0;
397+
}
398+
399+
/**
400+
* Reserve months: account_balance / average_monthly_expenses.
401+
* Returns 0 if no meaningful expenses (anti-gaming: no infinite reserve).
264402
*/
265-
public function getReserveMonths(User $user): float
403+
public function getReserveMonths(User $user, int $avgMonths = 3): float
266404
{
267-
$snapshot = $this->getUserFinancialSnapshot($user);
268-
$balance = (float) $snapshot['account_balance'];
269-
$monthlyExpenses = (float) $snapshot['monthly_expenses'];
405+
$balance = (float) Account::where('user_id', $user->id)->sum('balance');
406+
$monthlyExpenses = $this->getMonthlyExpensesAverage($user, $avgMonths);
270407

271408
if ($monthlyExpenses <= 0) {
272-
return $balance > 0 ? 999.0 : 0.0;
409+
return 0.0;
273410
}
274411

275412
return round($balance / $monthlyExpenses, 2);
276413
}
277414

415+
/**
416+
* Transaction count within date range (for rule guards).
417+
*/
418+
public function getTransactionCount(User $user, Carbon $start, Carbon $end): int
419+
{
420+
return Transaction::where('user_id', $user->id)
421+
->where('status', 'completed')
422+
->whereBetween('date', [$start, $end])
423+
->count();
424+
}
425+
426+
/**
427+
* Account age in days (for rule guards).
428+
*/
429+
public function getAccountAgeDays(User $user): int
430+
{
431+
$createdAt = $user->created_at ?? now();
432+
return (int) now()->diffInDays($createdAt, false);
433+
}
434+
278435
/**
279436
* Max consecutive days with at least one completed transaction in the last N days.
280437
*/
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
@props([
2+
'score' => 0,
3+
'label' => 'Em análise',
4+
'scoreTextClass' => 'text-slate-500 dark:text-slate-400',
5+
'scoreBorderClass' => 'hover:border-slate-400/30',
6+
'description' => '0-100: poupança, reserva e consistência',
7+
])
8+
9+
@php
10+
$pct = min(100, max(0, $score)) / 100;
11+
$angle = 180 * (1 - $pct);
12+
$rad = deg2rad($angle);
13+
$cx = 100;
14+
$cy = 90;
15+
$r = 80;
16+
$x2 = $cx + $r * cos($rad);
17+
$y2 = $cy + $r * sin($rad);
18+
$strokeHex = $score === 0 ? '#94a3b8' : ($score <= 40 ? '#ef4444' : ($score <= 70 ? '#f59e0b' : '#22c55e'));
19+
@endphp
20+
21+
<div {{ $attributes->merge([
22+
'class' => "group relative overflow-hidden bg-white dark:bg-gray-900/50 hover:bg-gray-50 dark:hover:bg-gray-900 transition-colors duration-200 rounded-3xl border border-gray-200 dark:border-white/5 {$scoreBorderClass} shadow-sm hover:shadow-xl",
23+
]) }}>
24+
<div class="relative p-6 flex flex-col">
25+
{{-- Header: SCORE FINANCEIRO --}}
26+
<p class="text-[10px] font-black text-gray-400 dark:text-gray-500 uppercase tracking-widest mb-4 whitespace-nowrap">
27+
Score Financeiro
28+
</p>
29+
30+
{{-- Center: Gauge + Number + Status --}}
31+
<div class="flex flex-col items-center justify-center">
32+
<div class="relative w-24 h-24 shrink-0">
33+
<svg viewBox="0 0 200 100" class="w-full h-full -scale-y-100">
34+
<path d="M 20 90 A 80 80 0 0 1 180 90" fill="none" stroke="currentColor" stroke-width="10" stroke-linecap="round" class="text-gray-200 dark:text-gray-700" />
35+
<path d="M 20 90 A 80 80 0 0 1 {{ $x2 }} {{ $y2 }}" fill="none" stroke="{{ $strokeHex }}" stroke-width="10" stroke-linecap="round" />
36+
</svg>
37+
<div class="absolute inset-0 flex items-center justify-center pt-2">
38+
<span class="text-2xl font-black text-gray-900 dark:text-white tabular-nums">{{ $score }}</span>
39+
</div>
40+
</div>
41+
<p class="text-sm font-bold {{ $scoreTextClass }} mt-2">{{ $label }}</p>
42+
</div>
43+
44+
{{-- Description: Section at bottom --}}
45+
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-white/5">
46+
<p class="text-[10px] text-gray-500 dark:text-gray-400">{{ $description }}</p>
47+
</div>
48+
</div>
49+
</div>

Modules/Core/resources/views/dashboard.blade.php

Lines changed: 10 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
$userName = $user->full_name ?? $user->first_name ?? 'Membro PRO';
55
$firstName = explode(' ', $userName)[0] ?? $userName;
66
$financialScore = $vertexBot['financial_score'] ?? 0;
7-
$scoreLabel = $financialScore <= 40 ? 'Risco' : ($financialScore <= 70 ? 'Atenção' : 'Saúde Vertex');
8-
$scoreTextClass = $financialScore <= 40 ? 'text-red-600 dark:text-red-400' : ($financialScore <= 70 ? 'text-amber-600 dark:text-amber-400' : 'text-emerald-600 dark:text-emerald-400');
9-
$scoreBorderClass = $financialScore <= 40 ? 'hover:border-red-500/30' : ($financialScore <= 70 ? 'hover:border-amber-500/30' : 'hover:border-emerald-500/30');
7+
// 0 = em análise (sem dados); 1-40 = em risco; 41-70 = atenção; 71-100 = sem risco
8+
$scoreLabel = $financialScore === 0 ? 'Em análise' : ($financialScore <= 40 ? 'Em risco' : ($financialScore <= 70 ? 'Atenção' : 'Sem risco'));
9+
$scoreTextClass = $financialScore === 0 ? 'text-slate-500 dark:text-slate-400' : ($financialScore <= 40 ? 'text-red-600 dark:text-red-400' : ($financialScore <= 70 ? 'text-amber-600 dark:text-amber-400' : 'text-emerald-600 dark:text-emerald-400'));
10+
$scoreBorderClass = $financialScore === 0 ? 'hover:border-slate-400/30' : ($financialScore <= 40 ? 'hover:border-red-500/30' : ($financialScore <= 70 ? 'hover:border-amber-500/30' : 'hover:border-emerald-500/30'));
1011
$greeting = match (true) {
1112
now()->hour < 12 => 'Bom dia',
1213
now()->hour < 18 => 'Boa tarde',
@@ -73,33 +74,12 @@
7374
{{-- Stats Grid - CBAV style (rounded-3xl, border, ícones duotone) --}}
7475
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6">
7576
{{-- Score Financeiro (Gauge) --}}
76-
<div class="group relative overflow-hidden bg-white dark:bg-gray-900/50 hover:bg-gray-50 dark:hover:bg-gray-900 transition-all duration-500 rounded-3xl border border-gray-200 dark:border-white/5 {{ $scoreBorderClass }} shadow-sm hover:shadow-xl">
77-
<div class="relative p-6 flex items-center gap-4">
78-
<div class="relative w-20 h-20 shrink-0">
79-
@php
80-
$pct = min(100, max(0, $financialScore)) / 100;
81-
$angle = 180 * (1 - $pct);
82-
$rad = deg2rad($angle);
83-
$cx = 100; $cy = 90; $r = 80;
84-
$x2 = $cx + $r * cos($rad);
85-
$y2 = $cy + $r * sin($rad);
86-
$strokeHex = $financialScore <= 40 ? '#ef4444' : ($financialScore <= 70 ? '#f59e0b' : '#22c55e');
87-
@endphp
88-
<svg viewBox="0 0 200 100" class="w-full h-full -scale-y-100">
89-
<path d="M 20 90 A 80 80 0 0 1 180 90" fill="none" stroke="#e5e7eb" stroke-width="10" stroke-linecap="round" />
90-
<path d="M 20 90 A 80 80 0 0 1 {{ $x2 }} {{ $y2 }}" fill="none" stroke="{{ $strokeHex }}" stroke-width="10" stroke-linecap="round" />
91-
</svg>
92-
<div class="absolute inset-0 flex items-center justify-center pt-2">
93-
<span class="text-2xl font-black text-gray-900 dark:text-white tabular-nums">{{ $financialScore }}</span>
94-
</div>
95-
</div>
96-
<div>
97-
<p class="text-[10px] font-black text-gray-400 dark:text-gray-500 uppercase tracking-widest">Score Financeiro</p>
98-
<p class="text-sm font-bold {{ $scoreTextClass }} mt-0.5">{{ $scoreLabel }}</p>
99-
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-1">0-100: poupança, reserva e consistência</p>
100-
</div>
101-
</div>
102-
</div>
77+
<x-core::financial-score-card
78+
:score="$financialScore"
79+
:label="$scoreLabel"
80+
:score-text-class="$scoreTextClass"
81+
:score-border-class="$scoreBorderClass"
82+
/>
10383
<div class="group relative overflow-hidden bg-white dark:bg-gray-900/50 rounded-3xl border border-gray-200 dark:border-white/5 hover:border-primary-500/30 shadow-sm hover:shadow-xl transition-all duration-500 p-6">
10484
<div class="w-12 h-12 rounded-2xl bg-primary-500/10 dark:bg-primary-500/20 flex items-center justify-center text-primary-600 dark:text-primary-400 ring-1 ring-black/5 dark:ring-white/10 mb-4 shrink-0">
10585
<x-icon name="wallet" style="duotone" class="w-6 h-6" />

Modules/Gamification/app/Models/Medal.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,23 @@ class Medal extends Model
1212
protected $fillable = [
1313
'title',
1414
'description',
15+
'explanation',
16+
'tips',
17+
'incentive_message',
1518
'icon_name',
1619
'trigger_key',
1720
'color',
1821
'rarity',
22+
'difficulty',
23+
'is_pro_only',
1924
'is_active',
2025
];
2126

27+
public const DIFFICULTIES = ['easy', 'medium', 'hard', 'advanced'];
28+
2229
protected $casts = [
2330
'is_active' => 'boolean',
31+
'is_pro_only' => 'boolean',
2432
];
2533

2634
public function userMedals(): HasMany

0 commit comments

Comments
 (0)