fix(static-analysis): clear phpstan-baseline by fixing all suppressed errors (#183)

## 🚪 Why?

### Problem

PHPStan was running with a baseline of 56 suppressed errors, meaning
static analysis was not enforcing type safety across a significant
portion of the codebase. These errors were real type mismatches,
redundant null-safety operators, and incorrect PHPDoc annotations that
could mask bugs and make the code harder to reason about.

## 🔑 What?

### Changes

- Add `@property` PHPDoc annotations to `Account`, `BankingConnection`,
`ExchangeRate`, and `Transaction` models so Enum casts and typed columns
are visible to PHPStan
- Add `instanceof User` guards in `ScheduleDripEmailsListener`,
`SyncUserToResendListener`, and `FortifyServiceProvider` to properly
narrow `Authenticatable` to `App\Models\User`
- Remove redundant `?? false` and unnecessary nullsafe `?->value` in
`HandleInertiaRequests`
- Fix `SyncBankingConnectionJob`: use `->name` instead of `?->name` on
an always-loaded `bank` relation
- Remove `is_countable()` guard in `BalanceLookup` (parameter is always
`Collection|array`, both countable)
- Remove `?? []` / `?? default` fallbacks on fully-typed array keys
across `BalanceSyncService`, `BinanceBalanceSyncService`,
`BinanceClient`, `BitpandaBalanceSyncService`, `BitpandaClient`,
`IndexaCapitalClient`, `IndexaCapitalBalanceSyncService`, and
`AuthorizationController`
- Fix `BinanceClient::publicClient()` `retry()` call: use `when:` named
argument and `\Throwable` type hint to match `PendingRequest::retry()`
signature
- Update `IndexaCapitalClient::getPerformance()` `@return` to include
`portfolios` and `net_amounts` keys; simplify sync service to remove
dead null checks
- Replace nullsafe chain with ternary in `BudgetPeriodService`
- Replace `match` statement in `SetupMainUser` with `if/else` to
eliminate always-true comparison
- Clear `phpstan-baseline.neon` entirely (was 56 suppressed errors, now
0)

##  Verification

### Tests

- Existing tests pass: PHPStan level 5 reports 0 errors with empty
baseline
This commit is contained in:
Víctor Falcón 2026-03-02 12:22:30 +00:00 committed by GitHub
parent 2a1286e98a
commit 3e087bdcd7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 460 additions and 46 deletions

View File

@ -127,6 +127,22 @@ jobs:
DB_USERNAME: root DB_USERNAME: root
DB_PASSWORD: password DB_PASSWORD: password
static-analysis:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
- name: Install PHP Dependencies
run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
- name: Run PHPStan
run: vendor/bin/phpstan analyse --memory-limit=512M --no-progress
linter: linter:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@ -209,7 +225,7 @@ jobs:
build-image: build-image:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [tests, linter, performance-tests] needs: [tests, linter, static-analysis, performance-tests]
if: (github.ref == 'refs/heads/main' && github.event_name == 'push') || github.event_name == 'workflow_dispatch' if: (github.ref == 'refs/heads/main' && github.event_name == 'push') || github.event_name == 'workflow_dispatch'
permissions: permissions:
contents: read contents: read
@ -253,7 +269,7 @@ jobs:
deploy: deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [tests, linter, performance-tests] needs: [tests, linter, static-analysis, performance-tests]
if: github.ref == 'refs/heads/main' && github.event_name == 'push' if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps: steps:
- name: Trigger deployment - name: Trigger deployment

View File

@ -106,7 +106,7 @@ class ResetDemoAccountCommand extends Command
$user->automationRules()->forceDelete(); $user->automationRules()->forceDelete();
$user->categories()->forceDelete(); $user->categories()->forceDelete();
$user->budgets()->forceDelete(); $user->budgets()->forceDelete();
$user->encryptedMessage()?->delete(); $user->encryptedMessage()->delete();
$this->info(' Deleted existing data'); $this->info(' Deleted existing data');
} }

View File

@ -80,11 +80,11 @@ class SetupMainUser extends Command
continue; continue;
} }
match ($fileName) { if ($fileName === 'categories.json') {
'categories.json' => $this->importCategories($user, $data), $this->importCategories($user, $data);
'automated_rules.json' => $this->importAutomationRules($user, $data), } else {
default => $this->warn(" ! Skipping {$fileName} (no importer defined)"), $this->importAutomationRules($user, $data);
}; }
} }
foreach ($jsonFiles as $filePath) { foreach ($jsonFiles as $filePath) {

View File

@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
use App\Enums\CategoryType; use App\Enums\CategoryType;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Category;
use App\Models\Transaction; use App\Models\Transaction;
use App\Services\PeriodComparator; use App\Services\PeriodComparator;
use Carbon\Carbon; use Carbon\Carbon;
@ -204,13 +205,13 @@ class CashflowAnalyticsController extends Controller
if ($uncategorized != 0) { if ($uncategorized != 0) {
$categorized->push([ $categorized->push([
'category_id' => null, 'category_id' => null,
'category' => (object) [ 'category' => (new Category)->forceFill([
'id' => null, 'id' => null,
'name' => $type === CategoryType::Income ? 'Unknown Income' : 'Unknown Expense', 'name' => $type === CategoryType::Income ? 'Unknown Income' : 'Unknown Expense',
'type' => $type, 'type' => $type,
'color' => 'gray', 'color' => 'gray',
'icon' => 'HelpCircle', 'icon' => 'HelpCircle',
], ]),
'amount' => abs($uncategorized), 'amount' => abs($uncategorized),
]); ]);
} }

View File

@ -103,7 +103,7 @@ class AuthorizationController extends Controller
'session_id' => $sessionData['session_id'], 'session_id' => $sessionData['session_id'],
'status' => BankingConnectionStatus::AwaitingMapping, 'status' => BankingConnectionStatus::AwaitingMapping,
'valid_until' => $sessionData['access']['valid_until'] ?? null, 'valid_until' => $sessionData['access']['valid_until'] ?? null,
'pending_accounts_data' => $sessionData['accounts'] ?? [], 'pending_accounts_data' => $sessionData['accounts'],
]); ]);
return redirect()->route('open-banking.map-accounts', $connection); return redirect()->route('open-banking.map-accounts', $connection);

View File

@ -40,7 +40,7 @@ class HandleInertiaRequests extends Middleware
[$message, $author] = str(Inspiring::quotes()->random())->explode('-'); [$message, $author] = str(Inspiring::quotes()->random())->explode('-');
$user = $request->user(); $user = $request->user();
$isDemoAccount = $user?->isDemoAccount() && ! app()->environment('local') ?? false; $isDemoAccount = $user?->isDemoAccount() && ! app()->environment('local');
$isDemoQuery = $request->query('demo') === '1'; $isDemoQuery = $request->query('demo') === '1';
// Cache encryption checks to avoid duplicate queries // Cache encryption checks to avoid duplicate queries
@ -83,7 +83,7 @@ class HandleInertiaRequests extends Middleware
'bestValuePlan' => config('subscriptions.best_value_plan', null), 'bestValuePlan' => config('subscriptions.best_value_plan', null),
'promo' => config('subscriptions.promo', []), 'promo' => config('subscriptions.promo', []),
], ],
'chartColorScheme' => $user?->setting?->chart_color_scheme?->value ?? 'colorful', 'chartColorScheme' => $user?->setting?->chart_color_scheme->value ?? 'colorful',
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
'features' => [ 'features' => [
'cashflow' => true, 'cashflow' => true,

View File

@ -141,7 +141,7 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue
{ {
$dateFrom = $isFirstSync $dateFrom = $isFirstSync
? now()->subYear()->toDateString() ? now()->subYear()->toDateString()
: $connection->last_synced_at->toDateString(); : ($connection->last_synced_at?->toDateString() ?? now()->subMonth()->toDateString());
$dateTo = now()->toDateString(); $dateTo = now()->toDateString();
$strategy = $isFirstSync ? 'longest' : null; $strategy = $isFirstSync ? 'longest' : null;
@ -171,7 +171,7 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue
} }
if ($created > 0) { if ($created > 0) {
$bankName = $account->bank?->name ?? __('Unknown Bank'); $bankName = $account->bank->name ?? __('Unknown Bank');
$transactionsPerBank[$bankName] = ($transactionsPerBank[$bankName] ?? 0) + $created; $transactionsPerBank[$bankName] = ($transactionsPerBank[$bankName] ?? 0) + $created;
} }
} }

View File

@ -7,6 +7,7 @@ use App\Jobs\Drip\SendImportHelpEmailJob;
use App\Jobs\Drip\SendOnboardingReminderEmailJob; use App\Jobs\Drip\SendOnboardingReminderEmailJob;
use App\Jobs\Drip\SendPromoCodeEmailJob; use App\Jobs\Drip\SendPromoCodeEmailJob;
use App\Jobs\Drip\SendWelcomeEmailJob; use App\Jobs\Drip\SendWelcomeEmailJob;
use App\Models\User;
use Illuminate\Auth\Events\Registered; use Illuminate\Auth\Events\Registered;
class ScheduleDripEmailsListener class ScheduleDripEmailsListener
@ -19,6 +20,10 @@ class ScheduleDripEmailsListener
$user = $event->user; $user = $event->user;
if (! $user instanceof User) {
return;
}
SendWelcomeEmailJob::dispatch($user)->delay(now()->addMinutes(30)); SendWelcomeEmailJob::dispatch($user)->delay(now()->addMinutes(30));
SendOnboardingReminderEmailJob::dispatch($user)->delay(now()->addDay()); SendOnboardingReminderEmailJob::dispatch($user)->delay(now()->addDay());
SendPromoCodeEmailJob::dispatch($user)->delay(now()->addDay()); SendPromoCodeEmailJob::dispatch($user)->delay(now()->addDay());

View File

@ -2,6 +2,7 @@
namespace App\Listeners; namespace App\Listeners;
use App\Models\User;
use App\Services\ResendService; use App\Services\ResendService;
use Illuminate\Auth\Events\Verified; use Illuminate\Auth\Events\Verified;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
@ -19,6 +20,12 @@ class SyncUserToResendListener implements ShouldQueue
return; return;
} }
$this->resendService->createContact($event->user); $user = $event->user;
if (! $user instanceof User) {
return;
}
$this->resendService->createContact($user);
} }
} }

View File

@ -10,6 +10,9 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
/**
* @property \App\Enums\AccountType $type
*/
class Account extends Model class Account extends Model
{ {
/** @use HasFactory<\Database\Factories\AccountFactory> */ /** @use HasFactory<\Database\Factories\AccountFactory> */
@ -37,26 +40,31 @@ class Account extends Model
]; ];
} }
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo public function user(): BelongsTo
{ {
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
/** @return BelongsTo<Bank, $this> */
public function bank(): BelongsTo public function bank(): BelongsTo
{ {
return $this->belongsTo(Bank::class); return $this->belongsTo(Bank::class);
} }
/** @return HasMany<Transaction, $this> */
public function transactions(): HasMany public function transactions(): HasMany
{ {
return $this->hasMany(Transaction::class); return $this->hasMany(Transaction::class);
} }
/** @return HasMany<AccountBalance, $this> */
public function balances(): HasMany public function balances(): HasMany
{ {
return $this->hasMany(AccountBalance::class); return $this->hasMany(AccountBalance::class);
} }
/** @return BelongsTo<BankingConnection, $this> */
public function bankingConnection(): BelongsTo public function bankingConnection(): BelongsTo
{ {
return $this->belongsTo(BankingConnection::class); return $this->belongsTo(BankingConnection::class);

View File

@ -7,6 +7,9 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property \Carbon\Carbon $balance_date
*/
class AccountBalance extends Model class AccountBalance extends Model
{ {
/** @use HasFactory<\Database\Factories\AccountBalanceFactory> */ /** @use HasFactory<\Database\Factories\AccountBalanceFactory> */
@ -28,6 +31,7 @@ class AccountBalance extends Model
]; ];
} }
/** @return BelongsTo<Account, $this> */
public function account(): BelongsTo public function account(): BelongsTo
{ {
return $this->belongsTo(Account::class); return $this->belongsTo(Account::class);

View File

@ -32,11 +32,13 @@ class AutomationRule extends Model
]; ];
} }
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo public function user(): BelongsTo
{ {
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
/** @return BelongsTo<Category, $this> */
public function category(): BelongsTo public function category(): BelongsTo
{ {
return $this->belongsTo(Category::class, 'action_category_id'); return $this->belongsTo(Category::class, 'action_category_id');

View File

@ -20,11 +20,13 @@ class Bank extends Model
'user_id', 'user_id',
]; ];
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo public function user(): BelongsTo
{ {
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
/** @return HasMany<Account, $this> */
public function accounts(): HasMany public function accounts(): HasMany
{ {
return $this->hasMany(Account::class); return $this->hasMany(Account::class);

View File

@ -10,6 +10,13 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
/**
* @property bool $has_pending_accounts
* @property \App\Enums\BankingConnectionStatus $status
* @property \Carbon\Carbon|null $valid_until
* @property \Carbon\Carbon|null $last_synced_at
* @property array<int, mixed>|null $pending_accounts_data
*/
class BankingConnection extends Model class BankingConnection extends Model
{ {
/** @use HasFactory<\Database\Factories\BankingConnectionFactory> */ /** @use HasFactory<\Database\Factories\BankingConnectionFactory> */
@ -52,11 +59,13 @@ class BankingConnection extends Model
]; ];
} }
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo public function user(): BelongsTo
{ {
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
/** @return HasMany<Account, $this> */
public function accounts(): HasMany public function accounts(): HasMany
{ {
return $this->hasMany(Account::class); return $this->hasMany(Account::class);

View File

@ -11,6 +11,10 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
/**
* @property \App\Enums\RolloverType $rollover_type
* @property \App\Enums\BudgetPeriodType $period_type
*/
class Budget extends Model class Budget extends Model
{ {
use HasFactory, HasUuids, SoftDeletes; use HasFactory, HasUuids, SoftDeletes;
@ -36,21 +40,25 @@ class Budget extends Model
]; ];
} }
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo public function user(): BelongsTo
{ {
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
/** @return BelongsTo<Category, $this> */
public function category(): BelongsTo public function category(): BelongsTo
{ {
return $this->belongsTo(Category::class); return $this->belongsTo(Category::class);
} }
/** @return BelongsTo<Label, $this> */
public function label(): BelongsTo public function label(): BelongsTo
{ {
return $this->belongsTo(Label::class); return $this->belongsTo(Label::class);
} }
/** @return HasMany<BudgetPeriod, $this> */
public function periods(): HasMany public function periods(): HasMany
{ {
return $this->hasMany(BudgetPeriod::class); return $this->hasMany(BudgetPeriod::class);

View File

@ -8,6 +8,10 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @property \Carbon\Carbon $start_date
* @property \Carbon\Carbon $end_date
*/
class BudgetPeriod extends Model class BudgetPeriod extends Model
{ {
use HasFactory, HasUuids; use HasFactory, HasUuids;
@ -32,11 +36,13 @@ class BudgetPeriod extends Model
]; ];
} }
/** @return BelongsTo<Budget, $this> */
public function budget(): BelongsTo public function budget(): BelongsTo
{ {
return $this->belongsTo(Budget::class); return $this->belongsTo(Budget::class);
} }
/** @return HasMany<BudgetTransaction, $this> */
public function budgetTransactions(): HasMany public function budgetTransactions(): HasMany
{ {
return $this->hasMany(BudgetTransaction::class); return $this->hasMany(BudgetTransaction::class);

View File

@ -24,11 +24,13 @@ class BudgetTransaction extends Model
]; ];
} }
/** @return BelongsTo<Transaction, $this> */
public function transaction(): BelongsTo public function transaction(): BelongsTo
{ {
return $this->belongsTo(Transaction::class); return $this->belongsTo(Transaction::class);
} }
/** @return BelongsTo<BudgetPeriod, $this> */
public function budgetPeriod(): BelongsTo public function budgetPeriod(): BelongsTo
{ {
return $this->belongsTo(BudgetPeriod::class); return $this->belongsTo(BudgetPeriod::class);

View File

@ -30,11 +30,13 @@ class Category extends Model
]; ];
} }
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo public function user(): BelongsTo
{ {
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
/** @return HasMany<Transaction, $this> */
public function transactions(): HasMany public function transactions(): HasMany
{ {
return $this->hasMany(Transaction::class); return $this->hasMany(Transaction::class);

View File

@ -16,6 +16,7 @@ class EncryptedMessage extends Model
'iv', 'iv',
]; ];
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo public function user(): BelongsTo
{ {
return $this->belongsTo(User::class); return $this->belongsTo(User::class);

View File

@ -5,6 +5,9 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
/**
* @property array<string, float> $rates
*/
class ExchangeRate extends Model class ExchangeRate extends Model
{ {
/** @use HasFactory<\Database\Factories\ExchangeRateFactory> */ /** @use HasFactory<\Database\Factories\ExchangeRateFactory> */

View File

@ -20,6 +20,7 @@ class Label extends Model
'user_id', 'user_id',
]; ];
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo public function user(): BelongsTo
{ {
return $this->belongsTo(User::class); return $this->belongsTo(User::class);

View File

@ -15,6 +15,10 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
/**
* @property \Carbon\Carbon $transaction_date
* @property int|float $total_amount
*/
class Transaction extends Model class Transaction extends Model
{ {
/** @use HasFactory<\Database\Factories\TransactionFactory> */ /** @use HasFactory<\Database\Factories\TransactionFactory> */
@ -54,16 +58,19 @@ class Transaction extends Model
]; ];
} }
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo public function user(): BelongsTo
{ {
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
/** @return BelongsTo<Account, $this> */
public function account(): BelongsTo public function account(): BelongsTo
{ {
return $this->belongsTo(Account::class); return $this->belongsTo(Account::class);
} }
/** @return BelongsTo<Category, $this> */
public function category(): BelongsTo public function category(): BelongsTo
{ {
return $this->belongsTo(Category::class); return $this->belongsTo(Category::class);
@ -76,6 +83,7 @@ class Transaction extends Model
->withTimestamps(); ->withTimestamps();
} }
/** @return HasMany<BudgetTransaction, $this> */
public function budgetTransactions(): HasMany public function budgetTransactions(): HasMany
{ {
return $this->hasMany(BudgetTransaction::class); return $this->hasMany(BudgetTransaction::class);

View File

@ -69,31 +69,37 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma
return $this->onboarded_at !== null; return $this->onboarded_at !== null;
} }
/** @return HasOne<UserSetting, $this> */
public function setting(): HasOne public function setting(): HasOne
{ {
return $this->hasOne(UserSetting::class); return $this->hasOne(UserSetting::class);
} }
/** @return HasOne<EncryptedMessage, $this> */
public function encryptedMessage(): HasOne public function encryptedMessage(): HasOne
{ {
return $this->hasOne(EncryptedMessage::class); return $this->hasOne(EncryptedMessage::class);
} }
/** @return HasMany<Transaction, $this> */
public function transactions(): HasMany public function transactions(): HasMany
{ {
return $this->hasMany(Transaction::class); return $this->hasMany(Transaction::class);
} }
/** @return HasMany<Account, $this> */
public function accounts(): HasMany public function accounts(): HasMany
{ {
return $this->hasMany(Account::class); return $this->hasMany(Account::class);
} }
/** @return HasMany<Category, $this> */
public function categories(): HasMany public function categories(): HasMany
{ {
return $this->hasMany(Category::class); return $this->hasMany(Category::class);
} }
/** @return HasMany<Bank, $this> */
public function banks(): HasMany public function banks(): HasMany
{ {
return $this->hasMany(Bank::class) return $this->hasMany(Bank::class)
@ -103,26 +109,31 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma
}); });
} }
/** @return HasMany<AutomationRule, $this> */
public function automationRules(): HasMany public function automationRules(): HasMany
{ {
return $this->hasMany(AutomationRule::class); return $this->hasMany(AutomationRule::class);
} }
/** @return HasMany<Label, $this> */
public function labels(): HasMany public function labels(): HasMany
{ {
return $this->hasMany(Label::class); return $this->hasMany(Label::class);
} }
/** @return HasMany<UserMailLog, $this> */
public function mailLogs(): HasMany public function mailLogs(): HasMany
{ {
return $this->hasMany(UserMailLog::class); return $this->hasMany(UserMailLog::class);
} }
/** @return HasMany<Budget, $this> */
public function budgets(): HasMany public function budgets(): HasMany
{ {
return $this->hasMany(Budget::class); return $this->hasMany(Budget::class);
} }
/** @return HasMany<BankingConnection, $this> */
public function bankingConnections(): HasMany public function bankingConnections(): HasMany
{ {
return $this->hasMany(BankingConnection::class); return $this->hasMany(BankingConnection::class);

View File

@ -8,6 +8,9 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property \App\Enums\ChartColorScheme $chart_color_scheme
*/
class UserSetting extends Model class UserSetting extends Model
{ {
/** @use HasFactory<\Database\Factories\UserSettingFactory> */ /** @use HasFactory<\Database\Factories\UserSettingFactory> */

View File

@ -7,6 +7,7 @@ use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\ResetUserPassword; use App\Actions\Fortify\ResetUserPassword;
use App\Http\Responses\LoginResponse; use App\Http\Responses\LoginResponse;
use App\Http\Responses\TwoFactorLoginResponse; use App\Http\Responses\TwoFactorLoginResponse;
use App\Models\User;
use Illuminate\Auth\Events\Registered; use Illuminate\Auth\Events\Registered;
use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -106,7 +107,13 @@ class FortifyServiceProvider extends ServiceProvider
private function configureEventListeners(): void private function configureEventListeners(): void
{ {
Event::listen(function (Registered $event) { Event::listen(function (Registered $event) {
app(CreateDefaultCategories::class)->handle($event->user); $user = $event->user;
if (! $user instanceof User) {
return;
}
app(CreateDefaultCategories::class)->handle($user);
}); });
} }
} }

View File

@ -86,7 +86,7 @@ class AccountMetricsService
* Investment accounts get `_invested` entries. * Investment accounts get `_invested` entries.
* *
* @param Collection<int, Account> $accounts * @param Collection<int, Account> $accounts
* @return array{data: list<array<string, mixed>>, accounts: array<string, array<string, mixed>>, currency_code: string} * @return array{data: list<array<string, mixed>>, accounts: mixed, currency_code: string}
*/ */
public function getNetWorthEvolution(string $userCurrency, Collection $accounts, Carbon $start, Carbon $end): array public function getNetWorthEvolution(string $userCurrency, Collection $accounts, Carbon $start, Carbon $end): array
{ {
@ -173,7 +173,7 @@ class AccountMetricsService
* Accounts with a different currency than the user also get `_original` entries. * Accounts with a different currency than the user also get `_original` entries.
* *
* @param Collection<int, Account> $accounts * @param Collection<int, Account> $accounts
* @return array{data: list<array<string, mixed>>, accounts: array<string, array<string, mixed>>, currency_code: string} * @return array{data: list<array<string, mixed>>, accounts: mixed, currency_code: string}
*/ */
public function getNetWorthDailyEvolution(string $userCurrency, Collection $accounts, Carbon $start, Carbon $end): array public function getNetWorthDailyEvolution(string $userCurrency, Collection $accounts, Carbon $start, Carbon $end): array
{ {

View File

@ -38,7 +38,7 @@ class BalanceLookup
{ {
$instance = new self; $instance = new self;
if (empty($accountIds) || (is_countable($accountIds) && count($accountIds) === 0)) { if (empty($accountIds)) {
return $instance; return $instance;
} }

View File

@ -25,7 +25,7 @@ class BalanceSyncService
} }
$result = $this->provider->getBalances($account->external_account_id); $result = $this->provider->getBalances($account->external_account_id);
$balances = $result['balances'] ?? []; $balances = $result['balances'];
if (empty($balances)) { if (empty($balances)) {
return; return;
@ -69,7 +69,7 @@ class BalanceSyncService
$existingDates = $account->balances() $existingDates = $account->balances()
->pluck('balance_date') ->pluck('balance_date')
->map(fn ($date) => $date->toDateString()) ->map(fn (mixed $date) => $date instanceof \Carbon\Carbon ? $date->toDateString() : (string) $date)
->flip() ->flip()
->all(); ->all();

View File

@ -71,7 +71,7 @@ class BinanceBalanceSyncService
public function syncCurrentBalance(Account $account, BinanceClient $client, ?int $investedAmountCents = null): void public function syncCurrentBalance(Account $account, BinanceClient $client, ?int $investedAmountCents = null): void
{ {
$accountData = $client->getAccount(); $accountData = $client->getAccount();
$balances = $accountData['balances'] ?? []; $balances = $accountData['balances'];
if (empty($balances)) { if (empty($balances)) {
return; return;
@ -194,7 +194,7 @@ class BinanceBalanceSyncService
self::SNAPSHOT_WINDOW_DAYS, self::SNAPSHOT_WINDOW_DAYS,
); );
foreach ($response['snapshotVos'] ?? [] as $snapshot) { foreach ($response['snapshotVos'] as $snapshot) {
$snapshots[] = $snapshot; $snapshots[] = $snapshot;
} }

View File

@ -143,7 +143,7 @@ class BinanceClient
->acceptJson() ->acceptJson()
->retry( ->retry(
self::RETRY_BACKOFF_MS, self::RETRY_BACKOFF_MS,
fn (Exception $e) => $e instanceof RequestException && $e->response->status() === 429, when: fn (\Throwable $e) => $e instanceof RequestException && $e->response->status() === 429,
); );
} }
} }

View File

@ -56,11 +56,11 @@ class BitpandaBalanceSyncService
$wallets = $client->getCryptoWallets(); $wallets = $client->getCryptoWallets();
$total = 0.0; $total = 0.0;
foreach ($wallets['data'] ?? [] as $wallet) { foreach ($wallets['data'] as $wallet) {
$attributes = $wallet['attributes'] ?? []; $attributes = $wallet['attributes'];
$balance = (float) ($attributes['balance'] ?? 0); $balance = (float) $attributes['balance'];
$symbol = $attributes['cryptocoin_symbol'] ?? null; $symbol = $attributes['cryptocoin_symbol'];
$deleted = $attributes['deleted'] ?? false; $deleted = $attributes['deleted'];
if ($balance <= 0 || ! $symbol || $deleted) { if ($balance <= 0 || ! $symbol || $deleted) {
continue; continue;
@ -91,10 +91,10 @@ class BitpandaBalanceSyncService
$wallets = $client->getFiatWallets(); $wallets = $client->getFiatWallets();
$total = 0.0; $total = 0.0;
foreach ($wallets['data'] ?? [] as $wallet) { foreach ($wallets['data'] as $wallet) {
$attributes = $wallet['attributes'] ?? []; $attributes = $wallet['attributes'];
$balance = (float) ($attributes['balance'] ?? 0); $balance = (float) $attributes['balance'];
$symbol = strtoupper($attributes['fiat_symbol'] ?? ''); $symbol = strtoupper($attributes['fiat_symbol']);
if ($balance <= 0 || ! $symbol) { if ($balance <= 0 || ! $symbol) {
continue; continue;
@ -161,7 +161,7 @@ class BitpandaBalanceSyncService
$total = 0.0; $total = 0.0;
foreach ($transactions as $transaction) { foreach ($transactions as $transaction) {
$attributes = $transaction['attributes'] ?? []; $attributes = $transaction['attributes'];
$status = $attributes['status'] ?? ''; $status = $attributes['status'] ?? '';
$amount = (float) ($attributes['amount'] ?? 0); $amount = (float) ($attributes['amount'] ?? 0);

View File

@ -115,7 +115,7 @@ class BitpandaClient
do { do {
$response = $this->getFiatTransactions($type, $cursor, 100); $response = $this->getFiatTransactions($type, $cursor, 100);
$transactions = $response['data'] ?? []; $transactions = $response['data'];
if (empty($transactions)) { if (empty($transactions)) {
break; break;
@ -147,7 +147,7 @@ class BitpandaClient
do { do {
$response = $this->getTrades($cursor, 100); $response = $this->getTrades($cursor, 100);
$trades = $response['data'] ?? []; $trades = $response['data'];
if (empty($trades)) { if (empty($trades)) {
break; break;

View File

@ -39,11 +39,7 @@ class IndexaCapitalClient
$accounts = []; $accounts = [];
foreach ($userData['accounts'] ?? [] as $account) { foreach ($userData['accounts'] as $account) {
if (! isset($account['account_number'])) {
continue;
}
$accountDetails = $this->getAccount($account['account_number']); $accountDetails = $this->getAccount($account['account_number']);
$accounts[] = $accountDetails; $accounts[] = $accountDetails;
} }
@ -68,7 +64,7 @@ class IndexaCapitalClient
/** /**
* Get performance data for an account, including current portfolio value. * Get performance data for an account, including current portfolio value.
* *
* @return array{total_amount: float, return: float, return_percentage: float} * @return array{total_amount?: float, return?: float, return_percentage?: float, portfolios?: array<int, array{date?: string, total_amount?: float, return?: float}>, net_amounts?: array<string, float>}
*/ */
public function getPerformance(string $accountNumber): array public function getPerformance(string $accountNumber): array
{ {

View File

@ -20,7 +20,7 @@ class BudgetPeriodService
// If no allocated amount provided, use the last period's amount or 0 // If no allocated amount provided, use the last period's amount or 0
if ($allocatedAmount === null) { if ($allocatedAmount === null) {
$lastPeriod = $budget->periods()->orderBy('end_date', 'desc')->first(); $lastPeriod = $budget->periods()->orderBy('end_date', 'desc')->first();
$allocatedAmount = $lastPeriod?->allocated_amount ?? 0; $allocatedAmount = $lastPeriod !== null ? $lastPeriod->allocated_amount : 0;
} }
return BudgetPeriod::create([ return BudgetPeriod::create([

View File

@ -24,6 +24,7 @@
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",
"larastan/larastan": "^3.9",
"laravel/boost": "^2", "laravel/boost": "^2",
"laravel/pail": "^1.2.2", "laravel/pail": "^1.2.2",
"laravel/pint": "^1.24", "laravel/pint": "^1.24",
@ -33,7 +34,8 @@
"pestphp/pest": "^4.1", "pestphp/pest": "^4.1",
"pestphp/pest-plugin-browser": "^4.0", "pestphp/pest-plugin-browser": "^4.0",
"pestphp/pest-plugin-laravel": "^4.0", "pestphp/pest-plugin-laravel": "^4.0",
"testcontainers/testcontainers": "^1.0" "testcontainers/testcontainers": "^1.0",
"tomasvotruba/unused-public": "^2.2"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {

243
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "3d386f9d4f41f3e20d9431bb81b4e030", "content-hash": "481c17881a66827f27d14db91425942d",
"packages": [ "packages": [
{ {
"name": "bacon/bacon-qr-code", "name": "bacon/bacon-qr-code",
@ -9377,6 +9377,47 @@
}, },
"time": "2025-04-30T06:54:44+00:00" "time": "2025-04-30T06:54:44+00:00"
}, },
{
"name": "iamcal/sql-parser",
"version": "v0.7",
"source": {
"type": "git",
"url": "https://github.com/iamcal/SQLParser.git",
"reference": "610392f38de49a44dab08dc1659960a29874c4b8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/iamcal/SQLParser/zipball/610392f38de49a44dab08dc1659960a29874c4b8",
"reference": "610392f38de49a44dab08dc1659960a29874c4b8",
"shasum": ""
},
"require-dev": {
"php-coveralls/php-coveralls": "^1.0",
"phpunit/phpunit": "^5|^6|^7|^8|^9"
},
"type": "library",
"autoload": {
"psr-4": {
"iamcal\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Cal Henderson",
"email": "cal@iamcal.com"
}
],
"description": "MySQL schema parser",
"support": {
"issues": "https://github.com/iamcal/SQLParser/issues",
"source": "https://github.com/iamcal/SQLParser/tree/v0.7"
},
"time": "2026-01-28T22:20:33+00:00"
},
{ {
"name": "jane-php/json-schema-runtime", "name": "jane-php/json-schema-runtime",
"version": "v7.10.4", "version": "v7.10.4",
@ -9560,6 +9601,96 @@
}, },
"time": "2023-02-03T21:26:53+00:00" "time": "2023-02-03T21:26:53+00:00"
}, },
{
"name": "larastan/larastan",
"version": "v3.9.3",
"source": {
"type": "git",
"url": "https://github.com/larastan/larastan.git",
"reference": "64a52bcc5347c89fdf131cb59f96ebfbc8d1ad65"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/larastan/larastan/zipball/64a52bcc5347c89fdf131cb59f96ebfbc8d1ad65",
"reference": "64a52bcc5347c89fdf131cb59f96ebfbc8d1ad65",
"shasum": ""
},
"require": {
"ext-json": "*",
"iamcal/sql-parser": "^0.7.0",
"illuminate/console": "^11.44.2 || ^12.4.1 || ^13",
"illuminate/container": "^11.44.2 || ^12.4.1 || ^13",
"illuminate/contracts": "^11.44.2 || ^12.4.1 || ^13",
"illuminate/database": "^11.44.2 || ^12.4.1 || ^13",
"illuminate/http": "^11.44.2 || ^12.4.1 || ^13",
"illuminate/pipeline": "^11.44.2 || ^12.4.1 || ^13",
"illuminate/support": "^11.44.2 || ^12.4.1 || ^13",
"php": "^8.2",
"phpstan/phpstan": "^2.1.32"
},
"require-dev": {
"doctrine/coding-standard": "^13",
"laravel/framework": "^11.44.2 || ^12.7.2 || ^13",
"mockery/mockery": "^1.6.12",
"nikic/php-parser": "^5.4",
"orchestra/canvas": "^v9.2.2 || ^10.0.1 || ^11",
"orchestra/testbench-core": "^9.12.0 || ^10.1 || ^11",
"phpstan/phpstan-deprecation-rules": "^2.0.1",
"phpunit/phpunit": "^10.5.35 || ^11.5.15 || ^12.5.8"
},
"suggest": {
"orchestra/testbench": "Using Larastan for analysing a package needs Testbench",
"phpmyadmin/sql-parser": "Install to enable Larastan's optional phpMyAdmin-based SQL parser automatically"
},
"type": "phpstan-extension",
"extra": {
"phpstan": {
"includes": [
"extension.neon"
]
},
"branch-alias": {
"dev-master": "3.0-dev"
}
},
"autoload": {
"psr-4": {
"Larastan\\Larastan\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Can Vural",
"email": "can9119@gmail.com"
}
],
"description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel",
"keywords": [
"PHPStan",
"code analyse",
"code analysis",
"larastan",
"laravel",
"package",
"php",
"static analysis"
],
"support": {
"issues": "https://github.com/larastan/larastan/issues",
"source": "https://github.com/larastan/larastan/tree/v3.9.3"
},
"funding": [
{
"url": "https://github.com/canvural",
"type": "github"
}
],
"time": "2026-02-20T12:07:12+00:00"
},
{ {
"name": "laravel/boost", "name": "laravel/boost",
"version": "v2.2.0", "version": "v2.2.0",
@ -11628,6 +11759,59 @@
}, },
"time": "2025-11-21T15:09:14+00:00" "time": "2025-11-21T15:09:14+00:00"
}, },
{
"name": "phpstan/phpstan",
"version": "2.1.40",
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b",
"reference": "9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b",
"shasum": ""
},
"require": {
"php": "^7.4|^8.0"
},
"conflict": {
"phpstan/phpstan-shim": "*"
},
"bin": [
"phpstan",
"phpstan.phar"
],
"type": "library",
"autoload": {
"files": [
"bootstrap.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHPStan - PHP Static Analysis Tool",
"keywords": [
"dev",
"static analysis"
],
"support": {
"docs": "https://phpstan.org/user-guide/getting-started",
"forum": "https://github.com/phpstan/phpstan/discussions",
"issues": "https://github.com/phpstan/phpstan/issues",
"security": "https://github.com/phpstan/phpstan/security/policy",
"source": "https://github.com/phpstan/phpstan-src"
},
"funding": [
{
"url": "https://github.com/ondrejmirtes",
"type": "github"
},
{
"url": "https://github.com/phpstan",
"type": "github"
}
],
"time": "2026-02-23T15:04:35+00:00"
},
{ {
"name": "phpunit/php-code-coverage", "name": "phpunit/php-code-coverage",
"version": "12.5.3", "version": "12.5.3",
@ -13604,6 +13788,63 @@
], ],
"time": "2025-12-08T11:19:18+00:00" "time": "2025-12-08T11:19:18+00:00"
}, },
{
"name": "tomasvotruba/unused-public",
"version": "2.2.0",
"source": {
"type": "git",
"url": "https://github.com/TomasVotruba/unused-public.git",
"reference": "ec82a9a1e3216eb5db174430a740ad8d9ac1dfc2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/TomasVotruba/unused-public/zipball/ec82a9a1e3216eb5db174430a740ad8d9ac1dfc2",
"reference": "ec82a9a1e3216eb5db174430a740ad8d9ac1dfc2",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0",
"phpstan/phpstan": "^2.1.33",
"webmozart/assert": "^1.11|^2.0"
},
"type": "phpstan-extension",
"extra": {
"phpstan": {
"includes": [
"config/extension.neon"
]
}
},
"autoload": {
"psr-4": {
"TomasVotruba\\UnusedPublic\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Detect unused public properties, constants and methods in your code",
"keywords": [
"phpstan-extension",
"static analysis"
],
"support": {
"issues": "https://github.com/TomasVotruba/unused-public/issues",
"source": "https://github.com/TomasVotruba/unused-public/tree/2.2.0"
},
"funding": [
{
"url": "https://www.paypal.me/rectorphp",
"type": "custom"
},
{
"url": "https://github.com/tomasvotruba",
"type": "github"
}
],
"time": "2026-01-07T22:23:06+00:00"
},
{ {
"name": "webmozart/assert", "name": "webmozart/assert",
"version": "2.1.5", "version": "2.1.5",

2
phpstan-baseline.neon Normal file
View File

@ -0,0 +1,2 @@
parameters:
ignoreErrors: []

67
phpstan.neon Normal file
View File

@ -0,0 +1,67 @@
includes:
- vendor/larastan/larastan/extension.neon
- vendor/tomasvotruba/unused-public/config/extension.neon
- phpstan-baseline.neon
parameters:
paths:
- app
level: 5
reportUnmatchedIgnoredErrors: false
unused_public:
methods: true
properties: true
constants: true
ignoreErrors:
# Controllers, Policies, Middleware, Jobs, Mail, Listeners are Laravel
# framework entry points called by the framework — not by PHP code.
- identifier: public.method.unused
path: app/Http/Controllers/*
- identifier: public.method.unused
path: app/Http/Controllers/**/*
- identifier: public.method.unused
path: app/Http/Middleware/*
- identifier: public.property.unused
path: app/Http/Middleware/*
- identifier: public.method.unused
path: app/Policies/*
- identifier: public.method.unused
path: app/Jobs/*
- identifier: public.property.unused
path: app/Jobs/*
- identifier: public.method.unused
path: app/Jobs/**/*
- identifier: public.property.unused
path: app/Jobs/**/*
- identifier: public.method.unused
path: app/Mail/*
- identifier: public.property.unused
path: app/Mail/*
- identifier: public.method.unused
path: app/Mail/**/*
- identifier: public.property.unused
path: app/Mail/**/*
- identifier: public.method.unused
path: app/Listeners/*
- identifier: public.property.unused
path: app/Listeners/*
# Model relationship methods are called dynamically by Eloquent
- identifier: public.method.unused
path: app/Models/*
- identifier: public.property.unused
path: app/Models/*
# Enum label/description methods used in Blade/frontend
- identifier: public.method.unused
path: app/Enums/*
# Actions used by Fortify/framework
- identifier: public.method.unused
path: app/Actions/*
# Service client methods may be used externally or reserved for future use
- identifier: public.method.unused
path: app/Services/Banking/BitpandaClient.php
- identifier: public.method.unused
path: app/Services/Banking/IndexaCapitalClient.php