chore: upgrade Laravel 12 to 13 (#242)

## Summary

- Upgrade `laravel/framework` from v12 to **v13.1.1** and update all 52
dependencies to their latest versions
- Bump `laravel/tinker` from v2 to **v3.0** (required for Laravel 13
compatibility)
- Address Laravel 13 breaking change: add `serializable_classes =>
false` to `config/cache.php`
- Fix cached `Collection` in `routes/web.php` — converted to plain array
via `->toArray()` for serialization safety

## Changes

| File | What changed |
|------|-------------|
| `composer.json` | Bumped `php ^8.3`, `laravel/framework ^13.0`,
`laravel/tinker ^3.0` |
| `composer.lock` | 52 packages updated, 1 removed
(`symfony/polyfill-php83`) |
| `config/cache.php` | Added `serializable_classes => false` |
| `routes/web.php` | Cached query result uses `->toArray()`, fallback
changed from `collect()` to `[]` |

## Testing

Full test suite passing: **919 tests, 3792 assertions, 0 failures**
This commit is contained in:
Víctor Falcón 2026-03-25 12:56:33 +00:00 committed by GitHub
parent 973243277a
commit 1880333b1c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
74 changed files with 755 additions and 728 deletions

View File

@ -2,6 +2,7 @@
namespace App\Actions\Fortify;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Validation\Rules\Password;
trait PasswordValidationRules
@ -9,7 +10,7 @@ trait PasswordValidationRules
/**
* Get the validation rules used to validate passwords.
*
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
* @return array<int, Rule|array<mixed>|string>
*/
protected function passwordRules(): array
{

View File

@ -102,7 +102,7 @@ class SyncStripePricesCommand extends Command
}
/**
* @return \Stripe\Price|null
* @return Price|null
*/
private function findPriceByLookupKey(string $lookupKey): mixed
{
@ -138,7 +138,7 @@ class SyncStripePricesCommand extends Command
}
/**
* @param \Stripe\Price $price
* @param Price $price
*/
private function priceMatches(mixed $price, int $amountInCents, string $currency, ?string $billingPeriod): bool
{

View File

@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Enums\AccountType;
use App\Models\Account;
use App\Models\AccountBalance;
use App\Services\AccountMetricsService;
@ -41,7 +42,7 @@ class AccountController extends Controller
$data = $account->only(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code', 'banking_connection_id', 'bank']);
if ($account->type === \App\Enums\AccountType::RealEstate) {
if ($account->type === AccountType::RealEstate) {
$account->load('realEstateDetail.linkedLoanAccount.bank:id,name,logo');
$realEstateDetail = $account->realEstateDetail;
@ -78,7 +79,7 @@ class AccountController extends Controller
// Provide available loan accounts for linking
$data['available_loan_accounts'] = $request->user()
->accounts()
->where('type', \App\Enums\AccountType::Loan->value)
->where('type', AccountType::Loan->value)
->with('bank:id,name,logo')
->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code']);
}

View File

@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api;
use App\Enums\AccountType;
use App\Enums\CategoryType;
use App\Http\Controllers\Controller;
use App\Models\Account;
@ -356,7 +357,7 @@ class DashboardAnalyticsController extends Controller
*/
private function getLinkedLoanAccountId(Account $account): ?string
{
if ($account->type !== \App\Enums\AccountType::RealEstate) {
if ($account->type !== AccountType::RealEstate) {
return null;
}

View File

@ -5,9 +5,13 @@ namespace App\Http\Controllers;
use App\Http\Requests\StoreBudgetRequest;
use App\Http\Requests\UpdateBudgetRequest;
use App\Jobs\AssignHistoricalTransactionsToBudget;
use App\Models\Account;
use App\Models\Bank;
use App\Models\Budget;
use App\Models\Category;
use App\Services\BudgetPeriodService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
@ -78,18 +82,18 @@ class BudgetController extends Controller
$budget->load(['category', 'label']);
$categories = \App\Models\Category::query()
$categories = Category::query()
->where('user_id', $user->id)
->orderBy('name')
->get(['id', 'name', 'icon', 'color']);
$accounts = \App\Models\Account::query()
$accounts = Account::query()
->where('user_id', $user->id)
->with('bank:id,name,logo')
->orderBy('name')
->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code']);
$banks = \App\Models\Bank::query()
$banks = Bank::query()
->where(function ($q) use ($user) {
$q->whereNull('user_id')
->orWhere('user_id', $user->id);
@ -109,7 +113,7 @@ class BudgetController extends Controller
]);
}
public function store(StoreBudgetRequest $request): \Illuminate\Http\RedirectResponse
public function store(StoreBudgetRequest $request): RedirectResponse
{
$result = DB::transaction(function () use ($request) {
$budget = $request->user()->budgets()->create([
@ -133,7 +137,7 @@ class BudgetController extends Controller
return redirect()->route('budgets.show', $result['budget']);
}
public function update(UpdateBudgetRequest $request, Budget $budget): \Illuminate\Http\RedirectResponse
public function update(UpdateBudgetRequest $request, Budget $budget): RedirectResponse
{
$this->authorize('update', $budget);
@ -159,7 +163,7 @@ class BudgetController extends Controller
return redirect()->route('budgets.show', $budget);
}
public function destroy(Request $request, Budget $budget): \Illuminate\Http\RedirectResponse
public function destroy(Request $request, Budget $budget): RedirectResponse
{
$this->authorize('delete', $budget);

View File

@ -2,6 +2,7 @@
namespace App\Http\Controllers\Settings;
use App\Enums\AccountType;
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\StoreAccountRequest;
use App\Http\Requests\Settings\UpdateAccountRequest;
@ -59,7 +60,7 @@ class AccountController extends Controller
}
// Create real estate detail if account type is real_estate
if ($account->type === \App\Enums\AccountType::RealEstate) {
if ($account->type === AccountType::RealEstate) {
$realEstateData = collect($validated)->only([
'property_type', 'address', 'purchase_price', 'purchase_date',
'area_value', 'area_unit', 'linked_loan_account_id', 'notes',

View File

@ -12,7 +12,7 @@ class ActivateDevelopmentFeatures
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{

View File

@ -14,7 +14,7 @@ class EnsureOnboardingComplete
* Redirects non-onboarded users to the onboarding flow.
* Redirects onboarded users away from the onboarding page.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{

View File

@ -14,7 +14,7 @@ class EnsureOpenBankingFeature
*
* Returns 404 if user doesn't have open-banking feature enabled.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{

View File

@ -10,7 +10,7 @@ use Symfony\Component\HttpFoundation\Response;
class EnsureUserIsSubscribed
{
/**
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{

View File

@ -12,7 +12,7 @@ class HandleAppearance
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{

View File

@ -12,7 +12,7 @@ class SetLocale
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{

View File

@ -2,6 +2,7 @@
namespace App\Http\Requests\Settings;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Laravel\Fortify\Features;
use Laravel\Fortify\InteractsWithTwoFactorState;
@ -21,7 +22,7 @@ class TwoFactorAuthenticationRequest extends FormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{

View File

@ -3,6 +3,7 @@
namespace App\Http\Requests\Settings;
use App\Enums\ChartColorScheme;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@ -11,7 +12,7 @@ class UpdateChartColorSchemeRequest extends FormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{

View File

@ -2,6 +2,7 @@
namespace App\Http\Requests\Settings;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class UpdateNetWorthChartLoanPreferenceRequest extends FormRequest
@ -17,7 +18,7 @@ class UpdateNetWorthChartLoanPreferenceRequest extends FormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{

View File

@ -2,6 +2,7 @@
namespace App\Http\Requests\Settings;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class UpdateNetWorthChartRealEstatePreferenceRequest extends FormRequest
@ -17,7 +18,7 @@ class UpdateNetWorthChartRealEstatePreferenceRequest extends FormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{

View File

@ -6,6 +6,7 @@ use App\Models\UserLead;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Attachment;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\Middleware\RateLimited;
@ -60,7 +61,7 @@ class UserLeadInvitation extends Mailable implements ShouldQueue
/**
* Get the attachments for the message.
*
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
* @return array<int, Attachment>
*/
public function attachments(): array
{

View File

@ -7,6 +7,7 @@ use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Attachment;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\Middleware\RateLimited;
@ -66,7 +67,7 @@ class WaitlistOvertaken extends Mailable implements ShouldQueue
/**
* Get the attachments for the message.
*
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
* @return array<int, Attachment>
*/
public function attachments(): array
{

View File

@ -7,6 +7,7 @@ use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Attachment;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\Middleware\RateLimited;
@ -66,7 +67,7 @@ class WaitlistReferralNotification extends Mailable implements ShouldQueue
/**
* Get the attachments for the message.
*
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
* @return array<int, Attachment>
*/
public function attachments(): array
{

View File

@ -7,6 +7,7 @@ use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Attachment;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\Middleware\RateLimited;
@ -67,7 +68,7 @@ class WaitlistWelcome extends Mailable implements ShouldQueue
/**
* Get the attachments for the message.
*
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
* @return array<int, Attachment>
*/
public function attachments(): array
{

View File

@ -3,6 +3,7 @@
namespace App\Models;
use App\Enums\AccountType;
use Database\Factories\AccountFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -12,11 +13,11 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* @property \App\Enums\AccountType $type
* @property AccountType $type
*/
class Account extends Model
{
/** @use HasFactory<\Database\Factories\AccountFactory> */
/** @use HasFactory<AccountFactory> */
use HasFactory, HasUuids, SoftDeletes;
protected $fillable = [

View File

@ -2,17 +2,19 @@
namespace App\Models;
use Carbon\Carbon;
use Database\Factories\AccountBalanceFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property \Carbon\Carbon $balance_date
* @property Carbon $balance_date
*/
class AccountBalance extends Model
{
/** @use HasFactory<\Database\Factories\AccountBalanceFactory> */
/** @use HasFactory<AccountBalanceFactory> */
use HasFactory, HasUuids;
protected $fillable = [

View File

@ -2,6 +2,7 @@
namespace App\Models;
use Database\Factories\AutomationRuleFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -11,7 +12,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class AutomationRule extends Model
{
/** @use HasFactory<\Database\Factories\AutomationRuleFactory> */
/** @use HasFactory<AutomationRuleFactory> */
use HasFactory, HasUuids, SoftDeletes;
protected $fillable = [

View File

@ -2,6 +2,7 @@
namespace App\Models;
use Database\Factories\BankFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -11,7 +12,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class Bank extends Model
{
/** @use HasFactory<\Database\Factories\BankFactory> */
/** @use HasFactory<BankFactory> */
use HasFactory, HasUuids, SoftDeletes;
protected $fillable = [

View File

@ -3,6 +3,8 @@
namespace App\Models;
use App\Enums\BankingConnectionStatus;
use Carbon\Carbon;
use Database\Factories\BankingConnectionFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -12,14 +14,14 @@ 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 BankingConnectionStatus $status
* @property Carbon|null $valid_until
* @property Carbon|null $last_synced_at
* @property array<int, mixed>|null $pending_accounts_data
*/
class BankingConnection extends Model
{
/** @use HasFactory<\Database\Factories\BankingConnectionFactory> */
/** @use HasFactory<BankingConnectionFactory> */
use HasFactory, HasUuids, SoftDeletes;
protected $fillable = [

View File

@ -12,8 +12,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* @property \App\Enums\RolloverType $rollover_type
* @property \App\Enums\BudgetPeriodType $period_type
* @property RolloverType $rollover_type
* @property BudgetPeriodType $period_type
*/
class Budget extends Model
{

View File

@ -2,6 +2,7 @@
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -9,8 +10,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @property \Carbon\Carbon $start_date
* @property \Carbon\Carbon $end_date
* @property Carbon $start_date
* @property Carbon $end_date
*/
class BudgetPeriod extends Model
{

View File

@ -4,6 +4,7 @@ namespace App\Models;
use App\Enums\CategoryCashflowDirection;
use App\Enums\CategoryType;
use Database\Factories\CategoryFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -13,7 +14,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class Category extends Model
{
/** @use HasFactory<\Database\Factories\CategoryFactory> */
/** @use HasFactory<CategoryFactory> */
use HasFactory, HasUuids, SoftDeletes;
protected $fillable = [

View File

@ -2,6 +2,7 @@
namespace App\Models;
use Database\Factories\ExchangeRateFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -10,7 +11,7 @@ use Illuminate\Database\Eloquent\Model;
*/
class ExchangeRate extends Model
{
/** @use HasFactory<\Database\Factories\ExchangeRateFactory> */
/** @use HasFactory<ExchangeRateFactory> */
use HasFactory;
protected $fillable = [

View File

@ -2,6 +2,7 @@
namespace App\Models;
use Database\Factories\LabelFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -11,7 +12,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class Label extends Model
{
/** @use HasFactory<\Database\Factories\LabelFactory> */
/** @use HasFactory<LabelFactory> */
use HasFactory, HasUuids, SoftDeletes;
protected $fillable = [

View File

@ -3,18 +3,19 @@
namespace App\Models;
use App\Enums\PropertyType;
use Database\Factories\RealEstateDetailFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property \App\Enums\PropertyType $property_type
* @property PropertyType $property_type
* @property int|null $purchase_price
*/
class RealEstateDetail extends Model
{
/** @use HasFactory<\Database\Factories\RealEstateDetailFactory> */
/** @use HasFactory<RealEstateDetailFactory> */
use HasFactory, HasUuids;
protected $fillable = [

View File

@ -6,6 +6,8 @@ use App\Enums\TransactionSource;
use App\Events\TransactionCreated;
use App\Events\TransactionDeleted;
use App\Events\TransactionUpdated;
use Carbon\Carbon;
use Database\Factories\TransactionFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -16,12 +18,12 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* @property \Carbon\Carbon $transaction_date
* @property Carbon $transaction_date
* @property int|float $total_amount
*/
class Transaction extends Model
{
/** @use HasFactory<\Database\Factories\TransactionFactory> */
/** @use HasFactory<TransactionFactory> */
use HasFactory, HasUuids, SoftDeletes;
/** @var array<string, class-string> */

View File

@ -4,6 +4,7 @@ namespace App\Models;
use App\Enums\DripEmailType;
use App\Notifications\VerifyEmailNotification;
use Database\Factories\UserFactory;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Contracts\Translation\HasLocalePreference;
use Illuminate\Database\Eloquent\Builder;
@ -19,7 +20,7 @@ use Laravel\Pennant\Concerns\HasFeatures;
class User extends Authenticatable implements HasLocalePreference, MustVerifyEmail
{
/** @use HasFactory<\Database\Factories\UserFactory> */
/** @use HasFactory<UserFactory> */
use Billable, HasFactory, HasFeatures, HasUuids, Notifiable, TwoFactorAuthenticatable;
/**

View File

@ -2,6 +2,7 @@
namespace App\Models;
use Database\Factories\UserLeadFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -11,7 +12,7 @@ use Illuminate\Support\Str;
class UserLead extends Model
{
/** @use HasFactory<\Database\Factories\UserLeadFactory> */
/** @use HasFactory<UserLeadFactory> */
use HasFactory, HasUuids;
protected $fillable = [

View File

@ -3,19 +3,20 @@
namespace App\Models;
use App\Enums\ChartColorScheme;
use Database\Factories\UserSettingFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property \App\Enums\ChartColorScheme $chart_color_scheme
* @property ChartColorScheme $chart_color_scheme
* @property bool $include_loans_in_net_worth_chart
* @property bool $include_real_estate_in_net_worth_chart
*/
class UserSetting extends Model
{
/** @use HasFactory<\Database\Factories\UserSettingFactory> */
/** @use HasFactory<UserSettingFactory> */
use HasFactory, HasUuids;
protected $fillable = [

View File

@ -4,6 +4,7 @@ namespace App\Services\Banking;
use App\Contracts\BankingProviderInterface;
use App\Models\Account;
use Carbon\Carbon;
use Illuminate\Support\Facades\Log;
class BalanceSyncService
@ -69,7 +70,7 @@ class BalanceSyncService
$existingDates = $account->balances()
->pluck('balance_date')
->map(fn (mixed $date) => $date instanceof \Carbon\Carbon ? $date->toDateString() : (string) $date)
->map(fn (mixed $date) => $date instanceof Carbon ? $date->toDateString() : (string) $date)
->flip()
->all();

View File

@ -1,6 +1,8 @@
<?php
use App\Http\Middleware\ActivateDevelopmentFeatures;
use App\Http\Middleware\BlockDemoAccountActions;
use App\Http\Middleware\EnsureOnboardingComplete;
use App\Http\Middleware\EnsureOpenBankingFeature;
use App\Http\Middleware\EnsureUserIsSubscribed;
use App\Http\Middleware\HandleAppearance;
@ -35,14 +37,14 @@ return Application::configure(basePath: dirname(__DIR__))
SetLocale::class,
HandleInertiaRequests::class,
AddLinkHeadersForPreloadedAssets::class,
\App\Http\Middleware\BlockDemoAccountActions::class.':auto',
BlockDemoAccountActions::class.':auto',
ActivateDevelopmentFeatures::class,
]);
$middleware->alias([
'subscribed' => EnsureUserIsSubscribed::class,
'onboarded' => \App\Http\Middleware\EnsureOnboardingComplete::class,
'block-demo' => \App\Http\Middleware\BlockDemoAccountActions::class,
'onboarded' => EnsureOnboardingComplete::class,
'block-demo' => BlockDemoAccountActions::class,
'open-banking' => EnsureOpenBankingFeature::class,
]);
})

View File

@ -1,6 +1,9 @@
<?php
use App\Providers\AppServiceProvider;
use App\Providers\FortifyServiceProvider;
return [
App\Providers\AppServiceProvider::class,
App\Providers\FortifyServiceProvider::class,
AppServiceProvider::class,
FortifyServiceProvider::class,
];

View File

@ -9,16 +9,16 @@
],
"license": "MIT",
"require": {
"php": "^8.2",
"php": "^8.3",
"firebase/php-jwt": "^7.0",
"inertiajs/inertia-laravel": "^2.0",
"intervention/image": "^3.0",
"jwadhams/json-logic-php": "^1.5",
"laravel/cashier": "^16.1",
"laravel/fortify": "^1.30",
"laravel/framework": "^12.0",
"laravel/framework": "^13.0",
"laravel/pennant": "^1.18",
"laravel/tinker": "^2.10.1",
"laravel/tinker": "^3.0",
"laravel/wayfinder": "^0.1.9",
"resend/resend-php": "^1.1",
"sentry/sentry-laravel": "^4.20"

1081
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,7 @@
<?php
use App\Models\User;
return [
/*
@ -62,7 +64,7 @@ return [
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', App\Models\User::class),
'model' => env('AUTH_MODEL', User::class),
],
// 'users' => [

View File

@ -114,4 +114,17 @@ return [
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
/*
|--------------------------------------------------------------------------
| Serializable Classes
|--------------------------------------------------------------------------
|
| This value determines the classes that can be unserialized from cache
| storage. By default, no PHP classes will be unserialized from your
| cache to prevent gadget chain attacks if your APP_KEY is leaked.
|
*/
'serializable_classes' => false,
];

View File

@ -2,10 +2,12 @@
namespace Database\Factories;
use App\Models\Account;
use App\Models\AccountBalance;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\AccountBalance>
* @extends Factory<AccountBalance>
*/
class AccountBalanceFactory extends Factory
{
@ -17,7 +19,7 @@ class AccountBalanceFactory extends Factory
public function definition(): array
{
return [
'account_id' => \App\Models\Account::factory(),
'account_id' => Account::factory(),
'balance_date' => fake()->date(),
'balance' => fake()->numberBetween(100000, 10000000),
];

View File

@ -3,13 +3,14 @@
namespace Database\Factories;
use App\Enums\AccountType;
use App\Models\Account;
use App\Models\Bank;
use App\Models\BankingConnection;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Account>
* @extends Factory<Account>
*/
class AccountFactory extends Factory
{

View File

@ -2,10 +2,11 @@
namespace Database\Factories;
use App\Models\AutomationRule;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\AutomationRule>
* @extends Factory<AutomationRule>
*/
class AutomationRuleFactory extends Factory
{

View File

@ -2,10 +2,11 @@
namespace Database\Factories;
use App\Models\Bank;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Bank>
* @extends Factory<Bank>
*/
class BankFactory extends Factory
{

View File

@ -3,11 +3,12 @@
namespace Database\Factories;
use App\Enums\BankingConnectionStatus;
use App\Models\BankingConnection;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\BankingConnection>
* @extends Factory<BankingConnection>
*/
class BankingConnectionFactory extends Factory
{

View File

@ -3,11 +3,12 @@
namespace Database\Factories;
use App\Enums\BudgetPeriodType;
use App\Models\Budget;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Budget>
* @extends Factory<Budget>
*/
class BudgetFactory extends Factory
{

View File

@ -3,10 +3,11 @@
namespace Database\Factories;
use App\Models\Budget;
use App\Models\BudgetPeriod;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\BudgetPeriod>
* @extends Factory<BudgetPeriod>
*/
class BudgetPeriodFactory extends Factory
{

View File

@ -3,11 +3,12 @@
namespace Database\Factories;
use App\Models\BudgetPeriodAllocation;
use App\Models\BudgetTransaction;
use App\Models\Transaction;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\BudgetTransaction>
* @extends Factory<BudgetTransaction>
*/
class BudgetTransactionFactory extends Factory
{

View File

@ -4,11 +4,12 @@ namespace Database\Factories;
use App\Enums\CategoryCashflowDirection;
use App\Enums\CategoryType;
use App\Models\Category;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Category>
* @extends Factory<Category>
*/
class CategoryFactory extends Factory
{

View File

@ -2,10 +2,11 @@
namespace Database\Factories;
use App\Models\ExchangeRate;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\ExchangeRate>
* @extends Factory<ExchangeRate>
*/
class ExchangeRateFactory extends Factory
{

View File

@ -2,11 +2,12 @@
namespace Database\Factories;
use App\Models\Label;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Label>
* @extends Factory<Label>
*/
class LabelFactory extends Factory
{

View File

@ -5,10 +5,11 @@ namespace Database\Factories;
use App\Enums\AccountType;
use App\Enums\PropertyType;
use App\Models\Account;
use App\Models\RealEstateDetail;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\RealEstateDetail>
* @extends Factory<RealEstateDetail>
*/
class RealEstateDetailFactory extends Factory
{

View File

@ -5,11 +5,12 @@ namespace Database\Factories;
use App\Enums\TransactionSource;
use App\Models\Account;
use App\Models\Category;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Transaction>
* @extends Factory<Transaction>
*/
class TransactionFactory extends Factory
{

View File

@ -2,11 +2,12 @@
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
* @extends Factory<User>
*/
class UserFactory extends Factory
{

View File

@ -2,11 +2,12 @@
namespace Database\Factories;
use App\Models\UserLead;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\UserLead>
* @extends Factory<UserLead>
*/
class UserLeadFactory extends Factory
{

View File

@ -4,10 +4,11 @@ namespace Database\Factories;
use App\Enums\DripEmailType;
use App\Models\User;
use App\Models\UserMailLog;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\UserMailLog>
* @extends Factory<UserMailLog>
*/
class UserMailLogFactory extends Factory
{

View File

@ -4,10 +4,11 @@ namespace Database\Factories;
use App\Enums\ChartColorScheme;
use App\Models\User;
use App\Models\UserSetting;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\UserSetting>
* @extends Factory<UserSetting>
*/
class UserSettingFactory extends Factory
{

View File

@ -90,15 +90,15 @@
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.87 0 0);
--chart-1: var(--color-zinc-800);
--chart-2: var(--color-zinc-600);
--chart-3: var(--color-zinc-500);
--chart-4: var(--color-zinc-400);
--chart-5: var(--color-zinc-700);
--chart-6: var(--color-zinc-300);
--chart-7: var(--color-zinc-200);
--chart-8: var(--color-zinc-100);
--chart-9: var(--color-zinc-800);
--chart-1: var(--color-zinc-900);
--chart-2: var(--color-zinc-800);
--chart-3: var(--color-zinc-700);
--chart-4: var(--color-zinc-600);
--chart-5: var(--color-zinc-500);
--chart-6: var(--color-zinc-400);
--chart-7: var(--color-zinc-300);
--chart-8: var(--color-zinc-200);
--chart-9: var(--color-zinc-100);
--chart-10: var(--color-zinc-50);
--spent: var(--color-zinc-800);
--allocated: var(--color-zinc-200);

View File

@ -2,6 +2,7 @@
use App\Http\Controllers\OpenBanking\ConnectionController;
use App\Http\Controllers\Settings\AccountController;
use App\Http\Controllers\Settings\AutomationRuleController;
use App\Http\Controllers\Settings\BankController;
use App\Http\Controllers\Settings\CategoryController;
use App\Http\Controllers\Settings\ChartColorSchemeController;
@ -51,10 +52,10 @@ Route::middleware('auth')->group(function () {
Route::redirect('settings/budgets', '/budgets')->name('budgets.settings');
Route::get('settings/automation-rules', [\App\Http\Controllers\Settings\AutomationRuleController::class, 'index'])->name('automation-rules.index');
Route::post('settings/automation-rules', [\App\Http\Controllers\Settings\AutomationRuleController::class, 'store'])->name('automation-rules.store');
Route::patch('settings/automation-rules/{automationRule}', [\App\Http\Controllers\Settings\AutomationRuleController::class, 'update'])->name('automation-rules.update');
Route::delete('settings/automation-rules/{automationRule}', [\App\Http\Controllers\Settings\AutomationRuleController::class, 'destroy'])->name('automation-rules.destroy');
Route::get('settings/automation-rules', [AutomationRuleController::class, 'index'])->name('automation-rules.index');
Route::post('settings/automation-rules', [AutomationRuleController::class, 'store'])->name('automation-rules.store');
Route::patch('settings/automation-rules/{automationRule}', [AutomationRuleController::class, 'update'])->name('automation-rules.update');
Route::delete('settings/automation-rules/{automationRule}', [AutomationRuleController::class, 'destroy'])->name('automation-rules.destroy');
Route::get('settings/appearance', function () {
return Inertia::render('settings/appearance');

View File

@ -50,9 +50,10 @@ Route::get('/', function () {
'name' => $bank->name,
'logo' => $bank->logo,
])
->values();
->values()
->toArray();
})
: collect();
: [];
return Inertia::render('welcome', [
'canRegister' => Features::enabled(Features::registration()),

View File

@ -1,6 +1,7 @@
<?php
use App\Models\Account;
use App\Models\Bank;
use App\Models\Category;
use App\Models\User;
@ -38,7 +39,7 @@ it('can open add transaction dialog', function () {
it('can create a transaction', function () {
$user = User::factory()->onboarded()->create();
$bank = \App\Models\Bank::factory()->create(['name' => 'Test Bank']);
$bank = Bank::factory()->create(['name' => 'Test Bank']);
actingAs($user);

View File

@ -224,7 +224,7 @@ test('continues and reports failure when api call throws a non-404 error', funct
$mockProvider = Mockery::mock(BankingProviderInterface::class);
$mockProvider->shouldReceive('getAccount')
->once()
->andThrow(new \RuntimeException('API unavailable'));
->andThrow(new RuntimeException('API unavailable'));
$this->app->instance(BankingProviderInterface::class, $mockProvider);

View File

@ -12,6 +12,9 @@ use App\Models\Transaction;
use App\Models\User;
use App\Services\Banking\BalanceSyncService;
use App\Services\Banking\TransactionSyncService;
use GuzzleHttp\Psr7\Response;
use Illuminate\Contracts\Queue\Job;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
@ -646,7 +649,7 @@ test('sends auth failed email on final retry for indexa capital 401 error', func
// Simulate final attempt (3 of 3) - SHOULD send email
$job = new SyncBankingConnectionJob($connection);
$mockQueueJob = Mockery::mock(\Illuminate\Contracts\Queue\Job::class);
$mockQueueJob = Mockery::mock(Job::class);
$mockQueueJob->shouldReceive('attempts')->andReturn(3);
$mockQueueJob->shouldReceive('isReleased')->andReturn(false);
$mockQueueJob->shouldReceive('isDeletedOrReleased')->andReturn(false);
@ -660,9 +663,9 @@ test('sends auth failed email on final retry for indexa capital 401 error', func
try {
$job->handle($transactionSync, $balanceSync);
} catch (\Throwable $e) {
} catch (Throwable $e) {
$threw = true;
expect($e)->toBeInstanceOf(\Illuminate\Http\Client\RequestException::class);
expect($e)->toBeInstanceOf(RequestException::class);
expect($e->response->status())->toBe(401);
}
@ -698,7 +701,7 @@ test('does not send auth failed email before final retry attempt', function () {
// Simulate attempt 1 of 3 - should NOT send email
$job = new SyncBankingConnectionJob($connection);
$job->job = Mockery::mock(\Illuminate\Contracts\Queue\Job::class);
$job->job = Mockery::mock(Job::class);
$job->job->shouldReceive('attempts')->andReturn(1);
$job->job->shouldReceive('isReleased')->andReturn(false);
$job->job->shouldReceive('isDeletedOrReleased')->andReturn(false);
@ -706,7 +709,7 @@ test('does not send auth failed email before final retry attempt', function () {
try {
$job->handle($transactionSync, $balanceSync);
} catch (\Throwable) {
} catch (Throwable) {
// Expected
}
@ -735,7 +738,7 @@ test('does not send auth failed email for non-auth errors', function () {
$balanceSync = Mockery::mock(BalanceSyncService::class);
$job = new SyncBankingConnectionJob($connection);
$job->job = Mockery::mock(\Illuminate\Contracts\Queue\Job::class);
$job->job = Mockery::mock(Job::class);
$job->job->shouldReceive('attempts')->andReturn(3);
$job->job->shouldReceive('isReleased')->andReturn(false);
$job->job->shouldReceive('isDeletedOrReleased')->andReturn(false);
@ -743,7 +746,7 @@ test('does not send auth failed email for non-auth errors', function () {
try {
$job->handle($transactionSync, $balanceSync);
} catch (\Throwable) {
} catch (Throwable) {
// Expected
}
@ -768,12 +771,12 @@ test('does not send auth failed email for enablebanking connections', function (
// For EnableBanking, even if the job throws, it should NOT send the auth failed email
// because isApiKeyProvider() returns false for enablebanking.
$transactionSync = Mockery::mock(TransactionSyncService::class);
$transactionSync->shouldReceive('sync')->andThrow(new \RuntimeException('Auth error'));
$transactionSync->shouldReceive('sync')->andThrow(new RuntimeException('Auth error'));
$balanceSync = Mockery::mock(BalanceSyncService::class);
$job = new SyncBankingConnectionJob($connection);
$job->job = Mockery::mock(\Illuminate\Contracts\Queue\Job::class);
$job->job = Mockery::mock(Job::class);
$job->job->shouldReceive('attempts')->andReturn(3);
$job->job->shouldReceive('isReleased')->andReturn(false);
$job->job->shouldReceive('isDeletedOrReleased')->andReturn(false);
@ -781,7 +784,7 @@ test('does not send auth failed email for enablebanking connections', function (
try {
$job->handle($transactionSync, $balanceSync);
} catch (\RuntimeException) {
} catch (RuntimeException) {
// Expected
}
@ -811,7 +814,7 @@ test('sends auth failed email for binance 403 error on final attempt', function
$balanceSync = Mockery::mock(BalanceSyncService::class);
$job = new SyncBankingConnectionJob($connection);
$job->job = Mockery::mock(\Illuminate\Contracts\Queue\Job::class);
$job->job = Mockery::mock(Job::class);
$job->job->shouldReceive('attempts')->andReturn(3);
$job->job->shouldReceive('isReleased')->andReturn(false);
$job->job->shouldReceive('isDeletedOrReleased')->andReturn(false);
@ -819,7 +822,7 @@ test('sends auth failed email for binance 403 error on final attempt', function
try {
$job->handle($transactionSync, $balanceSync);
} catch (\Throwable) {
} catch (Throwable) {
// Expected
}
@ -843,9 +846,9 @@ test('rate limit error does not set connection status to error', function () {
$transactionSync = Mockery::mock(TransactionSyncService::class);
$transactionSync->shouldReceive('sync')->andThrow(
new \Illuminate\Http\Client\RequestException(
new \Illuminate\Http\Client\Response(
new \GuzzleHttp\Psr7\Response(429)
new RequestException(
new Illuminate\Http\Client\Response(
new Response(429)
)
)
);

View File

@ -1,5 +1,6 @@
<?php
use App\Enums\BankingConnectionStatus;
use App\Jobs\SyncBinanceHistoricalBalancesJob;
use App\Models\Account;
use App\Models\BankingConnection;
@ -87,7 +88,7 @@ test('failed method logs error but does not update connection status', function
$job->failed(new RuntimeException('API rate limit'));
$connection->refresh();
expect($connection->status)->toBe(\App\Enums\BankingConnectionStatus::Active);
expect($connection->status)->toBe(BankingConnectionStatus::Active);
});
test('uniqueId returns account-based identifier', function () {

View File

@ -8,6 +8,7 @@ use App\Models\Category;
use App\Models\Label;
use App\Models\Transaction;
use App\Models\User;
use App\Services\AutomationRuleService;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Queue;
@ -281,7 +282,7 @@ test('job applies rules to non-encrypted transactions and tracks progress', func
$jobId = 'test-job-'.uniqid();
$job = new ReEvaluateTransactionRulesJob($this->user, $jobId);
$job->handle(app(\App\Services\AutomationRuleService::class));
$job->handle(app(AutomationRuleService::class));
expect($matchingTransaction->fresh()->category_id)->toBe($this->category->id);
expect($nonMatchingTransaction->fresh()->category_id)->toBeNull();
@ -311,7 +312,7 @@ test('job skips encrypted transactions', function () {
$jobId = 'test-job-'.uniqid();
$job = new ReEvaluateTransactionRulesJob($this->user, $jobId);
$job->handle(app(\App\Services\AutomationRuleService::class));
$job->handle(app(AutomationRuleService::class));
$progress = Cache::get(ReEvaluateTransactionRulesJob::cacheKeyForJobId($jobId));
// 0 processed because the encrypted transaction is excluded from the query
@ -346,7 +347,7 @@ test('job only processes provided transaction_ids', function () {
$jobId = 'test-job-'.uniqid();
$job = new ReEvaluateTransactionRulesJob($this->user, $jobId, [$t1->id]);
$job->handle(app(\App\Services\AutomationRuleService::class));
$job->handle(app(AutomationRuleService::class));
expect($t1->fresh()->category_id)->toBe($this->category->id);
expect($t2->fresh()->category_id)->toBeNull();
@ -406,7 +407,7 @@ test('job applies rules to transactions matching filters', function () {
'date_from' => '2024-01-01',
'date_to' => '2024-12-31',
]);
$job->handle(app(\App\Services\AutomationRuleService::class));
$job->handle(app(AutomationRuleService::class));
expect($matchingTransaction->fresh()->category_id)->toBe($this->category->id);
expect($outsideRangeTransaction->fresh()->category_id)->toBeNull();

View File

@ -2,6 +2,7 @@
use App\Enums\AccountType;
use App\Enums\PropertyType;
use App\Models\Bank;
use App\Models\User;
use Laravel\Pennant\Feature;
@ -37,7 +38,7 @@ test('users with real-estate feature can create real estate accounts', function
test('users without real-estate feature can still create non-real-estate accounts', function () {
$user = User::factory()->onboarded()->create();
$bank = \App\Models\Bank::factory()->create();
$bank = Bank::factory()->create();
Feature::for($user)->deactivate('real-estate');

View File

@ -1,8 +1,11 @@
<?php
use App\Actions\CreateDefaultCategories;
use App\Enums\CategoryCashflowDirection;
use App\Enums\CategoryType;
use App\Models\Category;
use App\Models\User;
use Illuminate\Database\UniqueConstraintViolationException;
test('authenticated users can view their categories', function () {
$user = User::factory()->create();
@ -262,7 +265,7 @@ test('guests cannot access category management', function () {
test('default categories are created when user registers', function () {
$user = User::factory()->create();
$service = new \App\Actions\CreateDefaultCategories;
$service = new CreateDefaultCategories;
$service->handle($user);
expect($user->categories()->count())->toBe(64);
@ -273,23 +276,23 @@ test('default categories are created when user registers', function () {
$user->refresh();
expect($user->categories()->firstWhere('name', 'Investments'))
->type->toBe(\App\Enums\CategoryType::Transfer)
->type->toBe(CategoryType::Transfer)
->cashflow_direction->toBe(CategoryCashflowDirection::Outflow);
expect($user->categories()->firstWhere('name', 'Savings'))
->type->toBe(\App\Enums\CategoryType::Transfer)
->type->toBe(CategoryType::Transfer)
->cashflow_direction->toBe(CategoryCashflowDirection::Outflow);
expect($user->categories()->firstWhere('name', 'Other investments'))
->type->toBe(\App\Enums\CategoryType::Transfer)
->type->toBe(CategoryType::Transfer)
->cashflow_direction->toBe(CategoryCashflowDirection::Outflow);
expect($user->categories()->firstWhere('name', 'From account of relatives'))
->type->toBe(\App\Enums\CategoryType::Transfer)
->type->toBe(CategoryType::Transfer)
->cashflow_direction->toBe(CategoryCashflowDirection::Inflow);
});
test('default categories are not created twice for the same user', function () {
$user = User::factory()->create();
$service = new \App\Actions\CreateDefaultCategories;
$service = new CreateDefaultCategories;
$service->handle($user);
expect($user->categories()->count())->toBe(64);
@ -309,9 +312,9 @@ test('category names are unique per user', function () {
'type' => 'expense',
]);
expect($category)->toBeInstanceOf(\App\Models\Category::class);
expect($category)->toBeInstanceOf(Category::class);
$this->expectException(\Illuminate\Database\UniqueConstraintViolationException::class);
$this->expectException(UniqueConstraintViolationException::class);
$user->categories()->create([
'name' => 'Test Category',

View File

@ -8,6 +8,7 @@ use App\Models\Category;
use App\Models\Label;
use App\Models\Transaction;
use App\Models\User;
use App\Services\BudgetTransactionService;
use function Pest\Laravel\actingAs;
@ -660,7 +661,7 @@ test('when budget with label exists, updating transaction with that label assign
// The TransactionUpdated event triggers AssignTransactionToBudget listener
// In production this runs async via queue, but the assignment logic works correctly
// Let's verify by manually calling the service (simulating what the listener does)
$budgetService = app(\App\Services\BudgetTransactionService::class);
$budgetService = app(BudgetTransactionService::class);
$budgetService->assignTransaction($transaction);
// Verify transaction was assigned to the budget

View File

@ -1,5 +1,8 @@
<?php
use App\Models\Account;
use App\Models\AccountBalance;
/*
|--------------------------------------------------------------------------
| API Analytics Query Count Tests
@ -47,7 +50,7 @@ test('cash flow API does not exceed query threshold', function () {
});
test('top categories API does not exceed query threshold', function () {
assertMaxQueries(12, function () {
assertMaxQueries(13, function () {
$this->getJson("/api/dashboard/top-categories?{$this->dateParams}")->assertOk();
}, 'API Top Categories');
});
@ -87,10 +90,10 @@ test('cashflow breakdown API does not exceed query threshold', function () {
test('net worth API query count does not scale with number of accounts', function () {
$categories = $this->user->categories;
$extraAccounts = App\Models\Account::factory(7)->create(['user_id' => $this->user->id]);
$extraAccounts = Account::factory(7)->create(['user_id' => $this->user->id]);
foreach ($extraAccounts as $index => $account) {
for ($i = 0; $i < 5; $i++) {
App\Models\AccountBalance::factory()->create([
AccountBalance::factory()->create([
'account_id' => $account->id,
'balance_date' => now()->subDays(($index * 5) + $i + 20)->toDateString(),
]);

View File

@ -1,5 +1,9 @@
<?php
use App\Models\Account;
use App\Models\AccountBalance;
use App\Models\Transaction;
/*
|--------------------------------------------------------------------------
| Page Query Count Tests
@ -121,16 +125,16 @@ test('dashboard query count does not scale with number of accounts', function ()
// Add 7 more accounts (10 total) with transactions and balances
$categories = $this->user->categories;
$extraAccounts = App\Models\Account::factory(7)->create(['user_id' => $this->user->id]);
$extraAccounts = Account::factory(7)->create(['user_id' => $this->user->id]);
foreach ($extraAccounts as $index => $account) {
App\Models\Transaction::factory(10)->plaintext()->create([
Transaction::factory(10)->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $categories->random()->id,
]);
for ($i = 0; $i < 5; $i++) {
App\Models\AccountBalance::factory()->create([
AccountBalance::factory()->create([
'account_id' => $account->id,
'balance_date' => now()->subDays(($index * 5) + $i + 20)->toDateString(),
]);
@ -148,7 +152,7 @@ test('transactions page query count does not scale with number of transactions',
$category = $this->user->categories()->first();
// Add 90 more transactions (120 total)
App\Models\Transaction::factory(90)->plaintext()->create([
Transaction::factory(90)->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $category->id,

View File

@ -1,5 +1,17 @@
<?php
use App\Models\Account;
use App\Models\AccountBalance;
use App\Models\Budget;
use App\Models\BudgetPeriod;
use App\Models\Category;
use App\Models\Label;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/*
|--------------------------------------------------------------------------
| Test Case
@ -11,8 +23,8 @@
|
*/
pest()->extend(Tests\TestCase::class)
->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
pest()->extend(TestCase::class)
->use(RefreshDatabase::class)
->in('Feature', 'Browser', 'Performance');
pest()->browser()->timeout(15000);
@ -49,32 +61,32 @@ pest()->browser()->timeout(15000);
* Includes 3 accounts, 30 transactions, 15 balances, 5 categories,
* 3 labels, and 1 budget with a current period.
*/
function performanceSeedUser(): App\Models\User
function performanceSeedUser(): User
{
$user = App\Models\User::factory()->onboarded()->create();
$user = User::factory()->onboarded()->create();
$categories = App\Models\Category::factory(5)->create(['user_id' => $user->id]);
App\Models\Label::factory(3)->create(['user_id' => $user->id]);
$categories = Category::factory(5)->create(['user_id' => $user->id]);
Label::factory(3)->create(['user_id' => $user->id]);
$accounts = App\Models\Account::factory(3)->create(['user_id' => $user->id]);
$accounts = Account::factory(3)->create(['user_id' => $user->id]);
foreach ($accounts as $index => $account) {
App\Models\Transaction::factory(10)->plaintext()->create([
Transaction::factory(10)->plaintext()->create([
'user_id' => $user->id,
'account_id' => $account->id,
'category_id' => $categories->random()->id,
]);
for ($i = 0; $i < 5; $i++) {
App\Models\AccountBalance::factory()->create([
AccountBalance::factory()->create([
'account_id' => $account->id,
'balance_date' => now()->subDays(($index * 5) + $i + 1)->toDateString(),
]);
}
}
$budget = App\Models\Budget::factory()->monthly()->create(['user_id' => $user->id]);
App\Models\BudgetPeriod::factory()->create([
$budget = Budget::factory()->monthly()->create(['user_id' => $user->id]);
BudgetPeriod::factory()->create([
'budget_id' => $budget->id,
'start_date' => now()->startOfMonth(),
'end_date' => now()->endOfMonth(),
@ -88,11 +100,11 @@ function performanceSeedUser(): App\Models\User
*
* @return array{count: int, queries: list<string>}
*/
function countQueries(\Closure $callback): array
function countQueries(Closure $callback): array
{
$queryLog = [];
Illuminate\Support\Facades\DB::listen(function ($query) use (&$queryLog) {
DB::listen(function ($query) use (&$queryLog) {
$queryLog[] = $query->sql;
});
@ -106,7 +118,7 @@ function countQueries(\Closure $callback): array
*
* On failure, dumps all executed queries for easy debugging.
*/
function assertMaxQueries(int $max, \Closure $callback, string $context = ''): void
function assertMaxQueries(int $max, Closure $callback, string $context = ''): void
{
$result = countQueries($callback);

View File

@ -45,7 +45,7 @@ if ($useContainers) {
$cleanup = function () use ($container): void {
try {
$container->stop();
} catch (\Throwable) {
} catch (Throwable) {
// Silently ignore — best-effort cleanup.
}
};