|
10 | 10 | use Illuminate\Support\Facades\DB; |
11 | 11 | use Modules\Core\Models\Account; |
12 | 12 | use Modules\Core\Models\RecurringTransaction; |
| 13 | +use Modules\Core\Models\Ticket; |
13 | 14 | use Modules\Core\Models\Transaction; |
14 | 15 |
|
15 | 16 | class FinancialHealthService |
@@ -41,6 +42,42 @@ public function getUserFinancialSnapshot(User $user): array |
41 | 42 | ]; |
42 | 43 | } |
43 | 44 |
|
| 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 | + |
44 | 81 | /** |
45 | 82 | * Batch monthly income for multiple users (avoids N+1 in lists). |
46 | 83 | */ |
@@ -199,15 +236,89 @@ public function getBudgetHealthAnalysis(User $user): array |
199 | 236 | ]; |
200 | 237 | } |
201 | 238 |
|
| 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 | + |
202 | 312 | /** |
203 | 313 | * 50/30/20 breakdown: percentages of income in each pillar. |
204 | 314 | * 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). |
205 | 316 | * |
206 | 317 | * @return array{essential_pct: float, want_pct: float, savings_pct: float, lifestyle_pct: float, financial_pct: float} |
207 | 318 | */ |
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 |
209 | 320 | { |
210 | | - $income = $this->getBaselineIncome($user); |
| 321 | + $income = $overrideIncome > 0 ? $overrideIncome : $this->getBaselineIncome($user); |
211 | 322 | if ($income <= 0) { |
212 | 323 | $income = (float) Transaction::where('user_id', $user->id) |
213 | 324 | ->where('type', 'income') |
@@ -260,21 +371,67 @@ public function get503020Breakdown(User $user, Carbon $start, Carbon $end): arra |
260 | 371 | } |
261 | 372 |
|
262 | 373 | /** |
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). |
264 | 402 | */ |
265 | | - public function getReserveMonths(User $user): float |
| 403 | + public function getReserveMonths(User $user, int $avgMonths = 3): float |
266 | 404 | { |
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); |
270 | 407 |
|
271 | 408 | if ($monthlyExpenses <= 0) { |
272 | | - return $balance > 0 ? 999.0 : 0.0; |
| 409 | + return 0.0; |
273 | 410 | } |
274 | 411 |
|
275 | 412 | return round($balance / $monthlyExpenses, 2); |
276 | 413 | } |
277 | 414 |
|
| 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 | + |
278 | 435 | /** |
279 | 436 | * Max consecutive days with at least one completed transaction in the last N days. |
280 | 437 | */ |
|
0 commit comments