From 9b6c30775f1140585ffcabc55ee061202ce960dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Wed, 21 Jan 2026 15:25:50 +0100 Subject: [PATCH] Add Budgeting Feature to Track and Manage Spending (#36) ## Overview We're excited to introduce budgeting capabilities to Whisper Money! This feature helps you take control of your finances by setting spending limits and tracking your progress over time. ## Screenshots image image image ## What's New ### Create Flexible Budgets - Set budgets for specific categories or labels - Choose from monthly, weekly, bi-weekly, or custom periods - Set your own budget start date for better alignment with your pay schedule ### Track Your Spending - Visual spending charts show how much you've spent vs. your budget - See at a glance which budgets are on track and which need attention - View all transactions that count toward each budget ### Smart Budget Management - **Carry Over**: Unused budget amounts automatically roll into the next period - **Reset**: Unused amounts return to your available money pool - Edit or delete budgets anytime as your needs change ### Easy Access - New Budgets section in the main navigation - Quick overview cards showing budget status - Detailed budget pages with spending history and transaction lists ## How It Works 1. Create a budget by selecting a category or label and setting your spending limit 2. Your transactions are automatically matched to relevant budgets 3. Track your progress with visual charts and spending summaries 4. Adjust your budgets as needed to stay on track with your financial goals This feature is now available behind a feature flag and can be enabled for users who want to start budgeting their expenses. --- .cursor/mcp.json | 2 +- .mcp.json | 2 +- .../Commands/BackfillUserCurrencyCode.php | 70 + .../Commands/Concerns/ResolvesFeatures.php | 37 +- .../Commands/FeatureDisableCommand.php | 2 +- app/Console/Commands/FeatureEnableCommand.php | 2 +- .../Commands/GenerateBudgetPeriods.php | 64 + .../Commands/ResetDemoAccountCommand.php | 179 ++- app/Enums/BudgetPeriodType.php | 21 + app/Enums/RolloverType.php | 25 + app/Events/TransactionCreated.php | 14 + app/Events/TransactionDeleted.php | 14 + app/Events/TransactionUpdated.php | 14 + app/Http/Controllers/BudgetController.php | 143 ++ .../Settings/AccountController.php | 8 +- app/Http/Middleware/EnsureBudgetsFeature.php | 29 + app/Http/Middleware/HandleInertiaRequests.php | 2 + app/Http/Requests/StoreBudgetRequest.php | 47 + app/Http/Requests/UpdateBudgetRequest.php | 32 + app/Listeners/AssignTransactionToBudget.php | 26 + .../UnassignTransactionFromBudget.php | 25 + app/Models/Budget.php | 66 + app/Models/BudgetPeriod.php | 42 + app/Models/BudgetTransaction.php | 36 + app/Models/Transaction.php | 16 + app/Models/User.php | 8 +- app/Policies/BudgetPolicy.php | 44 + app/Providers/AppServiceProvider.php | 14 + app/Services/BudgetPeriodService.php | 108 ++ app/Services/BudgetTransactionService.php | 74 + .../Demo/DemoTransactionsProvider.php | 105 +- bootstrap/app.php | 2 + composer.json | 2 +- composer.lock | 1243 +++++++++-------- database/factories/BudgetFactory.php | 54 + database/factories/BudgetPeriodFactory.php | 30 + .../factories/BudgetTransactionFactory.php | 27 + ...2025_12_19_092437_create_budgets_table.php | 36 + ..._19_092443_create_budget_periods_table.php | 34 + ...92446_create_budget_transactions_table.php | 30 + ...63338_add_currency_code_to_users_table.php | 4 +- resources/css/app.css | 4 + .../components/budgets/budget-list-card.tsx | 139 ++ .../budgets/budget-spending-chart.tsx | 273 ++++ .../budgets/create-budget-dialog.tsx | 390 ++++++ .../budgets/delete-budget-dialog.tsx | 72 + .../components/budgets/edit-budget-dialog.tsx | 266 ++++ resources/js/components/heading-small.tsx | 7 +- .../transactions/transaction-list.tsx | 93 +- resources/js/components/ui/input.tsx | 2 +- resources/js/layouts/settings/layout.tsx | 7 + resources/js/lib/dexie-db.ts | 21 + resources/js/pages/budgets/index.tsx | 57 + resources/js/pages/budgets/show.tsx | 184 +++ resources/js/pages/transactions/index.tsx | 4 +- resources/js/providers/menu-item-provider.tsx | 11 + resources/js/types/budget.ts | 90 ++ resources/js/types/index.d.ts | 2 + resources/js/utils/currency.ts | 22 + routes/console.php | 2 + routes/settings.php | 4 + routes/web.php | 9 + tests/Browser/AmountInputTest.php | 21 +- tests/Browser/BudgetCrudTest.php | 190 +++ .../Browser/BudgetsFeatureNavigationTest.php | 190 +++ tests/Feature/BudgetFeatureFlagTest.php | 114 ++ tests/Feature/BudgetTest.php | 140 ++ 67 files changed, 4393 insertions(+), 654 deletions(-) create mode 100644 app/Console/Commands/BackfillUserCurrencyCode.php create mode 100644 app/Console/Commands/GenerateBudgetPeriods.php create mode 100644 app/Enums/BudgetPeriodType.php create mode 100644 app/Enums/RolloverType.php create mode 100644 app/Events/TransactionCreated.php create mode 100644 app/Events/TransactionDeleted.php create mode 100644 app/Events/TransactionUpdated.php create mode 100644 app/Http/Controllers/BudgetController.php create mode 100644 app/Http/Middleware/EnsureBudgetsFeature.php create mode 100644 app/Http/Requests/StoreBudgetRequest.php create mode 100644 app/Http/Requests/UpdateBudgetRequest.php create mode 100644 app/Listeners/AssignTransactionToBudget.php create mode 100644 app/Listeners/UnassignTransactionFromBudget.php create mode 100644 app/Models/Budget.php create mode 100644 app/Models/BudgetPeriod.php create mode 100644 app/Models/BudgetTransaction.php create mode 100644 app/Policies/BudgetPolicy.php create mode 100644 app/Services/BudgetPeriodService.php create mode 100644 app/Services/BudgetTransactionService.php create mode 100644 database/factories/BudgetFactory.php create mode 100644 database/factories/BudgetPeriodFactory.php create mode 100644 database/factories/BudgetTransactionFactory.php create mode 100644 database/migrations/2025_12_19_092437_create_budgets_table.php create mode 100644 database/migrations/2025_12_19_092443_create_budget_periods_table.php create mode 100644 database/migrations/2025_12_19_092446_create_budget_transactions_table.php create mode 100644 resources/js/components/budgets/budget-list-card.tsx create mode 100644 resources/js/components/budgets/budget-spending-chart.tsx create mode 100644 resources/js/components/budgets/create-budget-dialog.tsx create mode 100644 resources/js/components/budgets/delete-budget-dialog.tsx create mode 100644 resources/js/components/budgets/edit-budget-dialog.tsx create mode 100644 resources/js/pages/budgets/index.tsx create mode 100644 resources/js/pages/budgets/show.tsx create mode 100644 resources/js/types/budget.ts create mode 100644 resources/js/utils/currency.ts create mode 100644 tests/Browser/BudgetCrudTest.php create mode 100644 tests/Browser/BudgetsFeatureNavigationTest.php create mode 100644 tests/Feature/BudgetFeatureFlagTest.php create mode 100644 tests/Feature/BudgetTest.php diff --git a/.cursor/mcp.json b/.cursor/mcp.json index 8c6715a1..40b4d24f 100644 --- a/.cursor/mcp.json +++ b/.cursor/mcp.json @@ -8,4 +8,4 @@ ] } } -} \ No newline at end of file +} diff --git a/.mcp.json b/.mcp.json index 8c6715a1..40b4d24f 100644 --- a/.mcp.json +++ b/.mcp.json @@ -8,4 +8,4 @@ ] } } -} \ No newline at end of file +} diff --git a/app/Console/Commands/BackfillUserCurrencyCode.php b/app/Console/Commands/BackfillUserCurrencyCode.php new file mode 100644 index 00000000..cbd4fe87 --- /dev/null +++ b/app/Console/Commands/BackfillUserCurrencyCode.php @@ -0,0 +1,70 @@ +info('Starting currency code backfill...'); + + // Get all users without a currency_code + $users = User::whereNull('currency_code') + ->has('accounts') + ->with(['accounts' => function ($query) { + $query->orderBy('created_at', 'asc')->limit(1); + }]) + ->get(); + + if ($users->isEmpty()) { + $this->info('No users found that need currency code backfill.'); + + return Command::SUCCESS; + } + + $this->info("Found {$users->count()} users to update."); + + $bar = $this->output->createProgressBar($users->count()); + $bar->start(); + + $updated = 0; + + foreach ($users as $user) { + $firstAccount = $user->accounts->first(); + + if ($firstAccount && $firstAccount->currency_code) { + $user->update(['currency_code' => $firstAccount->currency_code]); + $updated++; + } + + $bar->advance(); + } + + $bar->finish(); + $this->newLine(); + + $this->info("Successfully updated {$updated} users with currency codes."); + + return Command::SUCCESS; + } +} diff --git a/app/Console/Commands/Concerns/ResolvesFeatures.php b/app/Console/Commands/Concerns/ResolvesFeatures.php index d7bcd52a..ad21d0f6 100644 --- a/app/Console/Commands/Concerns/ResolvesFeatures.php +++ b/app/Console/Commands/Concerns/ResolvesFeatures.php @@ -14,24 +14,41 @@ trait ResolvesFeatures return $featureClass; } + if ($this->isStringBasedFeature($name)) { + return $name; + } + return null; } private function getAvailableFeatures(): string { - $featuresPath = app_path('Features'); - - if (! File::isDirectory($featuresPath)) { - return 'None'; - } - - $files = File::files($featuresPath); $features = []; - foreach ($files as $file) { - $features[] = $file->getFilenameWithoutExtension(); + $featuresPath = app_path('Features'); + + if (File::isDirectory($featuresPath)) { + $files = File::files($featuresPath); + + foreach ($files as $file) { + $features[] = $file->getFilenameWithoutExtension(); + } } - return implode(', ', $features) ?: 'None'; + $stringFeatures = $this->getStringBasedFeatures(); + + $allFeatures = array_merge($features, $stringFeatures); + + return implode(', ', array_unique($allFeatures)) ?: 'None'; + } + + private function isStringBasedFeature(string $name): bool + { + return in_array($name, $this->getStringBasedFeatures(), true); + } + + private function getStringBasedFeatures(): array + { + return ['budgets']; } } diff --git a/app/Console/Commands/FeatureDisableCommand.php b/app/Console/Commands/FeatureDisableCommand.php index 96779da0..faf4c293 100644 --- a/app/Console/Commands/FeatureDisableCommand.php +++ b/app/Console/Commands/FeatureDisableCommand.php @@ -11,7 +11,7 @@ class FeatureDisableCommand extends Command { use ResolvesFeatures; - protected $signature = 'feature:disable {feature : The feature class name} {target : User email or "all" for everyone}'; + protected $signature = 'feature:disable {feature : The feature name (class name or string-based feature)} {target : User email or "all" for everyone}'; protected $description = 'Disable a feature for a specific user or all users'; diff --git a/app/Console/Commands/FeatureEnableCommand.php b/app/Console/Commands/FeatureEnableCommand.php index 3db522ff..8f07b108 100644 --- a/app/Console/Commands/FeatureEnableCommand.php +++ b/app/Console/Commands/FeatureEnableCommand.php @@ -11,7 +11,7 @@ class FeatureEnableCommand extends Command { use ResolvesFeatures; - protected $signature = 'feature:enable {feature : The feature class name} {target : User email or "all" for everyone}'; + protected $signature = 'feature:enable {feature : The feature name (class name or string-based feature)} {target : User email or "all" for everyone}'; protected $description = 'Enable a feature for a specific user or all users'; diff --git a/app/Console/Commands/GenerateBudgetPeriods.php b/app/Console/Commands/GenerateBudgetPeriods.php new file mode 100644 index 00000000..69912369 --- /dev/null +++ b/app/Console/Commands/GenerateBudgetPeriods.php @@ -0,0 +1,64 @@ +info('Generating budget periods...'); + + $budgets = Budget::with('periods')->get(); + $generatedCount = 0; + $closedCount = 0; + + foreach ($budgets as $budget) { + $currentPeriod = $budget->getCurrentPeriod(); + + if (! $currentPeriod) { + $this->budgetPeriodService->generatePeriod($budget); + $generatedCount++; + $this->info("Generated initial period for budget: {$budget->name}"); + } + + $completedPeriods = BudgetPeriod::where('budget_id', $budget->id) + ->where('end_date', '<', now()->subDay()) + ->get(); + + foreach ($completedPeriods as $period) { + if ($period->end_date < now()->subDay()) { + $this->budgetPeriodService->closePeriod($period); + $closedCount++; + } + } + + $futurePeriods = $budget->periods() + ->where('start_date', '>', now()) + ->count(); + + if ($futurePeriods < 2) { + $this->budgetPeriodService->generatePeriod($budget); + $generatedCount++; + } + } + + $this->info("Generated {$generatedCount} new periods"); + $this->info("Closed {$closedCount} completed periods"); + + return Command::SUCCESS; + } +} diff --git a/app/Console/Commands/ResetDemoAccountCommand.php b/app/Console/Commands/ResetDemoAccountCommand.php index 02d9b7de..1bf78583 100644 --- a/app/Console/Commands/ResetDemoAccountCommand.php +++ b/app/Console/Commands/ResetDemoAccountCommand.php @@ -4,18 +4,24 @@ namespace App\Console\Commands; use App\Actions\CreateDefaultCategories; use App\Enums\AccountType; +use App\Enums\BudgetPeriodType; +use App\Enums\RolloverType; use App\Models\Account; use App\Models\Bank; +use App\Models\Budget; use App\Models\Category; use App\Models\EncryptedMessage; use App\Models\Label; use App\Models\User; +use App\Services\BudgetPeriodService; +use App\Services\BudgetTransactionService; use App\Services\Demo\DemoAutomationRulesProvider; use App\Services\Demo\DemoEncryptionService; use App\Services\Demo\DemoLabelsProvider; use App\Services\Demo\DemoTransactionsProvider; use Illuminate\Console\Command; use Illuminate\Support\Collection; +use Laravel\Pennant\Feature; class ResetDemoAccountCommand extends Command { @@ -32,6 +38,8 @@ class ResetDemoAccountCommand extends Command private DemoLabelsProvider $labelsProvider, private DemoAutomationRulesProvider $rulesProvider, private DemoEncryptionService $encryptionService, + private BudgetPeriodService $budgetPeriodService, + private BudgetTransactionService $budgetTransactionService, ) { parent::__construct(); } @@ -67,6 +75,12 @@ class ResetDemoAccountCommand extends Command $this->createAutomationRules($user, $labels); + $this->createBudgets($user); + + $this->enableBudgetsFeature($user); + + $this->assignTransactionsToBudgets($user); + $this->createSubscription($user); $this->info('✓ Demo account reset successfully!'); @@ -102,6 +116,7 @@ class ResetDemoAccountCommand extends Command $user->labels()->forceDelete(); $user->automationRules()->forceDelete(); $user->categories()->forceDelete(); + $user->budgets()->forceDelete(); $user->encryptedMessage()?->delete(); $this->info(' Deleted existing data'); @@ -214,7 +229,8 @@ class ResetDemoAccountCommand extends Command ], ]; - $totalTransactions = 0; + $createdAccounts = []; + $transactionAccounts = []; foreach ($accounts as $index => $accountData) { $encrypted = $this->encryptionService->encrypt( @@ -233,13 +249,16 @@ class ResetDemoAccountCommand extends Command $this->createBalanceHistory($account, $accountData['current_balance'], $accountData['monthly_variance']); + $createdAccounts[] = $account; + if ($this->accountTypeHasTransactions($accountData['type'])) { - $transactionCount = $this->createTransactionsForAccount($account, $categories, $labels); - $totalTransactions += $transactionCount; + $transactionAccounts[] = $account; } } - $this->info(" Created 5 accounts with {$totalTransactions} transactions and 12 months of balances"); + $totalTransactions = $this->createMixedTransactions($transactionAccounts, $categories, $labels); + + $this->info(' Created '.count($createdAccounts)." accounts with {$totalTransactions} transactions and 12 months of balances"); } private function generateRealisticBalance(int $min, int $max): int @@ -297,15 +316,21 @@ class ResetDemoAccountCommand extends Command } /** + * @param array $accounts * @param Collection $categories * @param array $labels */ - private function createTransactionsForAccount(Account $account, Collection $categories, array $labels): int + private function createMixedTransactions(array $accounts, Collection $categories, array $labels): int { - $transactions = $this->transactionsProvider->getTransactions(); + if (empty($accounts)) { + return 0; + } + + $allTransactions = $this->transactionsProvider->getTransactions(); + $transactionIndex = 0; $count = 0; - foreach ($transactions as $index => $transactionData) { + foreach ($allTransactions as $transactionData) { $categoryName = $transactionData['category_name']; unset($transactionData['category_name']); @@ -315,10 +340,12 @@ class ResetDemoAccountCommand extends Command continue; } + $account = $accounts[array_rand($accounts)]; + $encrypted = $this->encryptionService->encrypt( $transactionData['description'], $this->encryptionKey, - "demo_tx_{$account->id}_{$index}" + "demo_tx_{$account->id}_{$transactionIndex}" ); $transactionData['description'] = $encrypted['encrypted']; @@ -336,6 +363,7 @@ class ResetDemoAccountCommand extends Command } } + $transactionIndex++; $count++; } @@ -378,6 +406,141 @@ class ResetDemoAccountCommand extends Command return $type !== AccountType::Investment && $type !== AccountType::Retirement; } + private function createBudgets(User $user): void + { + $categories = $user->categories()->get()->keyBy('name'); + + $groceriesCategory = $categories->get('Groceries'); + $diningCategory = $categories->get('Cafes, restaurants, bars'); + + if (! $groceriesCategory || ! $diningCategory) { + $this->warn(' Skipping budget creation - required categories not found'); + + return; + } + + $budget1 = Budget::create([ + 'user_id' => $user->id, + 'name' => 'Monthly Groceries', + 'period_type' => BudgetPeriodType::Monthly, + 'period_start_day' => 1, + 'category_id' => $groceriesCategory->id, + 'rollover_type' => RolloverType::CarryOver, + ]); + + $this->generateHistoricalPeriods($budget1, 320000); + + $budget2 = Budget::create([ + 'user_id' => $user->id, + 'name' => 'Weekly Dining Out', + 'period_type' => BudgetPeriodType::Weekly, + 'period_start_day' => 0, + 'category_id' => $diningCategory->id, + 'rollover_type' => RolloverType::Reset, + ]); + + $this->generateHistoricalPeriods($budget2, 20000); + + $groceriesPeriodCount = $budget1->periods()->count(); + $diningPeriodCount = $budget2->periods()->count(); + $groceriesCurrentPeriod = $budget1->getCurrentPeriod(); + $diningCurrentPeriod = $budget2->getCurrentPeriod(); + + $this->info(' Created 2 budgets with historical periods'); + $this->info(" - Monthly Groceries: {$groceriesPeriodCount} periods".($groceriesCurrentPeriod ? ' (has current period)' : ' (no current period)')); + $this->info(" - Weekly Dining Out: {$diningPeriodCount} periods".($diningCurrentPeriod ? ' (has current period)' : ' (no current period)')); + } + + private function generateHistoricalPeriods(Budget $budget, int $allocatedAmount): void + { + $transactionStartDate = now()->subMonths(12); + $endDate = now()->addWeek(); + + $currentDate = $transactionStartDate->copy(); + + if ($budget->period_type === BudgetPeriodType::Weekly) { + $dayOfWeek = $budget->period_start_day ?? 0; + while ($currentDate->dayOfWeek !== $dayOfWeek) { + $currentDate->subDay(); + } + } elseif ($budget->period_type === BudgetPeriodType::Monthly) { + $currentDate->startOfMonth(); + if ($budget->period_start_day) { + $currentDate->day($budget->period_start_day); + } + } + + $maxIterations = 1000; + $iteration = 0; + + while ($currentDate->lte($endDate) && $iteration < $maxIterations) { + $period = $this->budgetPeriodService->generatePeriod($budget, $allocatedAmount, $currentDate); + + if ($period->end_date->gte($endDate)) { + break; + } + + $currentDate = $period->end_date->copy()->addDay(); + + switch ($budget->period_type) { + case BudgetPeriodType::Monthly: + if ($budget->period_start_day) { + $currentDate->day($budget->period_start_day); + } else { + $currentDate->startOfMonth(); + } + break; + case BudgetPeriodType::Weekly: + $dayOfWeek = $budget->period_start_day ?? 0; + while ($currentDate->dayOfWeek !== $dayOfWeek && $currentDate->lte($endDate)) { + $currentDate->addDay(); + } + break; + case BudgetPeriodType::Biweekly: + $dayOfWeek = $budget->period_start_day ?? 0; + while ($currentDate->dayOfWeek !== $dayOfWeek && $currentDate->lte($endDate)) { + $currentDate->addDay(); + } + break; + case BudgetPeriodType::Custom: + break; + } + + $iteration++; + } + } + + private function enableBudgetsFeature(User $user): void + { + Feature::for($user)->activate('budgets'); + } + + private function assignTransactionsToBudgets(User $user): void + { + $transactions = $user->transactions()->get(); + $assignedCount = 0; + $budgetAssignments = []; + + foreach ($transactions as $transaction) { + $this->budgetTransactionService->assignTransaction($transaction); + $transaction->refresh(); + $budgetTransactions = $transaction->budgetTransactions()->with('budgetPeriod.budget')->get(); + + if ($budgetTransactions->isNotEmpty()) { + $assignedCount++; + foreach ($budgetTransactions as $budgetTransaction) { + $budgetName = $budgetTransaction->budgetPeriod->budget->name; + $budgetAssignments[$budgetName] = ($budgetAssignments[$budgetName] ?? 0) + 1; + } + } + } + + $this->info(" Assigned {$assignedCount} transactions to budgets"); + foreach ($budgetAssignments as $budgetName => $count) { + $this->info(" - {$budgetName}: {$count} transactions"); + } + } + private function createSubscription(User $user): void { $user->subscriptions()->delete(); diff --git a/app/Enums/BudgetPeriodType.php b/app/Enums/BudgetPeriodType.php new file mode 100644 index 00000000..d4c3f08e --- /dev/null +++ b/app/Enums/BudgetPeriodType.php @@ -0,0 +1,21 @@ + 'Monthly', + self::Weekly => 'Weekly', + self::Biweekly => 'Bi-weekly', + self::Custom => 'Custom', + }; + } +} diff --git a/app/Enums/RolloverType.php b/app/Enums/RolloverType.php new file mode 100644 index 00000000..d283b0b8 --- /dev/null +++ b/app/Enums/RolloverType.php @@ -0,0 +1,25 @@ + 'Carry Over', + self::Reset => 'Reset/Pool', + }; + } + + public function description(): string + { + return match ($this) { + self::CarryOver => 'Remaining balance carries over to next period', + self::Reset => 'Remaining balance returns to available money pool', + }; + } +} diff --git a/app/Events/TransactionCreated.php b/app/Events/TransactionCreated.php new file mode 100644 index 00000000..fafc3b83 --- /dev/null +++ b/app/Events/TransactionCreated.php @@ -0,0 +1,14 @@ +user(); + $budgets = $user + ->budgets() + ->with(['category', 'label', 'periods' => function ($query) { + $query->where('start_date', '<=', now()) + ->where('end_date', '>=', now()) + ->with(['budgetTransactions']); + }]) + ->get(); + + return Inertia::render('budgets/index', [ + 'budgets' => $budgets, + 'currencyCode' => $user->currency_code ?? 'USD', + ]); + } + + public function show(Request $request, Budget $budget): Response + { + $this->authorize('view', $budget); + + $user = $request->user(); + + $currentPeriod = $budget->getCurrentPeriod(); + + if (! $currentPeriod) { + $currentPeriod = $this->budgetPeriodService->generatePeriod($budget); + } + + $currentPeriod->load([ + 'budgetTransactions.transaction.account.bank', + 'budgetTransactions.transaction.category', + 'budgetTransactions.transaction.labels', + ]); + + $budget->load(['category', 'label']); + + $categories = \App\Models\Category::query() + ->where('user_id', $user->id) + ->orderBy('name') + ->get(['id', 'name', 'icon', 'color']); + + $accounts = \App\Models\Account::query() + ->where('user_id', $user->id) + ->with('bank:id,name,logo') + ->orderBy('name') + ->get(['id', 'name', 'name_iv', 'bank_id', 'type', 'currency_code']); + + $banks = \App\Models\Bank::query() + ->where(function ($q) use ($user) { + $q->whereNull('user_id') + ->orWhere('user_id', $user->id); + }) + ->orderBy('name') + ->get(['id', 'name', 'logo']); + + return Inertia::render('budgets/show', [ + 'budget' => $budget, + 'currentPeriod' => $currentPeriod, + 'categories' => $categories, + 'accounts' => $accounts, + 'banks' => $banks, + 'currencyCode' => $user->currency_code ?? 'USD', + ]); + } + + public function store(StoreBudgetRequest $request): \Illuminate\Http\RedirectResponse + { + $budget = DB::transaction(function () use ($request) { + $budget = $request->user()->budgets()->create([ + 'name' => $request->name, + 'period_type' => $request->period_type, + 'period_duration' => $request->period_duration, + 'period_start_day' => $request->period_start_day, + 'category_id' => $request->category_id, + 'label_id' => $request->label_id, + 'rollover_type' => $request->rollover_type, + ]); + + $period = $this->budgetPeriodService->generatePeriod($budget, $request->allocated_amount); + + return $budget; + }); + + return redirect()->route('budgets.show', $budget); + } + + public function update(UpdateBudgetRequest $request, Budget $budget): \Illuminate\Http\RedirectResponse + { + $this->authorize('update', $budget); + + DB::transaction(function () use ($request, $budget) { + $budget->update($request->only([ + 'name', + 'period_type', + 'period_duration', + 'period_start_day', + 'category_id', + 'label_id', + 'rollover_type', + ])); + + // If allocated_amount is provided, update current and future periods + if ($request->has('allocated_amount')) { + $budget->periods() + ->where('start_date', '>=', now()->startOfDay()) + ->update(['allocated_amount' => $request->allocated_amount]); + } + }); + + return redirect()->route('budgets.show', $budget); + } + + public function destroy(Request $request, Budget $budget): \Illuminate\Http\RedirectResponse + { + $this->authorize('delete', $budget); + + $budget->delete(); + + return redirect()->route('budgets.index'); + } +} diff --git a/app/Http/Controllers/Settings/AccountController.php b/app/Http/Controllers/Settings/AccountController.php index cb33ef74..a09b1365 100644 --- a/app/Http/Controllers/Settings/AccountController.php +++ b/app/Http/Controllers/Settings/AccountController.php @@ -37,7 +37,13 @@ class AccountController extends Controller */ public function store(StoreAccountRequest $request): RedirectResponse|JsonResponse { - $account = auth()->user()->accounts()->create($request->validated()); + $user = auth()->user(); + $account = $user->accounts()->create($request->validated()); + + // Set user's currency_code from first account if not already set + if (is_null($user->currency_code)) { + $user->update(['currency_code' => $account->currency_code]); + } if ($request->wantsJson()) { return response()->json($account, 201); diff --git a/app/Http/Middleware/EnsureBudgetsFeature.php b/app/Http/Middleware/EnsureBudgetsFeature.php new file mode 100644 index 00000000..27391522 --- /dev/null +++ b/app/Http/Middleware/EnsureBudgetsFeature.php @@ -0,0 +1,29 @@ +user(); + + if (! $user || ! Feature::for($user)->active('budgets')) { + abort(404); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 56e71035..1b2a85c8 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -5,6 +5,7 @@ namespace App\Http\Middleware; use Illuminate\Foundation\Inspiring; use Illuminate\Http\Request; use Inertia\Middleware; +use Laravel\Pennant\Feature; class HandleInertiaRequests extends Middleware { @@ -68,6 +69,7 @@ class HandleInertiaRequests extends Middleware 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', 'features' => [ 'cashflow' => true, + 'budgets' => $user ? Feature::for($user)->active('budgets') : false, ], 'accounts' => fn () => $user ? $user->accounts() ->with('bank:id,name,logo') diff --git a/app/Http/Requests/StoreBudgetRequest.php b/app/Http/Requests/StoreBudgetRequest.php new file mode 100644 index 00000000..f44a0997 --- /dev/null +++ b/app/Http/Requests/StoreBudgetRequest.php @@ -0,0 +1,47 @@ +user()->id; + + return [ + 'name' => ['required', 'string', 'max:255'], + 'period_type' => ['required', Rule::enum(BudgetPeriodType::class)], + 'period_duration' => ['nullable', 'integer', 'min:1', 'max:365'], + 'period_start_day' => ['nullable', 'integer', 'min:0', 'max:31'], + 'category_id' => ['nullable', Rule::exists('categories', 'id')->where('user_id', $userId)], + 'label_id' => ['nullable', Rule::exists('labels', 'id')->where('user_id', $userId)], + 'rollover_type' => ['required', Rule::enum(RolloverType::class)], + 'allocated_amount' => ['required', 'integer', 'min:0'], + ]; + } + + public function withValidator($validator): void + { + $validator->after(function ($validator) { + $hasCategoryId = ! empty($this->category_id); + $hasLabelId = ! empty($this->label_id); + + if (! $hasCategoryId && ! $hasLabelId) { + $validator->errors()->add( + 'selection', + 'You must select either a category or a label.' + ); + } + }); + } +} diff --git a/app/Http/Requests/UpdateBudgetRequest.php b/app/Http/Requests/UpdateBudgetRequest.php new file mode 100644 index 00000000..e06420d8 --- /dev/null +++ b/app/Http/Requests/UpdateBudgetRequest.php @@ -0,0 +1,32 @@ +user()->id; + + return [ + 'name' => ['sometimes', 'string', 'max:255'], + 'period_type' => ['sometimes', Rule::enum(BudgetPeriodType::class)], + 'period_duration' => ['nullable', 'integer', 'min:1', 'max:365'], + 'period_start_day' => ['nullable', 'integer', 'min:0', 'max:31'], + 'category_id' => ['nullable', Rule::exists('categories', 'id')->where('user_id', $userId)], + 'label_id' => ['nullable', Rule::exists('labels', 'id')->where('user_id', $userId)], + 'rollover_type' => ['sometimes', Rule::enum(RolloverType::class)], + 'allocated_amount' => ['sometimes', 'integer', 'min:0'], + ]; + } +} diff --git a/app/Listeners/AssignTransactionToBudget.php b/app/Listeners/AssignTransactionToBudget.php new file mode 100644 index 00000000..aea8a5cb --- /dev/null +++ b/app/Listeners/AssignTransactionToBudget.php @@ -0,0 +1,26 @@ +transaction; + $user = $transaction->user; + + if (! $user || ! Feature::for($user)->active('budgets')) { + return; + } + + $this->budgetTransactionService->assignTransaction($transaction); + } +} diff --git a/app/Listeners/UnassignTransactionFromBudget.php b/app/Listeners/UnassignTransactionFromBudget.php new file mode 100644 index 00000000..9be70f47 --- /dev/null +++ b/app/Listeners/UnassignTransactionFromBudget.php @@ -0,0 +1,25 @@ +transaction; + $user = $transaction->user; + + if (! $user || ! Feature::for($user)->active('budgets')) { + return; + } + + $this->budgetTransactionService->unassignTransaction($transaction); + } +} diff --git a/app/Models/Budget.php b/app/Models/Budget.php new file mode 100644 index 00000000..0a023cf6 --- /dev/null +++ b/app/Models/Budget.php @@ -0,0 +1,66 @@ + BudgetPeriodType::class, + 'rollover_type' => RolloverType::class, + 'period_duration' => 'integer', + 'period_start_day' => 'integer', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function category(): BelongsTo + { + return $this->belongsTo(Category::class); + } + + public function label(): BelongsTo + { + return $this->belongsTo(Label::class); + } + + public function periods(): HasMany + { + return $this->hasMany(BudgetPeriod::class); + } + + public function getCurrentPeriod(): ?BudgetPeriod + { + return $this->periods() + ->where('start_date', '<=', now()) + ->where('end_date', '>=', now()) + ->first(); + } +} diff --git a/app/Models/BudgetPeriod.php b/app/Models/BudgetPeriod.php new file mode 100644 index 00000000..4b423b89 --- /dev/null +++ b/app/Models/BudgetPeriod.php @@ -0,0 +1,42 @@ + 'date', + 'end_date' => 'date', + 'allocated_amount' => 'integer', + 'carried_over_amount' => 'integer', + ]; + } + + public function budget(): BelongsTo + { + return $this->belongsTo(Budget::class); + } + + public function budgetTransactions(): HasMany + { + return $this->hasMany(BudgetTransaction::class); + } +} diff --git a/app/Models/BudgetTransaction.php b/app/Models/BudgetTransaction.php new file mode 100644 index 00000000..bf945b3b --- /dev/null +++ b/app/Models/BudgetTransaction.php @@ -0,0 +1,36 @@ + 'integer', + ]; + } + + public function transaction(): BelongsTo + { + return $this->belongsTo(Transaction::class); + } + + public function budgetPeriod(): BelongsTo + { + return $this->belongsTo(BudgetPeriod::class); + } +} diff --git a/app/Models/Transaction.php b/app/Models/Transaction.php index aa22ac9e..12b761b5 100644 --- a/app/Models/Transaction.php +++ b/app/Models/Transaction.php @@ -3,11 +3,15 @@ namespace App\Models; use App\Enums\TransactionSource; +use App\Events\TransactionCreated; +use App\Events\TransactionDeleted; +use App\Events\TransactionUpdated; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; class Transaction extends Model @@ -15,6 +19,13 @@ class Transaction extends Model /** @use HasFactory<\Database\Factories\TransactionFactory> */ use HasFactory, HasUuids, SoftDeletes; + /** @var array */ + protected $dispatchesEvents = [ + 'created' => TransactionCreated::class, + 'updated' => TransactionUpdated::class, + 'deleted' => TransactionDeleted::class, + ]; + protected $fillable = [ 'user_id', 'account_id', @@ -59,4 +70,9 @@ class Transaction extends Model ->using(LabelTransaction::class) ->withTimestamps(); } + + public function budgetTransactions(): HasMany + { + return $this->hasMany(BudgetTransaction::class); + } } diff --git a/app/Models/User.php b/app/Models/User.php index 9039ee54..6106e585 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -13,11 +13,12 @@ use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Cashier\Billable; use Laravel\Fortify\TwoFactorAuthenticatable; +use Laravel\Pennant\Concerns\HasFeatures; class User extends Authenticatable { /** @use HasFactory<\Database\Factories\UserFactory> */ - use Billable, HasFactory, HasUuids, Notifiable, TwoFactorAuthenticatable; + use Billable, HasFactory, HasFeatures, HasUuids, Notifiable, TwoFactorAuthenticatable; /** * The attributes that are mass assignable. @@ -109,6 +110,11 @@ class User extends Authenticatable return $this->hasMany(UserMailLog::class); } + public function budgets(): HasMany + { + return $this->hasMany(Budget::class); + } + public function hasReceivedEmail(DripEmailType $type): bool { return $this->mailLogs()->where('email_type', $type)->exists(); diff --git a/app/Policies/BudgetPolicy.php b/app/Policies/BudgetPolicy.php new file mode 100644 index 00000000..fb181589 --- /dev/null +++ b/app/Policies/BudgetPolicy.php @@ -0,0 +1,44 @@ +id === $budget->user_id; + } + + public function create(User $user): bool + { + return true; + } + + public function update(User $user, Budget $budget): bool + { + return $user->id === $budget->user_id; + } + + public function delete(User $user, Budget $budget): bool + { + return $user->id === $budget->user_id; + } + + public function restore(User $user, Budget $budget): bool + { + return $user->id === $budget->user_id; + } + + public function forceDelete(User $user, Budget $budget): bool + { + return $user->id === $budget->user_id; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 05d1d5d6..cc248124 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,11 +2,19 @@ namespace App\Providers; +use App\Events\TransactionCreated; +use App\Events\TransactionDeleted; +use App\Events\TransactionUpdated; use App\Http\Responses\RegisterResponse; +use App\Listeners\AssignTransactionToBudget; +use App\Listeners\UnassignTransactionFromBudget; +use App\Models\User; use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract; +use Laravel\Pennant\Feature; class AppServiceProvider extends ServiceProvider { @@ -23,8 +31,14 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { + Event::listen(TransactionCreated::class, AssignTransactionToBudget::class); + Event::listen(TransactionUpdated::class, AssignTransactionToBudget::class); + Event::listen(TransactionDeleted::class, UnassignTransactionFromBudget::class); + RateLimiter::for('emails', function (object $job): Limit { return Limit::perSecond(30); }); + + Feature::define('budgets', fn (User $user) => false); } } diff --git a/app/Services/BudgetPeriodService.php b/app/Services/BudgetPeriodService.php new file mode 100644 index 00000000..680cbd46 --- /dev/null +++ b/app/Services/BudgetPeriodService.php @@ -0,0 +1,108 @@ +calculateNextPeriodStartDate($budget); + } + + [$periodStart, $periodEnd] = $this->calculatePeriodDates($budget, $startDate); + + // If no allocated amount provided, use the last period's amount or 0 + if ($allocatedAmount === null) { + $lastPeriod = $budget->periods()->orderBy('end_date', 'desc')->first(); + $allocatedAmount = $lastPeriod?->allocated_amount ?? 0; + } + + return BudgetPeriod::create([ + 'budget_id' => $budget->id, + 'start_date' => $periodStart, + 'end_date' => $periodEnd, + 'allocated_amount' => $allocatedAmount, + 'carried_over_amount' => 0, + ]); + } + + public function closePeriod(BudgetPeriod $period): void + { + $budget = $period->budget; + $carriedOverAmount = 0; + + if ($budget->rollover_type->value === 'carry_over') { + $totalSpent = $period->budgetTransactions()->sum('amount'); + $remaining = $period->allocated_amount - abs($totalSpent); + + if ($remaining > 0) { + $carriedOverAmount = $remaining; + } + } + + $nextPeriod = $this->generatePeriod($budget, $period->allocated_amount); + $nextPeriod->update(['carried_over_amount' => $carriedOverAmount]); + } + + public function calculatePeriodDates(Budget $budget, Carbon $referenceDate): array + { + $startDate = $referenceDate->copy(); + + switch ($budget->period_type) { + case BudgetPeriodType::Monthly: + $startDate->day($budget->period_start_day ?? 1); + if ($startDate > $referenceDate) { + $startDate->subMonth(); + } + $endDate = $startDate->copy()->addMonth()->subDay(); + break; + + case BudgetPeriodType::Weekly: + $dayOfWeek = $budget->period_start_day ?? 0; + while ($startDate->dayOfWeek !== $dayOfWeek) { + $startDate->subDay(); + } + $endDate = $startDate->copy()->addWeek()->subDay(); + break; + + case BudgetPeriodType::Biweekly: + $dayOfWeek = $budget->period_start_day ?? 0; + while ($startDate->dayOfWeek !== $dayOfWeek) { + $startDate->subDay(); + } + $endDate = $startDate->copy()->addWeeks(2)->subDay(); + break; + + case BudgetPeriodType::Custom: + $duration = $budget->period_duration ?? 30; + $startDate->day($budget->period_start_day ?? 1); + if ($startDate > $referenceDate) { + $startDate->subDays($duration); + } + $endDate = $startDate->copy()->addDays($duration)->subDay(); + break; + + default: + $endDate = $startDate->copy()->addMonth()->subDay(); + } + + return [$startDate, $endDate]; + } + + protected function calculateNextPeriodStartDate(Budget $budget): Carbon + { + $lastPeriod = $budget->periods()->orderBy('end_date', 'desc')->first(); + + if ($lastPeriod) { + return $lastPeriod->end_date->copy()->addDay(); + } + + return now(); + } +} diff --git a/app/Services/BudgetTransactionService.php b/app/Services/BudgetTransactionService.php new file mode 100644 index 00000000..fc4b577d --- /dev/null +++ b/app/Services/BudgetTransactionService.php @@ -0,0 +1,74 @@ +budgetTransactions()->exists()) { + $this->unassignTransaction($transaction); + } + + // Get the user who owns this transaction + $userId = $transaction->user_id; + + if (! $userId) { + return; + } + + // Find matching budget periods for this user only + $budgetPeriods = BudgetPeriod::query() + ->whereHas('budget', function ($query) use ($transaction, $userId) { + // Scope to user's budgets only + $query->where('user_id', $userId) + ->where(function ($q) use ($transaction) { + // Match by category + $q->where('category_id', $transaction->category_id) + ->orWhere(function ($labelQuery) use ($transaction) { + // Match by label + $labelQuery->whereHas('label', function ($lq) use ($transaction) { + $lq->whereIn('id', $transaction->labels->pluck('id')); + }); + }); + }); + }) + ->where('start_date', '<=', $transaction->transaction_date) + ->where('end_date', '>=', $transaction->transaction_date) + ->with('budget') + ->get(); + + foreach ($budgetPeriods as $period) { + $budget = $period->budget; + + // Double-check transaction matches budget criteria + $matches = false; + + if ($budget->category_id && $budget->category_id === $transaction->category_id) { + $matches = true; + } + + if ($budget->label_id && $transaction->labels->contains('id', $budget->label_id)) { + $matches = true; + } + + if ($matches) { + BudgetTransaction::create([ + 'transaction_id' => $transaction->id, + 'budget_period_id' => $period->id, + 'amount' => abs($transaction->amount), + ]); + } + } + } + + public function unassignTransaction(Transaction $transaction): void + { + BudgetTransaction::where('transaction_id', $transaction->id)->delete(); + } +} diff --git a/app/Services/Demo/DemoTransactionsProvider.php b/app/Services/Demo/DemoTransactionsProvider.php index a3cda5f7..935202f4 100644 --- a/app/Services/Demo/DemoTransactionsProvider.php +++ b/app/Services/Demo/DemoTransactionsProvider.php @@ -14,46 +14,145 @@ class DemoTransactionsProvider ['description' => 'Whole Foods Market', 'amount_min' => -15000, 'amount_max' => -8000, 'category_name' => 'Groceries', 'frequency' => 'weekly'], ['description' => 'Trader Joe\'s', 'amount_min' => -8500, 'amount_max' => -4500, 'category_name' => 'Groceries', 'frequency' => 'weekly'], ['description' => 'Costco Wholesale', 'amount_min' => -25000, 'amount_max' => -12000, 'category_name' => 'Groceries', 'frequency' => 'monthly'], + ['description' => 'Safeway Supermarket', 'amount_min' => -12000, 'amount_max' => -6500, 'category_name' => 'Groceries', 'frequency' => 'weekly'], + ['description' => 'Publix Grocery Store', 'amount_min' => -11000, 'amount_max' => -6000, 'category_name' => 'Groceries', 'frequency' => 'weekly'], + ['description' => 'Kroger Marketplace', 'amount_min' => -9500, 'amount_max' => -5000, 'category_name' => 'Groceries', 'frequency' => 'weekly'], + ['description' => 'Aldi Grocery', 'amount_min' => -7500, 'amount_max' => -4000, 'category_name' => 'Groceries', 'frequency' => 'weekly'], + ['description' => 'Sprouts Farmers Market', 'amount_min' => -10000, 'amount_max' => -5500, 'category_name' => 'Groceries', 'frequency' => 'biweekly'], ['description' => 'Starbucks Coffee', 'amount_min' => -850, 'amount_max' => -450, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'frequent'], ['description' => 'Dunkin Donuts', 'amount_min' => -650, 'amount_max' => -350, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'frequent'], + ['description' => 'Peet\'s Coffee', 'amount_min' => -750, 'amount_max' => -400, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'frequent'], + ['description' => 'Local Coffee Shop', 'amount_min' => -900, 'amount_max' => -500, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'frequent'], + ['description' => 'Chipotle Mexican Grill', 'amount_min' => -1800, 'amount_max' => -1200, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'weekly'], + ['description' => 'Olive Garden Restaurant', 'amount_min' => -8500, 'amount_max' => -4500, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'monthly'], + ['description' => 'Thai Palace Dinner', 'amount_min' => -6500, 'amount_max' => -3500, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'monthly'], + ['description' => 'Sushi House', 'amount_min' => -7500, 'amount_max' => -4000, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'monthly'], + ['description' => 'Pizza Hut', 'amount_min' => -3500, 'amount_max' => -1800, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'biweekly'], + ['description' => 'Domino\'s Pizza', 'amount_min' => -3200, 'amount_max' => -1500, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'biweekly'], + ['description' => 'Papa John\'s Pizza', 'amount_min' => -3000, 'amount_max' => -1400, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'biweekly'], + ['description' => 'Red Lobster', 'amount_min' => -9500, 'amount_max' => -5000, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'quarterly'], + ['description' => 'Outback Steakhouse', 'amount_min' => -8500, 'amount_max' => -4500, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'quarterly'], + ['description' => 'Applebees', 'amount_min' => -6500, 'amount_max' => -3500, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'monthly'], + ['description' => 'TGI Fridays', 'amount_min' => -7000, 'amount_max' => -3800, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'monthly'], + ['description' => 'Buffalo Wild Wings', 'amount_min' => -5500, 'amount_max' => -2800, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'monthly'], + ['description' => 'Panera Bread', 'amount_min' => -2500, 'amount_max' => -1200, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'weekly'], + ['description' => 'Subway', 'amount_min' => -1200, 'amount_max' => -700, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'weekly'], + ['description' => 'McDonald\'s', 'amount_min' => -1000, 'amount_max' => -600, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'weekly'], + ['description' => 'Burger King', 'amount_min' => -950, 'amount_max' => -550, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'weekly'], + ['description' => 'Taco Bell', 'amount_min' => -800, 'amount_max' => -450, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'weekly'], + ['description' => 'Indian Curry House', 'amount_min' => -6000, 'amount_max' => -3200, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'monthly'], + ['description' => 'Mediterranean Grill', 'amount_min' => -5500, 'amount_max' => -2800, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'monthly'], + ['description' => 'Chinese Restaurant', 'amount_min' => -5000, 'amount_max' => -2500, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'monthly'], + ['description' => 'Italian Bistro', 'amount_min' => -7500, 'amount_max' => -4000, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'monthly'], ['description' => 'Shell Gas Station', 'amount_min' => -6500, 'amount_max' => -3500, 'category_name' => 'Fuel', 'frequency' => 'biweekly'], ['description' => 'Chevron Gas', 'amount_min' => -5800, 'amount_max' => -3200, 'category_name' => 'Fuel', 'frequency' => 'biweekly'], + ['description' => 'BP Gas Station', 'amount_min' => -6200, 'amount_max' => -3400, 'category_name' => 'Fuel', 'frequency' => 'biweekly'], + ['description' => 'Exxon Mobil', 'amount_min' => -6000, 'amount_max' => -3300, 'category_name' => 'Fuel', 'frequency' => 'biweekly'], + ['description' => 'Speedway Gas', 'amount_min' => -5500, 'amount_max' => -3000, 'category_name' => 'Fuel', 'frequency' => 'biweekly'], ['description' => 'Salary Deposit - ACME Corp', 'amount_min' => 485000, 'amount_max' => 485000, 'category_name' => 'Salary', 'frequency' => 'monthly'], + ['description' => 'Freelance Payment - Web Design', 'amount_min' => 25000, 'amount_max' => 75000, 'category_name' => 'Salary', 'frequency' => 'monthly'], + ['description' => 'Quarterly Bonus', 'amount_min' => 50000, 'amount_max' => 150000, 'category_name' => 'Salary', 'frequency' => 'quarterly'], ['description' => 'Electric Company - Monthly Bill', 'amount_min' => -18500, 'amount_max' => -9500, 'category_name' => 'Electricity', 'frequency' => 'monthly'], ['description' => 'Water & Sewer Utility', 'amount_min' => -7500, 'amount_max' => -4500, 'category_name' => 'Water', 'frequency' => 'monthly'], ['description' => 'Natural Gas Bill', 'amount_min' => -12000, 'amount_max' => -4500, 'category_name' => 'Natural gas', 'frequency' => 'monthly'], + ['description' => 'Trash Collection Service', 'amount_min' => -3500, 'amount_max' => -2000, 'category_name' => 'Water', 'frequency' => 'monthly'], ['description' => 'Comcast Internet & Cable', 'amount_min' => -15999, 'amount_max' => -12999, 'category_name' => 'Telephone, internet, TV, computer', 'frequency' => 'monthly'], ['description' => 'T-Mobile Wireless', 'amount_min' => -8500, 'amount_max' => -7500, 'category_name' => 'Telephone, internet, TV, computer', 'frequency' => 'monthly'], + ['description' => 'Verizon Wireless', 'amount_min' => -9000, 'amount_max' => -8000, 'category_name' => 'Telephone, internet, TV, computer', 'frequency' => 'monthly'], + ['description' => 'AT&T Mobile', 'amount_min' => -8800, 'amount_max' => -7800, 'category_name' => 'Telephone, internet, TV, computer', 'frequency' => 'monthly'], + ['description' => 'Spectrum Internet', 'amount_min' => -7999, 'amount_max' => -6999, 'category_name' => 'Telephone, internet, TV, computer', 'frequency' => 'monthly'], ['description' => 'Netflix Subscription', 'amount_min' => -1599, 'amount_max' => -1599, 'category_name' => 'Online services', 'frequency' => 'monthly'], ['description' => 'Spotify Premium', 'amount_min' => -1099, 'amount_max' => -1099, 'category_name' => 'Online services', 'frequency' => 'monthly'], ['description' => 'Amazon Prime', 'amount_min' => -1499, 'amount_max' => -1499, 'category_name' => 'Online services', 'frequency' => 'monthly'], ['description' => 'Disney+ Subscription', 'amount_min' => -1099, 'amount_max' => -1099, 'category_name' => 'Online services', 'frequency' => 'monthly'], - ['description' => 'Chipotle Mexican Grill', 'amount_min' => -1800, 'amount_max' => -1200, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'weekly'], - ['description' => 'Olive Garden Restaurant', 'amount_min' => -8500, 'amount_max' => -4500, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'monthly'], - ['description' => 'Thai Palace Dinner', 'amount_min' => -6500, 'amount_max' => -3500, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'monthly'], + ['description' => 'Hulu Subscription', 'amount_min' => -799, 'amount_max' => -799, 'category_name' => 'Online services', 'frequency' => 'monthly'], + ['description' => 'HBO Max', 'amount_min' => -1599, 'amount_max' => -1599, 'category_name' => 'Online services', 'frequency' => 'monthly'], + ['description' => 'Apple Music', 'amount_min' => -1099, 'amount_max' => -1099, 'category_name' => 'Online services', 'frequency' => 'monthly'], + ['description' => 'YouTube Premium', 'amount_min' => -1399, 'amount_max' => -1399, 'category_name' => 'Online services', 'frequency' => 'monthly'], + ['description' => 'Adobe Creative Cloud', 'amount_min' => -5499, 'amount_max' => -5499, 'category_name' => 'Online services', 'frequency' => 'monthly'], + ['description' => 'Microsoft 365', 'amount_min' => -699, 'amount_max' => -699, 'category_name' => 'Online services', 'frequency' => 'monthly'], + ['description' => 'Dropbox Plus', 'amount_min' => -999, 'amount_max' => -999, 'category_name' => 'Online services', 'frequency' => 'monthly'], ['description' => 'DoorDash Delivery', 'amount_min' => -4500, 'amount_max' => -2500, 'category_name' => 'Food delivery', 'frequency' => 'weekly'], ['description' => 'Uber Eats Order', 'amount_min' => -3800, 'amount_max' => -2200, 'category_name' => 'Food delivery', 'frequency' => 'weekly'], + ['description' => 'Grubhub Delivery', 'amount_min' => -4200, 'amount_max' => -2400, 'category_name' => 'Food delivery', 'frequency' => 'weekly'], + ['description' => 'Postmates', 'amount_min' => -4000, 'amount_max' => -2300, 'category_name' => 'Food delivery', 'frequency' => 'weekly'], ['description' => 'Amazon.com Purchase', 'amount_min' => -15000, 'amount_max' => -2500, 'category_name' => 'Online transactions', 'frequency' => 'weekly'], + ['description' => 'eBay Purchase', 'amount_min' => -12000, 'amount_max' => -3000, 'category_name' => 'Online transactions', 'frequency' => 'biweekly'], + ['description' => 'Etsy Order', 'amount_min' => -8000, 'amount_max' => -2000, 'category_name' => 'Online transactions', 'frequency' => 'monthly'], ['description' => 'Target Store', 'amount_min' => -12000, 'amount_max' => -3500, 'category_name' => 'Household goods', 'frequency' => 'biweekly'], ['description' => 'Walmart Supercenter', 'amount_min' => -8500, 'amount_max' => -2500, 'category_name' => 'Other groceries', 'frequency' => 'biweekly'], + ['description' => 'Home Depot', 'amount_min' => -15000, 'amount_max' => -4000, 'category_name' => 'Household goods', 'frequency' => 'monthly'], + ['description' => 'Lowe\'s Home Improvement', 'amount_min' => -14000, 'amount_max' => -3500, 'category_name' => 'Household goods', 'frequency' => 'monthly'], + ['description' => 'Bed Bath & Beyond', 'amount_min' => -10000, 'amount_max' => -3000, 'category_name' => 'Household goods', 'frequency' => 'quarterly'], + ['description' => 'IKEA', 'amount_min' => -20000, 'amount_max' => -5000, 'category_name' => 'Household goods', 'frequency' => 'quarterly'], ['description' => 'CVS Pharmacy', 'amount_min' => -4500, 'amount_max' => -1500, 'category_name' => 'Health and pharmaceuticals', 'frequency' => 'monthly'], ['description' => 'Walgreens', 'amount_min' => -3500, 'amount_max' => -1200, 'category_name' => 'Health and pharmaceuticals', 'frequency' => 'monthly'], + ['description' => 'Rite Aid Pharmacy', 'amount_min' => -4000, 'amount_max' => -1300, 'category_name' => 'Health and pharmaceuticals', 'frequency' => 'monthly'], + ['description' => 'Doctor\'s Office Visit', 'amount_min' => -15000, 'amount_max' => -8000, 'category_name' => 'Health and pharmaceuticals', 'frequency' => 'quarterly'], + ['description' => 'Dentist Appointment', 'amount_min' => -12000, 'amount_max' => -6000, 'category_name' => 'Health and pharmaceuticals', 'frequency' => 'quarterly'], + ['description' => 'Pharmacy Prescription', 'amount_min' => -5000, 'amount_max' => -2000, 'category_name' => 'Health and pharmaceuticals', 'frequency' => 'monthly'], ['description' => 'Planet Fitness Monthly', 'amount_min' => -2499, 'amount_max' => -2499, 'category_name' => 'Sport and sports goods', 'frequency' => 'monthly'], + ['description' => '24 Hour Fitness', 'amount_min' => -3999, 'amount_max' => -3999, 'category_name' => 'Sport and sports goods', 'frequency' => 'monthly'], + ['description' => 'LA Fitness Membership', 'amount_min' => -3499, 'amount_max' => -3499, 'category_name' => 'Sport and sports goods', 'frequency' => 'monthly'], + ['description' => 'Golf Course Green Fees', 'amount_min' => -8500, 'amount_max' => -4500, 'category_name' => 'Sport and sports goods', 'frequency' => 'monthly'], + ['description' => 'Tennis Court Rental', 'amount_min' => -3500, 'amount_max' => -1500, 'category_name' => 'Sport and sports goods', 'frequency' => 'monthly'], ['description' => 'ATM Cash Withdrawal', 'amount_min' => -30000, 'amount_max' => -10000, 'category_name' => 'Cash withdrawal', 'frequency' => 'biweekly'], ['description' => 'State Farm Insurance', 'amount_min' => -15800, 'amount_max' => -12500, 'category_name' => 'Insurance', 'frequency' => 'monthly'], + ['description' => 'Geico Auto Insurance', 'amount_min' => -14500, 'amount_max' => -11000, 'category_name' => 'Insurance', 'frequency' => 'monthly'], + ['description' => 'Progressive Insurance', 'amount_min' => -15000, 'amount_max' => -12000, 'category_name' => 'Insurance', 'frequency' => 'monthly'], + ['description' => 'Health Insurance Premium', 'amount_min' => -35000, 'amount_max' => -25000, 'category_name' => 'Insurance', 'frequency' => 'monthly'], ['description' => 'Rent Payment', 'amount_min' => -195000, 'amount_max' => -195000, 'category_name' => 'Rent and maintanence', 'frequency' => 'monthly'], + ['description' => 'Homeowners Association Fee', 'amount_min' => -25000, 'amount_max' => -15000, 'category_name' => 'Rent and maintanence', 'frequency' => 'monthly'], + ['description' => 'Property Management Fee', 'amount_min' => -12000, 'amount_max' => -8000, 'category_name' => 'Rent and maintanence', 'frequency' => 'monthly'], ['description' => 'Uber Ride', 'amount_min' => -3500, 'amount_max' => -1200, 'category_name' => 'Transportation expenses', 'frequency' => 'weekly'], ['description' => 'Lyft Ride', 'amount_min' => -2800, 'amount_max' => -1000, 'category_name' => 'Transportation expenses', 'frequency' => 'weekly'], ['description' => 'Parking Garage', 'amount_min' => -2500, 'amount_max' => -800, 'category_name' => 'Parking', 'frequency' => 'weekly'], + ['description' => 'Street Parking Meter', 'amount_min' => -500, 'amount_max' => -200, 'category_name' => 'Parking', 'frequency' => 'weekly'], + ['description' => 'Toll Road Payment', 'amount_min' => -800, 'amount_max' => -300, 'category_name' => 'Transportation expenses', 'frequency' => 'weekly'], + ['description' => 'Car Wash', 'amount_min' => -1500, 'amount_max' => -800, 'category_name' => 'Transportation expenses', 'frequency' => 'monthly'], + ['description' => 'Oil Change Service', 'amount_min' => -4500, 'amount_max' => -2500, 'category_name' => 'Transportation expenses', 'frequency' => 'quarterly'], + ['description' => 'Auto Repair Shop', 'amount_min' => -25000, 'amount_max' => -8000, 'category_name' => 'Transportation expenses', 'frequency' => 'quarterly'], ['description' => 'H&M Clothing', 'amount_min' => -8500, 'amount_max' => -3500, 'category_name' => 'Clothing and shoes', 'frequency' => 'monthly'], ['description' => 'Nike Store', 'amount_min' => -15000, 'amount_max' => -6500, 'category_name' => 'Clothing and shoes', 'frequency' => 'quarterly'], + ['description' => 'Zara Fashion', 'amount_min' => -12000, 'amount_max' => -5000, 'category_name' => 'Clothing and shoes', 'frequency' => 'monthly'], + ['description' => 'Macy\'s Department Store', 'amount_min' => -18000, 'amount_max' => -8000, 'category_name' => 'Clothing and shoes', 'frequency' => 'quarterly'], + ['description' => 'Nordstrom', 'amount_min' => -25000, 'amount_max' => -12000, 'category_name' => 'Clothing and shoes', 'frequency' => 'quarterly'], + ['description' => 'Adidas Store', 'amount_min' => -13000, 'amount_max' => -6000, 'category_name' => 'Clothing and shoes', 'frequency' => 'quarterly'], ['description' => 'AMC Movie Theater', 'amount_min' => -3500, 'amount_max' => -1500, 'category_name' => 'Theatre, music, cinema', 'frequency' => 'monthly'], + ['description' => 'Regal Cinemas', 'amount_min' => -3200, 'amount_max' => -1400, 'category_name' => 'Theatre, music, cinema', 'frequency' => 'monthly'], + ['description' => 'Concert Tickets', 'amount_min' => -15000, 'amount_max' => -6000, 'category_name' => 'Theatre, music, cinema', 'frequency' => 'quarterly'], + ['description' => 'Theater Show Tickets', 'amount_min' => -12000, 'amount_max' => -5000, 'category_name' => 'Theatre, music, cinema', 'frequency' => 'quarterly'], ['description' => 'Barnes & Noble Books', 'amount_min' => -4500, 'amount_max' => -1500, 'category_name' => 'Books, newspapers, magazines', 'frequency' => 'monthly'], + ['description' => 'Kindle Book Purchase', 'amount_min' => -1299, 'amount_max' => -599, 'category_name' => 'Books, newspapers, magazines', 'frequency' => 'monthly'], + ['description' => 'New York Times Subscription', 'amount_min' => -1799, 'amount_max' => -1799, 'category_name' => 'Books, newspapers, magazines', 'frequency' => 'monthly'], + ['description' => 'Magazine Subscription', 'amount_min' => -999, 'amount_max' => -499, 'category_name' => 'Books, newspapers, magazines', 'frequency' => 'monthly'], + ['description' => 'Haircut & Styling', 'amount_min' => -3500, 'amount_max' => -1500, 'category_name' => 'Other personal transfers', 'frequency' => 'monthly'], + ['description' => 'Barbershop', 'amount_min' => -2500, 'amount_max' => -1000, 'category_name' => 'Other personal transfers', 'frequency' => 'monthly'], + ['description' => 'Nail Salon', 'amount_min' => -4500, 'amount_max' => -2000, 'category_name' => 'Other personal transfers', 'frequency' => 'monthly'], + ['description' => 'Dry Cleaning', 'amount_min' => -2500, 'amount_max' => -1200, 'category_name' => 'Other personal transfers', 'frequency' => 'biweekly'], + ['description' => 'Laundromat', 'amount_min' => -1200, 'amount_max' => -600, 'category_name' => 'Other personal transfers', 'frequency' => 'weekly'], + ['description' => 'Pet Store - Petco', 'amount_min' => -8500, 'amount_max' => -3500, 'category_name' => 'Other personal transfers', 'frequency' => 'monthly'], + ['description' => 'Veterinary Visit', 'amount_min' => -12000, 'amount_max' => -6000, 'category_name' => 'Other personal transfers', 'frequency' => 'quarterly'], + ['description' => 'Pet Grooming', 'amount_min' => -5500, 'amount_max' => -2500, 'category_name' => 'Other personal transfers', 'frequency' => 'monthly'], + ['description' => 'Starbucks Gift Card', 'amount_min' => -5000, 'amount_max' => -2500, 'category_name' => 'Other personal transfers', 'frequency' => 'quarterly'], + ['description' => 'Amazon Gift Card', 'amount_min' => -10000, 'amount_max' => -5000, 'category_name' => 'Other personal transfers', 'frequency' => 'quarterly'], + ['description' => 'Charity Donation', 'amount_min' => -5000, 'amount_max' => -2000, 'category_name' => 'Other personal transfers', 'frequency' => 'monthly'], ['description' => 'Interest Payment', 'amount_min' => 250, 'amount_max' => 850, 'category_name' => 'Other incoming payments', 'frequency' => 'monthly'], ['description' => 'Dividend - VTI ETF', 'amount_min' => 15000, 'amount_max' => 25000, 'category_name' => 'Other incoming payments', 'frequency' => 'quarterly'], + ['description' => 'Dividend - Apple Stock', 'amount_min' => 5000, 'amount_max' => 12000, 'category_name' => 'Other incoming payments', 'frequency' => 'quarterly'], + ['description' => 'Dividend - Microsoft Stock', 'amount_min' => 4500, 'amount_max' => 10000, 'category_name' => 'Other incoming payments', 'frequency' => 'quarterly'], + ['description' => 'Tax Refund', 'amount_min' => 50000, 'amount_max' => 200000, 'category_name' => 'Other incoming payments', 'frequency' => 'yearly'], ['description' => 'Transfer to Savings', 'amount_min' => -50000, 'amount_max' => -25000, 'category_name' => 'Own account', 'frequency' => 'monthly'], + ['description' => 'Transfer from Savings', 'amount_min' => 30000, 'amount_max' => 80000, 'category_name' => 'Own account', 'frequency' => 'quarterly'], ['description' => 'Birthday Gift from Mom', 'amount_min' => 10000, 'amount_max' => 25000, 'category_name' => 'From account of relatives', 'frequency' => 'yearly'], + ['description' => 'Christmas Gift from Parents', 'amount_min' => 15000, 'amount_max' => 30000, 'category_name' => 'From account of relatives', 'frequency' => 'yearly'], ['description' => 'Venmo from Friend', 'amount_min' => 2000, 'amount_max' => 8000, 'category_name' => 'Other personal transfers', 'frequency' => 'monthly'], + ['description' => 'PayPal Payment Received', 'amount_min' => 5000, 'amount_max' => 15000, 'category_name' => 'Other personal transfers', 'frequency' => 'monthly'], + ['description' => 'Zelle Transfer Received', 'amount_min' => 3000, 'amount_max' => 10000, 'category_name' => 'Other personal transfers', 'frequency' => 'monthly'], + ['description' => 'Cash App Payment', 'amount_min' => 1500, 'amount_max' => 6000, 'category_name' => 'Other personal transfers', 'frequency' => 'monthly'], + ['description' => 'Venmo Payment Sent', 'amount_min' => -5000, 'amount_max' => -2000, 'category_name' => 'Other personal transfers', 'frequency' => 'monthly'], + ['description' => 'PayPal Payment Sent', 'amount_min' => -8000, 'amount_max' => -3000, 'category_name' => 'Other personal transfers', 'frequency' => 'monthly'], + ['description' => 'Zelle Transfer Sent', 'amount_min' => -6000, 'amount_max' => -2500, 'category_name' => 'Other personal transfers', 'frequency' => 'monthly'], ]; /** diff --git a/bootstrap/app.php b/bootstrap/app.php index cd051183..be35e9f2 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,5 +1,6 @@ EnsureUserIsSubscribed::class, 'onboarded' => \App\Http\Middleware\EnsureOnboardingComplete::class, 'block-demo' => \App\Http\Middleware\BlockDemoAccountActions::class, + 'budgets' => EnsureBudgetsFeature::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/composer.json b/composer.json index 96ccb74b..a9513724 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,7 @@ }, "require-dev": { "fakerphp/faker": "^1.23", - "laravel/boost": "^1.7", + "laravel/boost": "1.8.7", "laravel/pail": "^1.2.2", "laravel/pint": "^1.24", "laravel/sail": "^1.41", diff --git a/composer.lock b/composer.lock index 59fa41a5..bafa3d4c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "90c2b736664d37c29e9aab679a810670", + "content-hash": "d9944c8b1651f576ba20b38f6e20959a", "packages": [ { "name": "bacon/bacon-qr-code", - "version": "v3.0.1", + "version": "v3.0.3", "source": { "type": "git", "url": "https://github.com/Bacon/BaconQrCode.git", - "reference": "f9cc1f52b5a463062251d666761178dbdb6b544f" + "reference": "36a1cb2b81493fa5b82e50bf8068bf84d1542563" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/f9cc1f52b5a463062251d666761178dbdb6b544f", - "reference": "f9cc1f52b5a463062251d666761178dbdb6b544f", + "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/36a1cb2b81493fa5b82e50bf8068bf84d1542563", + "reference": "36a1cb2b81493fa5b82e50bf8068bf84d1542563", "shasum": "" }, "require": { @@ -27,8 +27,9 @@ }, "require-dev": { "phly/keep-a-changelog": "^2.12", - "phpunit/phpunit": "^10.5.11 || 11.0.4", + "phpunit/phpunit": "^10.5.11 || ^11.0.4", "spatie/phpunit-snapshot-assertions": "^5.1.5", + "spatie/pixelmatch-php": "^1.2.0", "squizlabs/php_codesniffer": "^3.9" }, "suggest": { @@ -56,22 +57,22 @@ "homepage": "https://github.com/Bacon/BaconQrCode", "support": { "issues": "https://github.com/Bacon/BaconQrCode/issues", - "source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.1" + "source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.3" }, - "time": "2024-10-01T13:55:55+00:00" + "time": "2025-11-19T17:15:36+00:00" }, { "name": "brick/math", - "version": "0.14.0", + "version": "0.14.1", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "113a8ee2656b882d4c3164fa31aa6e12cbb7aaa2" + "reference": "f05858549e5f9d7bb45875a75583240a38a281d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/113a8ee2656b882d4c3164fa31aa6e12cbb7aaa2", - "reference": "113a8ee2656b882d4c3164fa31aa6e12cbb7aaa2", + "url": "https://api.github.com/repos/brick/math/zipball/f05858549e5f9d7bb45875a75583240a38a281d0", + "reference": "f05858549e5f9d7bb45875a75583240a38a281d0", "shasum": "" }, "require": { @@ -110,7 +111,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.14.0" + "source": "https://github.com/brick/math/tree/0.14.1" }, "funding": [ { @@ -118,7 +119,7 @@ "type": "github" } ], - "time": "2025-08-29T12:40:03+00:00" + "time": "2025-11-24T14:40:29+00:00" }, { "name": "carbonphp/carbon-doctrine-types", @@ -614,31 +615,31 @@ }, { "name": "fruitcake/php-cors", - "version": "v1.3.0", + "version": "v1.4.0", "source": { "type": "git", "url": "https://github.com/fruitcake/php-cors.git", - "reference": "3d158f36e7875e2f040f37bc0573956240a5a38b" + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/3d158f36e7875e2f040f37bc0573956240a5a38b", - "reference": "3d158f36e7875e2f040f37bc0573956240a5a38b", + "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", "shasum": "" }, "require": { - "php": "^7.4|^8.0", - "symfony/http-foundation": "^4.4|^5.4|^6|^7" + "php": "^8.1", + "symfony/http-foundation": "^5.4|^6.4|^7.3|^8" }, "require-dev": { - "phpstan/phpstan": "^1.4", + "phpstan/phpstan": "^2", "phpunit/phpunit": "^9", - "squizlabs/php_codesniffer": "^3.5" + "squizlabs/php_codesniffer": "^4" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.2-dev" + "dev-master": "1.3-dev" } }, "autoload": { @@ -669,7 +670,7 @@ ], "support": { "issues": "https://github.com/fruitcake/php-cors/issues", - "source": "https://github.com/fruitcake/php-cors/tree/v1.3.0" + "source": "https://github.com/fruitcake/php-cors/tree/v1.4.0" }, "funding": [ { @@ -681,28 +682,28 @@ "type": "github" } ], - "time": "2023-10-12T05:21:21+00:00" + "time": "2025-12-03T09:33:47+00:00" }, { "name": "graham-campbell/result-type", - "version": "v1.1.3", + "version": "v1.1.4", "source": { "type": "git", "url": "https://github.com/GrahamCampbell/Result-Type.git", - "reference": "3ba905c11371512af9d9bdd27d99b782216b6945" + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/3ba905c11371512af9d9bdd27d99b782216b6945", - "reference": "3ba905c11371512af9d9bdd27d99b782216b6945", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.3" + "phpoption/phpoption": "^1.9.5" }, "require-dev": { - "phpunit/phpunit": "^8.5.39 || ^9.6.20 || ^10.5.28" + "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" }, "type": "library", "autoload": { @@ -731,7 +732,7 @@ ], "support": { "issues": "https://github.com/GrahamCampbell/Result-Type/issues", - "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.3" + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" }, "funding": [ { @@ -743,7 +744,7 @@ "type": "tidelift" } ], - "time": "2024-07-20T21:45:45+00:00" + "time": "2025-12-27T19:43:20+00:00" }, { "name": "guzzlehttp/guzzle", @@ -1158,16 +1159,16 @@ }, { "name": "inertiajs/inertia-laravel", - "version": "v2.0.10", + "version": "v2.0.19", "source": { "type": "git", "url": "https://github.com/inertiajs/inertia-laravel.git", - "reference": "07da425d58a3a0e3ace9c296e67bd897a6e47009" + "reference": "732a991342a0f82653a935440e2f3b9be1eb6f6e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/inertiajs/inertia-laravel/zipball/07da425d58a3a0e3ace9c296e67bd897a6e47009", - "reference": "07da425d58a3a0e3ace9c296e67bd897a6e47009", + "url": "https://api.github.com/repos/inertiajs/inertia-laravel/zipball/732a991342a0f82653a935440e2f3b9be1eb6f6e", + "reference": "732a991342a0f82653a935440e2f3b9be1eb6f6e", "shasum": "" }, "require": { @@ -1222,9 +1223,9 @@ ], "support": { "issues": "https://github.com/inertiajs/inertia-laravel/issues", - "source": "https://github.com/inertiajs/inertia-laravel/tree/v2.0.10" + "source": "https://github.com/inertiajs/inertia-laravel/tree/v2.0.19" }, - "time": "2025-09-28T21:21:36+00:00" + "time": "2026-01-13T15:29:20+00:00" }, { "name": "jean85/pretty-package-versions", @@ -1288,16 +1289,16 @@ }, { "name": "laravel/cashier", - "version": "v16.1.0", + "version": "v16.2.0", "source": { "type": "git", "url": "https://github.com/laravel/cashier-stripe.git", - "reference": "6d526c79ea0b0e664299e5a122811034fe75ece9" + "reference": "9634b60c196ef1a512aa4f9543b6c2a1d64dff85" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/cashier-stripe/zipball/6d526c79ea0b0e664299e5a122811034fe75ece9", - "reference": "6d526c79ea0b0e664299e5a122811034fe75ece9", + "url": "https://api.github.com/repos/laravel/cashier-stripe/zipball/9634b60c196ef1a512aa4f9543b6c2a1d64dff85", + "reference": "9634b60c196ef1a512aa4f9543b6c2a1d64dff85", "shasum": "" }, "require": { @@ -1372,20 +1373,20 @@ "issues": "https://github.com/laravel/cashier/issues", "source": "https://github.com/laravel/cashier" }, - "time": "2025-12-03T00:26:48+00:00" + "time": "2026-01-06T16:30:29+00:00" }, { "name": "laravel/fortify", - "version": "v1.31.2", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/laravel/fortify.git", - "reference": "a046d52ee087ee52c9852b840cf4bbad19f10934" + "reference": "e0666dabeec0b6428678af1d51f436dcfb24e3a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/fortify/zipball/a046d52ee087ee52c9852b840cf4bbad19f10934", - "reference": "a046d52ee087ee52c9852b840cf4bbad19f10934", + "url": "https://api.github.com/repos/laravel/fortify/zipball/e0666dabeec0b6428678af1d51f436dcfb24e3a9", + "reference": "e0666dabeec0b6428678af1d51f436dcfb24e3a9", "shasum": "" }, "require": { @@ -1393,14 +1394,12 @@ "ext-json": "*", "illuminate/support": "^10.0|^11.0|^12.0", "php": "^8.1", - "pragmarx/google2fa": "^8.0", + "pragmarx/google2fa": "^9.0", "symfony/console": "^6.0|^7.0" }, "require-dev": { - "mockery/mockery": "^1.0", - "orchestra/testbench": "^8.16|^9.0|^10.0", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^10.4|^11.3" + "orchestra/testbench": "^8.36|^9.15|^10.8", + "phpstan/phpstan": "^1.10" }, "type": "library", "extra": { @@ -1437,20 +1436,20 @@ "issues": "https://github.com/laravel/fortify/issues", "source": "https://github.com/laravel/fortify" }, - "time": "2025-10-21T14:47:38+00:00" + "time": "2025-12-15T14:48:33+00:00" }, { "name": "laravel/framework", - "version": "v12.37.0", + "version": "v12.47.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "3c3c4ad30f5b528b164a7c09aa4ad03118c4c125" + "reference": "ab8114c2e78f32e64eb238fc4b495bea3f8b80ec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/3c3c4ad30f5b528b164a7c09aa4ad03118c4c125", - "reference": "3c3c4ad30f5b528b164a7c09aa4ad03118c4c125", + "url": "https://api.github.com/repos/laravel/framework/zipball/ab8114c2e78f32e64eb238fc4b495bea3f8b80ec", + "reference": "ab8114c2e78f32e64eb238fc4b495bea3f8b80ec", "shasum": "" }, "require": { @@ -1538,6 +1537,7 @@ "illuminate/process": "self.version", "illuminate/queue": "self.version", "illuminate/redis": "self.version", + "illuminate/reflection": "self.version", "illuminate/routing": "self.version", "illuminate/session": "self.version", "illuminate/support": "self.version", @@ -1562,13 +1562,13 @@ "league/flysystem-sftp-v3": "^3.25.1", "mockery/mockery": "^1.6.10", "opis/json-schema": "^2.4.1", - "orchestra/testbench-core": "^10.7.0", + "orchestra/testbench-core": "^10.8.1", "pda/pheanstalk": "^5.0.6|^7.0.0", "php-http/discovery": "^1.15", "phpstan/phpstan": "^2.0", "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1", "predis/predis": "^2.3|^3.0", - "resend/resend-php": "^0.10.0", + "resend/resend-php": "^0.10.0|^1.0", "symfony/cache": "^7.2.0", "symfony/http-client": "^7.2.0", "symfony/psr-http-message-bridge": "^7.2.0", @@ -1602,7 +1602,7 @@ "predis/predis": "Required to use the predis connector (^2.3|^3.0).", "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0).", - "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0).", + "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0|^1.0).", "symfony/cache": "Required to PSR-6 cache bridge (^7.2).", "symfony/filesystem": "Required to enable support for relative symbolic links (^7.2).", "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.2).", @@ -1624,6 +1624,7 @@ "src/Illuminate/Filesystem/functions.php", "src/Illuminate/Foundation/helpers.php", "src/Illuminate/Log/functions.php", + "src/Illuminate/Reflection/helpers.php", "src/Illuminate/Support/functions.php", "src/Illuminate/Support/helpers.php" ], @@ -1632,7 +1633,8 @@ "Illuminate\\Support\\": [ "src/Illuminate/Macroable/", "src/Illuminate/Collections/", - "src/Illuminate/Conditionable/" + "src/Illuminate/Conditionable/", + "src/Illuminate/Reflection/" ] } }, @@ -1656,7 +1658,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-11-04T15:39:33+00:00" + "time": "2026-01-13T15:29:06+00:00" }, { "name": "laravel/pennant", @@ -1736,16 +1738,16 @@ }, { "name": "laravel/prompts", - "version": "v0.3.7", + "version": "v0.3.10", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "a1891d362714bc40c8d23b0b1d7090f022ea27cc" + "reference": "360ba095ef9f51017473505191fbd4ab73e1cab3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/a1891d362714bc40c8d23b0b1d7090f022ea27cc", - "reference": "a1891d362714bc40c8d23b0b1d7090f022ea27cc", + "url": "https://api.github.com/repos/laravel/prompts/zipball/360ba095ef9f51017473505191fbd4ab73e1cab3", + "reference": "360ba095ef9f51017473505191fbd4ab73e1cab3", "shasum": "" }, "require": { @@ -1761,7 +1763,7 @@ "require-dev": { "illuminate/collections": "^10.0|^11.0|^12.0", "mockery/mockery": "^1.5", - "pestphp/pest": "^2.3|^3.4", + "pestphp/pest": "^2.3|^3.4|^4.0", "phpstan/phpstan": "^1.12.28", "phpstan/phpstan-mockery": "^1.1.3" }, @@ -1789,22 +1791,22 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.7" + "source": "https://github.com/laravel/prompts/tree/v0.3.10" }, - "time": "2025-09-19T13:47:56+00:00" + "time": "2026-01-13T20:29:29+00:00" }, { "name": "laravel/serializable-closure", - "version": "v2.0.6", + "version": "v2.0.8", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "038ce42edee619599a1debb7e81d7b3759492819" + "reference": "7581a4407012f5f53365e11bafc520fd7f36bc9b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/038ce42edee619599a1debb7e81d7b3759492819", - "reference": "038ce42edee619599a1debb7e81d7b3759492819", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/7581a4407012f5f53365e11bafc520fd7f36bc9b", + "reference": "7581a4407012f5f53365e11bafc520fd7f36bc9b", "shasum": "" }, "require": { @@ -1813,7 +1815,7 @@ "require-dev": { "illuminate/support": "^10.0|^11.0|^12.0", "nesbot/carbon": "^2.67|^3.0", - "pestphp/pest": "^2.36|^3.0", + "pestphp/pest": "^2.36|^3.0|^4.0", "phpstan/phpstan": "^2.0", "symfony/var-dumper": "^6.2.0|^7.0.0" }, @@ -1852,20 +1854,20 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2025-10-09T13:42:30+00:00" + "time": "2026-01-08T16:22:46+00:00" }, { "name": "laravel/tinker", - "version": "v2.10.1", + "version": "v2.11.0", "source": { "type": "git", "url": "https://github.com/laravel/tinker.git", - "reference": "22177cc71807d38f2810c6204d8f7183d88a57d3" + "reference": "3d34b97c9a1747a81a3fde90482c092bd8b66468" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/tinker/zipball/22177cc71807d38f2810c6204d8f7183d88a57d3", - "reference": "22177cc71807d38f2810c6204d8f7183d88a57d3", + "url": "https://api.github.com/repos/laravel/tinker/zipball/3d34b97c9a1747a81a3fde90482c092bd8b66468", + "reference": "3d34b97c9a1747a81a3fde90482c092bd8b66468", "shasum": "" }, "require": { @@ -1874,7 +1876,7 @@ "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", "php": "^7.2.5|^8.0", "psy/psysh": "^0.11.1|^0.12.0", - "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0" + "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0|^8.0" }, "require-dev": { "mockery/mockery": "~1.3.3|^1.4.2", @@ -1916,22 +1918,22 @@ ], "support": { "issues": "https://github.com/laravel/tinker/issues", - "source": "https://github.com/laravel/tinker/tree/v2.10.1" + "source": "https://github.com/laravel/tinker/tree/v2.11.0" }, - "time": "2025-01-27T14:24:01+00:00" + "time": "2025-12-19T19:16:45+00:00" }, { "name": "laravel/wayfinder", - "version": "v0.1.12", + "version": "v0.1.13", "source": { "type": "git", "url": "https://github.com/laravel/wayfinder.git", - "reference": "08285225df7f8a094789ad8f384ac6bad6712ff6" + "reference": "cc32fedd4744cdb64d018974ce92296e88fcdd0f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/wayfinder/zipball/08285225df7f8a094789ad8f384ac6bad6712ff6", - "reference": "08285225df7f8a094789ad8f384ac6bad6712ff6", + "url": "https://api.github.com/repos/laravel/wayfinder/zipball/cc32fedd4744cdb64d018974ce92296e88fcdd0f", + "reference": "cc32fedd4744cdb64d018974ce92296e88fcdd0f", "shasum": "" }, "require": { @@ -1981,20 +1983,20 @@ "issues": "https://github.com/laravel/wayfinder/issues", "source": "https://github.com/laravel/wayfinder" }, - "time": "2025-09-08T17:03:10+00:00" + "time": "2026-01-12T19:57:57+00:00" }, { "name": "league/commonmark", - "version": "2.7.1", + "version": "2.8.0", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "10732241927d3971d28e7ea7b5712721fa2296ca" + "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/10732241927d3971d28e7ea7b5712721fa2296ca", - "reference": "10732241927d3971d28e7ea7b5712721fa2296ca", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/4efa10c1e56488e658d10adf7b7b7dcd19940bfb", + "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb", "shasum": "" }, "require": { @@ -2031,7 +2033,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "2.8-dev" + "dev-main": "2.9-dev" } }, "autoload": { @@ -2088,7 +2090,7 @@ "type": "tidelift" } ], - "time": "2025-07-20T12:47:49+00:00" + "time": "2025-11-26T21:48:24+00:00" }, { "name": "league/config", @@ -2174,16 +2176,16 @@ }, { "name": "league/flysystem", - "version": "3.30.1", + "version": "3.30.2", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "c139fd65c1f796b926f4aec0df37f6caa959a8da" + "reference": "5966a8ba23e62bdb518dd9e0e665c2dbd4b5b277" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/c139fd65c1f796b926f4aec0df37f6caa959a8da", - "reference": "c139fd65c1f796b926f4aec0df37f6caa959a8da", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/5966a8ba23e62bdb518dd9e0e665c2dbd4b5b277", + "reference": "5966a8ba23e62bdb518dd9e0e665c2dbd4b5b277", "shasum": "" }, "require": { @@ -2251,22 +2253,22 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.30.1" + "source": "https://github.com/thephpleague/flysystem/tree/3.30.2" }, - "time": "2025-10-20T15:35:26+00:00" + "time": "2025-11-10T17:13:11+00:00" }, { "name": "league/flysystem-local", - "version": "3.30.0", + "version": "3.30.2", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-local.git", - "reference": "6691915f77c7fb69adfb87dcd550052dc184ee10" + "reference": "ab4f9d0d672f601b102936aa728801dd1a11968d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/6691915f77c7fb69adfb87dcd550052dc184ee10", - "reference": "6691915f77c7fb69adfb87dcd550052dc184ee10", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/ab4f9d0d672f601b102936aa728801dd1a11968d", + "reference": "ab4f9d0d672f601b102936aa728801dd1a11968d", "shasum": "" }, "require": { @@ -2300,9 +2302,9 @@ "local" ], "support": { - "source": "https://github.com/thephpleague/flysystem-local/tree/3.30.0" + "source": "https://github.com/thephpleague/flysystem-local/tree/3.30.2" }, - "time": "2025-05-21T10:34:19+00:00" + "time": "2025-11-10T11:23:37+00:00" }, { "name": "league/mime-type-detection", @@ -2362,33 +2364,38 @@ }, { "name": "league/uri", - "version": "7.5.1", + "version": "7.8.0", "source": { "type": "git", "url": "https://github.com/thephpleague/uri.git", - "reference": "81fb5145d2644324614cc532b28efd0215bda430" + "reference": "4436c6ec8d458e4244448b069cc572d088230b76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri/zipball/81fb5145d2644324614cc532b28efd0215bda430", - "reference": "81fb5145d2644324614cc532b28efd0215bda430", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/4436c6ec8d458e4244448b069cc572d088230b76", + "reference": "4436c6ec8d458e4244448b069cc572d088230b76", "shasum": "" }, "require": { - "league/uri-interfaces": "^7.5", - "php": "^8.1" + "league/uri-interfaces": "^7.8", + "php": "^8.1", + "psr/http-factory": "^1" }, "conflict": { "league/uri-schemes": "^1.0" }, "suggest": { "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", "ext-fileinfo": "to create Data URI from file contennts", "ext-gmp": "to improve IPV4 host parsing", "ext-intl": "to handle IDN host with the best performance", - "jeremykendall/php-domain-parser": "to resolve Public Suffix and Top Level Domain", - "league/uri-components": "Needed to easily manipulate URI objects components", + "ext-uri": "to use the PHP native URI class", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" }, "type": "library", @@ -2416,6 +2423,7 @@ "description": "URI manipulation library", "homepage": "https://uri.thephpleague.com", "keywords": [ + "URN", "data-uri", "file-uri", "ftp", @@ -2428,9 +2436,11 @@ "psr-7", "query-string", "querystring", + "rfc2141", "rfc3986", "rfc3987", "rfc6570", + "rfc8141", "uri", "uri-template", "url", @@ -2440,7 +2450,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri/tree/7.5.1" + "source": "https://github.com/thephpleague/uri/tree/7.8.0" }, "funding": [ { @@ -2448,26 +2458,25 @@ "type": "github" } ], - "time": "2024-12-08T08:40:02+00:00" + "time": "2026-01-14T17:24:56+00:00" }, { "name": "league/uri-interfaces", - "version": "7.5.0", + "version": "7.8.0", "source": { "type": "git", "url": "https://github.com/thephpleague/uri-interfaces.git", - "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742" + "reference": "c5c5cd056110fc8afaba29fa6b72a43ced42acd4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/08cfc6c4f3d811584fb09c37e2849e6a7f9b0742", - "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/c5c5cd056110fc8afaba29fa6b72a43ced42acd4", + "reference": "c5c5cd056110fc8afaba29fa6b72a43ced42acd4", "shasum": "" }, "require": { "ext-filter": "*", "php": "^8.1", - "psr/http-factory": "^1", "psr/http-message": "^1.1 || ^2.0" }, "suggest": { @@ -2475,6 +2484,7 @@ "ext-gmp": "to improve IPV4 host parsing", "ext-intl": "to handle IDN host with the best performance", "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" }, "type": "library", @@ -2499,7 +2509,7 @@ "homepage": "https://nyamsprod.com" } ], - "description": "Common interfaces and classes for URI representation and interaction", + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", "homepage": "https://uri.thephpleague.com", "keywords": [ "data-uri", @@ -2524,7 +2534,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri-interfaces/tree/7.5.0" + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.0" }, "funding": [ { @@ -2532,7 +2542,7 @@ "type": "github" } ], - "time": "2024-12-08T08:18:47+00:00" + "time": "2026-01-15T06:54:53+00:00" }, { "name": "moneyphp/money", @@ -2626,16 +2636,16 @@ }, { "name": "monolog/monolog", - "version": "3.9.0", + "version": "3.10.0", "source": { "type": "git", "url": "https://github.com/Seldaek/monolog.git", - "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6" + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/10d85740180ecba7896c87e06a166e0c95a0e3b6", - "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", "shasum": "" }, "require": { @@ -2653,7 +2663,7 @@ "graylog2/gelf-php": "^1.4.2 || ^2.0", "guzzlehttp/guzzle": "^7.4.5", "guzzlehttp/psr7": "^2.2", - "mongodb/mongodb": "^1.8", + "mongodb/mongodb": "^1.8 || ^2.0", "php-amqplib/php-amqplib": "~2.4 || ^3", "php-console/php-console": "^3.1.8", "phpstan/phpstan": "^2", @@ -2713,7 +2723,7 @@ ], "support": { "issues": "https://github.com/Seldaek/monolog/issues", - "source": "https://github.com/Seldaek/monolog/tree/3.9.0" + "source": "https://github.com/Seldaek/monolog/tree/3.10.0" }, "funding": [ { @@ -2725,20 +2735,20 @@ "type": "tidelift" } ], - "time": "2025-03-24T10:02:05+00:00" + "time": "2026-01-02T08:56:05+00:00" }, { "name": "nesbot/carbon", - "version": "3.10.3", + "version": "3.11.0", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "8e3643dcd149ae0fe1d2ff4f2c8e4bbfad7c165f" + "reference": "bdb375400dcd162624531666db4799b36b64e4a1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/8e3643dcd149ae0fe1d2ff4f2c8e4bbfad7c165f", - "reference": "8e3643dcd149ae0fe1d2ff4f2c8e4bbfad7c165f", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/bdb375400dcd162624531666db4799b36b64e4a1", + "reference": "bdb375400dcd162624531666db4799b36b64e4a1", "shasum": "" }, "require": { @@ -2746,9 +2756,9 @@ "ext-json": "*", "php": "^8.1", "psr/clock": "^1.0", - "symfony/clock": "^6.3.12 || ^7.0", + "symfony/clock": "^6.3.12 || ^7.0 || ^8.0", "symfony/polyfill-mbstring": "^1.0", - "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0" + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0 || ^8.0" }, "provide": { "psr/clock-implementation": "1.0" @@ -2830,7 +2840,7 @@ "type": "tidelift" } ], - "time": "2025-09-06T13:39:36+00:00" + "time": "2025-12-02T21:04:28+00:00" }, { "name": "nette/schema", @@ -2899,20 +2909,20 @@ }, { "name": "nette/utils", - "version": "v4.0.8", + "version": "v4.1.1", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "c930ca4e3cf4f17dcfb03037703679d2396d2ede" + "reference": "c99059c0315591f1a0db7ad6002000288ab8dc72" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/c930ca4e3cf4f17dcfb03037703679d2396d2ede", - "reference": "c930ca4e3cf4f17dcfb03037703679d2396d2ede", + "url": "https://api.github.com/repos/nette/utils/zipball/c99059c0315591f1a0db7ad6002000288ab8dc72", + "reference": "c99059c0315591f1a0db7ad6002000288ab8dc72", "shasum": "" }, "require": { - "php": "8.0 - 8.5" + "php": "8.2 - 8.5" }, "conflict": { "nette/finder": "<3", @@ -2935,7 +2945,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-master": "4.1-dev" } }, "autoload": { @@ -2982,22 +2992,22 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.0.8" + "source": "https://github.com/nette/utils/tree/v4.1.1" }, - "time": "2025-08-06T21:43:34+00:00" + "time": "2025-12-22T12:14:32+00:00" }, { "name": "nikic/php-parser", - "version": "v5.6.2", + "version": "v5.7.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "3a454ca033b9e06b63282ce19562e892747449bb" + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/3a454ca033b9e06b63282ce19562e892747449bb", - "reference": "3a454ca033b9e06b63282ce19562e892747449bb", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", "shasum": "" }, "require": { @@ -3040,37 +3050,37 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.2" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" }, - "time": "2025-10-21T19:32:17+00:00" + "time": "2025-12-06T11:56:16+00:00" }, { "name": "nunomaduro/termwind", - "version": "v2.3.2", + "version": "v2.3.3", "source": { "type": "git", "url": "https://github.com/nunomaduro/termwind.git", - "reference": "eb61920a53057a7debd718a5b89c2178032b52c0" + "reference": "6fb2a640ff502caace8e05fd7be3b503a7e1c017" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/eb61920a53057a7debd718a5b89c2178032b52c0", - "reference": "eb61920a53057a7debd718a5b89c2178032b52c0", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/6fb2a640ff502caace8e05fd7be3b503a7e1c017", + "reference": "6fb2a640ff502caace8e05fd7be3b503a7e1c017", "shasum": "" }, "require": { "ext-mbstring": "*", "php": "^8.2", - "symfony/console": "^7.3.4" + "symfony/console": "^7.3.6" }, "require-dev": { "illuminate/console": "^11.46.1", "laravel/pint": "^1.25.1", "mockery/mockery": "^1.6.12", - "pestphp/pest": "^2.36.0 || ^3.8.4", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.1.3", "phpstan/phpstan": "^1.12.32", "phpstan/phpstan-strict-rules": "^1.6.2", - "symfony/var-dumper": "^7.3.4", + "symfony/var-dumper": "^7.3.5", "thecodingmachine/phpstan-strict-rules": "^1.0.0" }, "type": "library", @@ -3113,7 +3123,7 @@ ], "support": { "issues": "https://github.com/nunomaduro/termwind/issues", - "source": "https://github.com/nunomaduro/termwind/tree/v2.3.2" + "source": "https://github.com/nunomaduro/termwind/tree/v2.3.3" }, "funding": [ { @@ -3129,7 +3139,7 @@ "type": "github" } ], - "time": "2025-10-18T11:10:27+00:00" + "time": "2025-11-20T02:34:59+00:00" }, { "name": "nyholm/psr7", @@ -3280,16 +3290,16 @@ }, { "name": "phpoption/phpoption", - "version": "1.9.4", + "version": "1.9.5", "source": { "type": "git", "url": "https://github.com/schmittjoh/php-option.git", - "reference": "638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d" + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d", - "reference": "638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", "shasum": "" }, "require": { @@ -3339,7 +3349,7 @@ ], "support": { "issues": "https://github.com/schmittjoh/php-option/issues", - "source": "https://github.com/schmittjoh/php-option/tree/1.9.4" + "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" }, "funding": [ { @@ -3351,20 +3361,20 @@ "type": "tidelift" } ], - "time": "2025-08-21T11:53:16+00:00" + "time": "2025-12-27T19:41:33+00:00" }, { "name": "phpstan/phpdoc-parser", - "version": "2.3.0", + "version": "2.3.1", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495" + "reference": "16dbf9937da8d4528ceb2145c9c7c0bd29e26374" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/1e0cd5370df5dd2e556a36b9c62f62e555870495", - "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/16dbf9937da8d4528ceb2145c9c7c0bd29e26374", + "reference": "16dbf9937da8d4528ceb2145c9c7c0bd29e26374", "shasum": "" }, "require": { @@ -3396,22 +3406,22 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.0" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.1" }, - "time": "2025-08-30T15:50:23+00:00" + "time": "2026-01-12T11:33:04+00:00" }, { "name": "pragmarx/google2fa", - "version": "v8.0.3", + "version": "v9.0.0", "source": { "type": "git", "url": "https://github.com/antonioribeiro/google2fa.git", - "reference": "6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad" + "reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad", - "reference": "6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad", + "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/e6bc62dd6ae83acc475f57912e27466019a1f2cf", + "reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf", "shasum": "" }, "require": { @@ -3448,9 +3458,9 @@ ], "support": { "issues": "https://github.com/antonioribeiro/google2fa/issues", - "source": "https://github.com/antonioribeiro/google2fa/tree/v8.0.3" + "source": "https://github.com/antonioribeiro/google2fa/tree/v9.0.0" }, - "time": "2024-09-05T11:56:40+00:00" + "time": "2025-09-19T22:51:08+00:00" }, { "name": "psr/clock", @@ -3866,16 +3876,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.14", + "version": "v0.12.18", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "95c29b3756a23855a30566b745d218bee690bef2" + "reference": "ddff0ac01beddc251786fe70367cd8bbdb258196" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/95c29b3756a23855a30566b745d218bee690bef2", - "reference": "95c29b3756a23855a30566b745d218bee690bef2", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ddff0ac01beddc251786fe70367cd8bbdb258196", + "reference": "ddff0ac01beddc251786fe70367cd8bbdb258196", "shasum": "" }, "require": { @@ -3883,8 +3893,8 @@ "ext-tokenizer": "*", "nikic/php-parser": "^5.0 || ^4.0", "php": "^8.0 || ^7.4", - "symfony/console": "^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", - "symfony/var-dumper": "^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" + "symfony/console": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", + "symfony/var-dumper": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" }, "conflict": { "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" @@ -3939,9 +3949,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.14" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.18" }, - "time": "2025-10-27T17:15:31+00:00" + "time": "2025-12-17T14:35:46+00:00" }, { "name": "ralouphie/getallheaders", @@ -4065,20 +4075,20 @@ }, { "name": "ramsey/uuid", - "version": "4.9.1", + "version": "4.9.2", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440" + "reference": "8429c78ca35a09f27565311b98101e2826affde0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/81f941f6f729b1e3ceea61d9d014f8b6c6800440", - "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0", + "reference": "8429c78ca35a09f27565311b98101e2826affde0", "shasum": "" }, "require": { - "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -4137,9 +4147,9 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.1" + "source": "https://github.com/ramsey/uuid/tree/4.9.2" }, - "time": "2025-09-04T20:59:21+00:00" + "time": "2025-12-14T04:43:48+00:00" }, { "name": "resend/resend-php", @@ -4288,16 +4298,16 @@ }, { "name": "sentry/sentry-laravel", - "version": "4.20.0", + "version": "4.20.1", "source": { "type": "git", "url": "https://github.com/getsentry/sentry-laravel.git", - "reference": "95f2542ee1ebc993529b63f5c8543184abd00650" + "reference": "503853fa7ee74b34b64e76f1373db86cd11afe72" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/getsentry/sentry-laravel/zipball/95f2542ee1ebc993529b63f5c8543184abd00650", - "reference": "95f2542ee1ebc993529b63f5c8543184abd00650", + "url": "https://api.github.com/repos/getsentry/sentry-laravel/zipball/503853fa7ee74b34b64e76f1373db86cd11afe72", + "reference": "503853fa7ee74b34b64e76f1373db86cd11afe72", "shasum": "" }, "require": { @@ -4305,7 +4315,7 @@ "nyholm/psr7": "^1.0", "php": "^7.2 | ^8.0", "sentry/sentry": "^4.19.0", - "symfony/psr-http-message-bridge": "^1.0 | ^2.0 | ^6.0 | ^7.0" + "symfony/psr-http-message-bridge": "^1.0 | ^2.0 | ^6.0 | ^7.0 | ^8.0" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.11", @@ -4362,7 +4372,7 @@ ], "support": { "issues": "https://github.com/getsentry/sentry-laravel/issues", - "source": "https://github.com/getsentry/sentry-laravel/tree/4.20.0" + "source": "https://github.com/getsentry/sentry-laravel/tree/4.20.1" }, "funding": [ { @@ -4374,7 +4384,7 @@ "type": "custom" } ], - "time": "2025-12-02T10:37:40+00:00" + "time": "2026-01-07T08:53:19+00:00" }, { "name": "stripe/stripe-php", @@ -4437,22 +4447,21 @@ }, { "name": "symfony/clock", - "version": "v7.3.0", + "version": "v8.0.0", "source": { "type": "git", "url": "https://github.com/symfony/clock.git", - "reference": "b81435fbd6648ea425d1ee96a2d8e68f4ceacd24" + "reference": "832119f9b8dbc6c8e6f65f30c5969eca1e88764f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/clock/zipball/b81435fbd6648ea425d1ee96a2d8e68f4ceacd24", - "reference": "b81435fbd6648ea425d1ee96a2d8e68f4ceacd24", + "url": "https://api.github.com/repos/symfony/clock/zipball/832119f9b8dbc6c8e6f65f30c5969eca1e88764f", + "reference": "832119f9b8dbc6c8e6f65f30c5969eca1e88764f", "shasum": "" }, "require": { - "php": ">=8.2", - "psr/clock": "^1.0", - "symfony/polyfill-php83": "^1.28" + "php": ">=8.4", + "psr/clock": "^1.0" }, "provide": { "psr/clock-implementation": "1.0" @@ -4491,7 +4500,7 @@ "time" ], "support": { - "source": "https://github.com/symfony/clock/tree/v7.3.0" + "source": "https://github.com/symfony/clock/tree/v8.0.0" }, "funding": [ { @@ -4502,25 +4511,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2025-11-12T15:46:48+00:00" }, { "name": "symfony/console", - "version": "v7.3.6", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "c28ad91448f86c5f6d9d2c70f0cf68bf135f252a" + "reference": "732a9ca6cd9dfd940c639062d5edbde2f6727fb6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/c28ad91448f86c5f6d9d2c70f0cf68bf135f252a", - "reference": "c28ad91448f86c5f6d9d2c70f0cf68bf135f252a", + "url": "https://api.github.com/repos/symfony/console/zipball/732a9ca6cd9dfd940c639062d5edbde2f6727fb6", + "reference": "732a9ca6cd9dfd940c639062d5edbde2f6727fb6", "shasum": "" }, "require": { @@ -4528,7 +4541,7 @@ "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^7.2" + "symfony/string": "^7.2|^8.0" }, "conflict": { "symfony/dependency-injection": "<6.4", @@ -4542,16 +4555,16 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/event-dispatcher": "^6.4|^7.0", - "symfony/http-foundation": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/lock": "^6.4|^7.0", - "symfony/messenger": "^6.4|^7.0", - "symfony/process": "^6.4|^7.0", - "symfony/stopwatch": "^6.4|^7.0", - "symfony/var-dumper": "^6.4|^7.0" + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -4585,7 +4598,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.3.6" + "source": "https://github.com/symfony/console/tree/v7.4.3" }, "funding": [ { @@ -4605,24 +4618,24 @@ "type": "tidelift" } ], - "time": "2025-11-04T01:21:42+00:00" + "time": "2025-12-23T14:50:43+00:00" }, { "name": "symfony/css-selector", - "version": "v7.3.6", + "version": "v8.0.0", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "84321188c4754e64273b46b406081ad9b18e8614" + "reference": "6225bd458c53ecdee056214cb4a2ffaf58bd592b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/84321188c4754e64273b46b406081ad9b18e8614", - "reference": "84321188c4754e64273b46b406081ad9b18e8614", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/6225bd458c53ecdee056214cb4a2ffaf58bd592b", + "reference": "6225bd458c53ecdee056214cb4a2ffaf58bd592b", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.4" }, "type": "library", "autoload": { @@ -4654,7 +4667,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v7.3.6" + "source": "https://github.com/symfony/css-selector/tree/v8.0.0" }, "funding": [ { @@ -4674,7 +4687,7 @@ "type": "tidelift" } ], - "time": "2025-10-29T17:24:25+00:00" + "time": "2025-10-30T14:17:19+00:00" }, { "name": "symfony/deprecation-contracts", @@ -4745,32 +4758,33 @@ }, { "name": "symfony/error-handler", - "version": "v7.3.6", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "bbe40bfab84323d99dab491b716ff142410a92a8" + "reference": "48be2b0653594eea32dcef130cca1c811dcf25c2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/bbe40bfab84323d99dab491b716ff142410a92a8", - "reference": "bbe40bfab84323d99dab491b716ff142410a92a8", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/48be2b0653594eea32dcef130cca1c811dcf25c2", + "reference": "48be2b0653594eea32dcef130cca1c811dcf25c2", "shasum": "" }, "require": { "php": ">=8.2", "psr/log": "^1|^2|^3", - "symfony/var-dumper": "^6.4|^7.0" + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "conflict": { "symfony/deprecation-contracts": "<2.5", "symfony/http-kernel": "<6.4" }, "require-dev": { - "symfony/console": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0|^8.0", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/serializer": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", "symfony/webpack-encore-bundle": "^1.0|^2.0" }, "bin": [ @@ -4802,7 +4816,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.3.6" + "source": "https://github.com/symfony/error-handler/tree/v7.4.0" }, "funding": [ { @@ -4822,28 +4836,28 @@ "type": "tidelift" } ], - "time": "2025-10-31T19:12:50+00:00" + "time": "2025-11-05T14:29:59+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v7.3.3", + "version": "v8.0.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "b7dc69e71de420ac04bc9ab830cf3ffebba48191" + "reference": "573f95783a2ec6e38752979db139f09fec033f03" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/b7dc69e71de420ac04bc9ab830cf3ffebba48191", - "reference": "b7dc69e71de420ac04bc9ab830cf3ffebba48191", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/573f95783a2ec6e38752979db139f09fec033f03", + "reference": "573f95783a2ec6e38752979db139f09fec033f03", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4", "symfony/event-dispatcher-contracts": "^2.5|^3" }, "conflict": { - "symfony/dependency-injection": "<6.4", + "symfony/security-http": "<7.4", "symfony/service-contracts": "<2.5" }, "provide": { @@ -4852,13 +4866,14 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/error-handler": "^6.4|^7.0", - "symfony/expression-language": "^6.4|^7.0", - "symfony/http-foundation": "^6.4|^7.0", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/stopwatch": "^6.4|^7.0" + "symfony/stopwatch": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -4886,7 +4901,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v7.3.3" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.0" }, "funding": [ { @@ -4906,7 +4921,7 @@ "type": "tidelift" } ], - "time": "2025-08-13T11:49:31+00:00" + "time": "2025-10-30T14:17:19+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -4986,23 +5001,23 @@ }, { "name": "symfony/finder", - "version": "v7.3.5", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "9f696d2f1e340484b4683f7853b273abff94421f" + "reference": "fffe05569336549b20a1be64250b40516d6e8d06" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/9f696d2f1e340484b4683f7853b273abff94421f", - "reference": "9f696d2f1e340484b4683f7853b273abff94421f", + "url": "https://api.github.com/repos/symfony/finder/zipball/fffe05569336549b20a1be64250b40516d6e8d06", + "reference": "fffe05569336549b20a1be64250b40516d6e8d06", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { - "symfony/filesystem": "^6.4|^7.0" + "symfony/filesystem": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -5030,7 +5045,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.3.5" + "source": "https://github.com/symfony/finder/tree/v7.4.3" }, "funding": [ { @@ -5050,27 +5065,26 @@ "type": "tidelift" } ], - "time": "2025-10-15T18:45:57+00:00" + "time": "2025-12-23T14:50:43+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.3.6", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "6379e490d6ecfc5c4224ff3a754b90495ecd135c" + "reference": "a70c745d4cea48dbd609f4075e5f5cbce453bd52" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/6379e490d6ecfc5c4224ff3a754b90495ecd135c", - "reference": "6379e490d6ecfc5c4224ff3a754b90495ecd135c", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/a70c745d4cea48dbd609f4075e5f5cbce453bd52", + "reference": "a70c745d4cea48dbd609f4075e5f5cbce453bd52", "shasum": "" }, "require": { "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3.0", - "symfony/polyfill-mbstring": "~1.1", - "symfony/polyfill-php83": "^1.27" + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" }, "conflict": { "doctrine/dbal": "<3.6", @@ -5079,13 +5093,13 @@ "require-dev": { "doctrine/dbal": "^3.6|^4", "predis/predis": "^1.1|^2.0", - "symfony/cache": "^6.4.12|^7.1.5", - "symfony/clock": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/expression-language": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/mime": "^6.4|^7.0", - "symfony/rate-limiter": "^6.4|^7.0" + "symfony/cache": "^6.4.12|^7.1.5|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -5113,7 +5127,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.3.6" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.3" }, "funding": [ { @@ -5133,29 +5147,29 @@ "type": "tidelift" } ], - "time": "2025-11-06T11:05:57+00:00" + "time": "2025-12-23T14:23:49+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.3.6", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "f9a34dc0196677250e3609c2fac9de9e1551a262" + "reference": "885211d4bed3f857b8c964011923528a55702aa5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/f9a34dc0196677250e3609c2fac9de9e1551a262", - "reference": "f9a34dc0196677250e3609c2fac9de9e1551a262", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/885211d4bed3f857b8c964011923528a55702aa5", + "reference": "885211d4bed3f857b8c964011923528a55702aa5", "shasum": "" }, "require": { "php": ">=8.2", "psr/log": "^1|^2|^3", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/error-handler": "^6.4|^7.0", - "symfony/event-dispatcher": "^7.3", - "symfony/http-foundation": "^7.3", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/http-foundation": "^7.4|^8.0", "symfony/polyfill-ctype": "^1.8" }, "conflict": { @@ -5165,6 +5179,7 @@ "symfony/console": "<6.4", "symfony/dependency-injection": "<6.4", "symfony/doctrine-bridge": "<6.4", + "symfony/flex": "<2.10", "symfony/form": "<6.4", "symfony/http-client": "<6.4", "symfony/http-client-contracts": "<2.5", @@ -5182,27 +5197,27 @@ }, "require-dev": { "psr/cache": "^1.0|^2.0|^3.0", - "symfony/browser-kit": "^6.4|^7.0", - "symfony/clock": "^6.4|^7.0", - "symfony/config": "^6.4|^7.0", - "symfony/console": "^6.4|^7.0", - "symfony/css-selector": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/dom-crawler": "^6.4|^7.0", - "symfony/expression-language": "^6.4|^7.0", - "symfony/finder": "^6.4|^7.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", "symfony/http-client-contracts": "^2.5|^3", - "symfony/process": "^6.4|^7.0", - "symfony/property-access": "^7.1", - "symfony/routing": "^6.4|^7.0", - "symfony/serializer": "^7.1", - "symfony/stopwatch": "^6.4|^7.0", - "symfony/translation": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^7.1|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.1|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", "symfony/translation-contracts": "^2.5|^3", - "symfony/uid": "^6.4|^7.0", - "symfony/validator": "^6.4|^7.0", - "symfony/var-dumper": "^6.4|^7.0", - "symfony/var-exporter": "^6.4|^7.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0", "twig/twig": "^3.12" }, "type": "library", @@ -5231,7 +5246,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.3.6" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.3" }, "funding": [ { @@ -5251,20 +5266,20 @@ "type": "tidelift" } ], - "time": "2025-11-06T20:58:12+00:00" + "time": "2025-12-31T08:43:57+00:00" }, { "name": "symfony/mailer", - "version": "v7.3.5", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "fd497c45ba9c10c37864e19466b090dcb60a50ba" + "reference": "e472d35e230108231ccb7f51eb6b2100cac02ee4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/fd497c45ba9c10c37864e19466b090dcb60a50ba", - "reference": "fd497c45ba9c10c37864e19466b090dcb60a50ba", + "url": "https://api.github.com/repos/symfony/mailer/zipball/e472d35e230108231ccb7f51eb6b2100cac02ee4", + "reference": "e472d35e230108231ccb7f51eb6b2100cac02ee4", "shasum": "" }, "require": { @@ -5272,8 +5287,8 @@ "php": ">=8.2", "psr/event-dispatcher": "^1", "psr/log": "^1|^2|^3", - "symfony/event-dispatcher": "^6.4|^7.0", - "symfony/mime": "^7.2", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/mime": "^7.2|^8.0", "symfony/service-contracts": "^2.5|^3" }, "conflict": { @@ -5284,10 +5299,10 @@ "symfony/twig-bridge": "<6.4" }, "require-dev": { - "symfony/console": "^6.4|^7.0", - "symfony/http-client": "^6.4|^7.0", - "symfony/messenger": "^6.4|^7.0", - "symfony/twig-bridge": "^6.4|^7.0" + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/twig-bridge": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -5315,7 +5330,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.3.5" + "source": "https://github.com/symfony/mailer/tree/v7.4.3" }, "funding": [ { @@ -5335,24 +5350,25 @@ "type": "tidelift" } ], - "time": "2025-10-24T14:27:20+00:00" + "time": "2025-12-16T08:02:06+00:00" }, { "name": "symfony/mime", - "version": "v7.3.4", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "b1b828f69cbaf887fa835a091869e55df91d0e35" + "reference": "bdb02729471be5d047a3ac4a69068748f1a6be7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/b1b828f69cbaf887fa835a091869e55df91d0e35", - "reference": "b1b828f69cbaf887fa835a091869e55df91d0e35", + "url": "https://api.github.com/repos/symfony/mime/zipball/bdb02729471be5d047a3ac4a69068748f1a6be7a", + "reference": "bdb02729471be5d047a3ac4a69068748f1a6be7a", "shasum": "" }, "require": { "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-intl-idn": "^1.10", "symfony/polyfill-mbstring": "^1.0" }, @@ -5367,11 +5383,11 @@ "egulias/email-validator": "^2.1.10|^3.1|^4", "league/html-to-markdown": "^5.0", "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/process": "^6.4|^7.0", - "symfony/property-access": "^6.4|^7.0", - "symfony/property-info": "^6.4|^7.0", - "symfony/serializer": "^6.4.3|^7.0.3" + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.3|^7.0.3|^8.0" }, "type": "library", "autoload": { @@ -5403,7 +5419,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.3.4" + "source": "https://github.com/symfony/mime/tree/v7.4.0" }, "funding": [ { @@ -5423,7 +5439,7 @@ "type": "tidelift" } ], - "time": "2025-09-16T08:38:17+00:00" + "time": "2025-11-16T10:14:42+00:00" }, { "name": "symfony/options-resolver", @@ -6415,16 +6431,16 @@ }, { "name": "symfony/process", - "version": "v7.3.4", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "f24f8f316367b30810810d4eb30c543d7003ff3b" + "reference": "2f8e1a6cdf590ca63715da4d3a7a3327404a523f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/f24f8f316367b30810810d4eb30c543d7003ff3b", - "reference": "f24f8f316367b30810810d4eb30c543d7003ff3b", + "url": "https://api.github.com/repos/symfony/process/zipball/2f8e1a6cdf590ca63715da4d3a7a3327404a523f", + "reference": "2f8e1a6cdf590ca63715da4d3a7a3327404a523f", "shasum": "" }, "require": { @@ -6456,7 +6472,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.3.4" + "source": "https://github.com/symfony/process/tree/v7.4.3" }, "funding": [ { @@ -6476,41 +6492,40 @@ "type": "tidelift" } ], - "time": "2025-09-11T10:12:26+00:00" + "time": "2025-12-19T10:00:43+00:00" }, { "name": "symfony/psr-http-message-bridge", - "version": "v7.4.0", + "version": "v8.0.0", "source": { "type": "git", "url": "https://github.com/symfony/psr-http-message-bridge.git", - "reference": "0101ff8bd0506703b045b1670960302d302a726c" + "reference": "27fa49adadbf5f757f558c920bb9f3994b133fe5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/0101ff8bd0506703b045b1670960302d302a726c", - "reference": "0101ff8bd0506703b045b1670960302d302a726c", + "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/27fa49adadbf5f757f558c920bb9f3994b133fe5", + "reference": "27fa49adadbf5f757f558c920bb9f3994b133fe5", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4", "psr/http-message": "^1.0|^2.0", - "symfony/http-foundation": "^6.4|^7.0|^8.0" + "symfony/http-foundation": "^7.4|^8.0" }, "conflict": { - "php-http/discovery": "<1.15", - "symfony/http-kernel": "<6.4" + "php-http/discovery": "<1.15" }, "require-dev": { "nyholm/psr7": "^1.1", "php-http/discovery": "^1.15", "psr/log": "^1.1.4|^2|^3", - "symfony/browser-kit": "^6.4|^7.0|^8.0", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/event-dispatcher": "^6.4|^7.0|^8.0", - "symfony/framework-bundle": "^6.4.13|^7.1.6|^8.0", - "symfony/http-kernel": "^6.4.13|^7.1.6|^8.0", - "symfony/runtime": "^6.4.13|^7.1.6|^8.0" + "symfony/browser-kit": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/runtime": "^7.4|^8.0" }, "type": "symfony-bridge", "autoload": { @@ -6544,7 +6559,7 @@ "psr-7" ], "support": { - "source": "https://github.com/symfony/psr-http-message-bridge/tree/v7.4.0" + "source": "https://github.com/symfony/psr-http-message-bridge/tree/v8.0.0" }, "funding": [ { @@ -6564,20 +6579,20 @@ "type": "tidelift" } ], - "time": "2025-11-13T08:38:49+00:00" + "time": "2025-11-13T08:54:25+00:00" }, { "name": "symfony/routing", - "version": "v7.3.6", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "c97abe725f2a1a858deca629a6488c8fc20c3091" + "reference": "5d3fd7adf8896c2fdb54e2f0f35b1bcbd9e45090" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/c97abe725f2a1a858deca629a6488c8fc20c3091", - "reference": "c97abe725f2a1a858deca629a6488c8fc20c3091", + "url": "https://api.github.com/repos/symfony/routing/zipball/5d3fd7adf8896c2fdb54e2f0f35b1bcbd9e45090", + "reference": "5d3fd7adf8896c2fdb54e2f0f35b1bcbd9e45090", "shasum": "" }, "require": { @@ -6591,11 +6606,11 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/expression-language": "^6.4|^7.0", - "symfony/http-foundation": "^6.4|^7.0", - "symfony/yaml": "^6.4|^7.0" + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -6629,7 +6644,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.3.6" + "source": "https://github.com/symfony/routing/tree/v7.4.3" }, "funding": [ { @@ -6649,7 +6664,7 @@ "type": "tidelift" } ], - "time": "2025-11-05T07:57:47+00:00" + "time": "2025-12-19T10:00:43+00:00" }, { "name": "symfony/service-contracts", @@ -6740,34 +6755,34 @@ }, { "name": "symfony/string", - "version": "v7.3.4", + "version": "v8.0.1", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "f96476035142921000338bad71e5247fbc138872" + "reference": "ba65a969ac918ce0cc3edfac6cdde847eba231dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/f96476035142921000338bad71e5247fbc138872", - "reference": "f96476035142921000338bad71e5247fbc138872", + "url": "https://api.github.com/repos/symfony/string/zipball/ba65a969ac918ce0cc3edfac6cdde847eba231dc", + "reference": "ba65a969ac918ce0cc3edfac6cdde847eba231dc", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-intl-grapheme": "~1.0", - "symfony/polyfill-intl-normalizer": "~1.0", - "symfony/polyfill-mbstring": "~1.0" + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" }, "conflict": { "symfony/translation-contracts": "<2.5" }, "require-dev": { - "symfony/emoji": "^7.1", - "symfony/http-client": "^6.4|^7.0", - "symfony/intl": "^6.4|^7.0", + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^6.4|^7.0" + "symfony/var-exporter": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -6806,7 +6821,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.3.4" + "source": "https://github.com/symfony/string/tree/v8.0.1" }, "funding": [ { @@ -6826,38 +6841,31 @@ "type": "tidelift" } ], - "time": "2025-09-11T14:36:48+00:00" + "time": "2025-12-01T09:13:36+00:00" }, { "name": "symfony/translation", - "version": "v7.3.4", + "version": "v8.0.3", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "ec25870502d0c7072d086e8ffba1420c85965174" + "reference": "60a8f11f0e15c48f2cc47c4da53873bb5b62135d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/ec25870502d0c7072d086e8ffba1420c85965174", - "reference": "ec25870502d0c7072d086e8ffba1420c85965174", + "url": "https://api.github.com/repos/symfony/translation/zipball/60a8f11f0e15c48f2cc47c4da53873bb5b62135d", + "reference": "60a8f11f0e15c48f2cc47c4da53873bb5b62135d", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "~1.0", - "symfony/translation-contracts": "^2.5|^3.0" + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation-contracts": "^3.6.1" }, "conflict": { "nikic/php-parser": "<5.0", - "symfony/config": "<6.4", - "symfony/console": "<6.4", - "symfony/dependency-injection": "<6.4", "symfony/http-client-contracts": "<2.5", - "symfony/http-kernel": "<6.4", - "symfony/service-contracts": "<2.5", - "symfony/twig-bundle": "<6.4", - "symfony/yaml": "<6.4" + "symfony/service-contracts": "<2.5" }, "provide": { "symfony/translation-implementation": "2.3|3.0" @@ -6865,17 +6873,17 @@ "require-dev": { "nikic/php-parser": "^5.0", "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0", - "symfony/console": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/finder": "^6.4|^7.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", "symfony/http-client-contracts": "^2.5|^3.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/intl": "^6.4|^7.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", "symfony/polyfill-intl-icu": "^1.21", - "symfony/routing": "^6.4|^7.0", + "symfony/routing": "^7.4|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/yaml": "^6.4|^7.0" + "symfony/yaml": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -6906,7 +6914,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v7.3.4" + "source": "https://github.com/symfony/translation/tree/v8.0.3" }, "funding": [ { @@ -6926,7 +6934,7 @@ "type": "tidelift" } ], - "time": "2025-09-07T11:39:36+00:00" + "time": "2025-12-21T10:59:45+00:00" }, { "name": "symfony/translation-contracts", @@ -7012,16 +7020,16 @@ }, { "name": "symfony/uid", - "version": "v7.3.1", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "a69f69f3159b852651a6bf45a9fdd149520525bb" + "reference": "2498e9f81b7baa206f44de583f2f48350b90142c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/a69f69f3159b852651a6bf45a9fdd149520525bb", - "reference": "a69f69f3159b852651a6bf45a9fdd149520525bb", + "url": "https://api.github.com/repos/symfony/uid/zipball/2498e9f81b7baa206f44de583f2f48350b90142c", + "reference": "2498e9f81b7baa206f44de583f2f48350b90142c", "shasum": "" }, "require": { @@ -7029,7 +7037,7 @@ "symfony/polyfill-uuid": "^1.15" }, "require-dev": { - "symfony/console": "^6.4|^7.0" + "symfony/console": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -7066,7 +7074,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v7.3.1" + "source": "https://github.com/symfony/uid/tree/v7.4.0" }, "funding": [ { @@ -7077,25 +7085,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T19:55:54+00:00" + "time": "2025-09-25T11:02:55+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.3.5", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "476c4ae17f43a9a36650c69879dcf5b1e6ae724d" + "reference": "7e99bebcb3f90d8721890f2963463280848cba92" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/476c4ae17f43a9a36650c69879dcf5b1e6ae724d", - "reference": "476c4ae17f43a9a36650c69879dcf5b1e6ae724d", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/7e99bebcb3f90d8721890f2963463280848cba92", + "reference": "7e99bebcb3f90d8721890f2963463280848cba92", "shasum": "" }, "require": { @@ -7107,10 +7119,10 @@ "symfony/console": "<6.4" }, "require-dev": { - "symfony/console": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/process": "^6.4|^7.0", - "symfony/uid": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", "twig/twig": "^3.12" }, "bin": [ @@ -7149,7 +7161,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.3.5" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.3" }, "funding": [ { @@ -7169,27 +7181,27 @@ "type": "tidelift" } ], - "time": "2025-09-27T09:00:46+00:00" + "time": "2025-12-18T07:04:31+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", - "version": "v2.3.0", + "version": "v2.4.0", "source": { "type": "git", "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", - "reference": "0d72ac1c00084279c1816675284073c5a337c20d" + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/0d72ac1c00084279c1816675284073c5a337c20d", - "reference": "0d72ac1c00084279c1816675284073c5a337c20d", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/f0292ccf0ec75843d65027214426b6b163b48b41", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "php": "^7.4 || ^8.0", - "symfony/css-selector": "^5.4 || ^6.0 || ^7.0" + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0" }, "require-dev": { "phpstan/phpstan": "^2.0", @@ -7222,32 +7234,32 @@ "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", "support": { "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", - "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.3.0" + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0" }, - "time": "2024-12-21T16:25:41+00:00" + "time": "2025-12-02T11:56:42+00:00" }, { "name": "vlucas/phpdotenv", - "version": "v5.6.2", + "version": "v5.6.3", "source": { "type": "git", "url": "https://github.com/vlucas/phpdotenv.git", - "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af" + "reference": "955e7815d677a3eaa7075231212f2110983adecc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/24ac4c74f91ee2c193fa1aaa5c249cb0822809af", - "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc", + "reference": "955e7815d677a3eaa7075231212f2110983adecc", "shasum": "" }, "require": { "ext-pcre": "*", - "graham-campbell/result-type": "^1.1.3", + "graham-campbell/result-type": "^1.1.4", "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.3", - "symfony/polyfill-ctype": "^1.24", - "symfony/polyfill-mbstring": "^1.24", - "symfony/polyfill-php80": "^1.24" + "phpoption/phpoption": "^1.9.5", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", @@ -7296,7 +7308,7 @@ ], "support": { "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.2" + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" }, "funding": [ { @@ -7308,7 +7320,7 @@ "type": "tidelift" } ], - "time": "2025-04-30T23:37:27+00:00" + "time": "2025-12-27T19:49:13+00:00" }, { "name": "voku/portable-ascii", @@ -8610,16 +8622,16 @@ }, { "name": "brianium/paratest", - "version": "v7.14.2", + "version": "v7.16.1", "source": { "type": "git", "url": "https://github.com/paratestphp/paratest.git", - "reference": "de06de1ae1203b11976c6ca01d6a9081c8b33d45" + "reference": "f0fdfd8e654e0d38bc2ba756a6cabe7be287390b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/de06de1ae1203b11976c6ca01d6a9081c8b33d45", - "reference": "de06de1ae1203b11976c6ca01d6a9081c8b33d45", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/f0fdfd8e654e0d38bc2ba756a6cabe7be287390b", + "reference": "f0fdfd8e654e0d38bc2ba756a6cabe7be287390b", "shasum": "" }, "require": { @@ -8630,24 +8642,24 @@ "fidry/cpu-core-counter": "^1.3.0", "jean85/pretty-package-versions": "^2.1.1", "php": "~8.3.0 || ~8.4.0 || ~8.5.0", - "phpunit/php-code-coverage": "^12.4.0", + "phpunit/php-code-coverage": "^12.5.2", "phpunit/php-file-iterator": "^6", "phpunit/php-timer": "^8", - "phpunit/phpunit": "^12.4.1", + "phpunit/phpunit": "^12.5.4", "sebastian/environment": "^8.0.3", - "symfony/console": "^6.4.20 || ^7.3.4", - "symfony/process": "^6.4.20 || ^7.3.4" + "symfony/console": "^7.3.4 || ^8.0.0", + "symfony/process": "^7.3.4 || ^8.0.0" }, "require-dev": { "doctrine/coding-standard": "^14.0.0", "ext-pcntl": "*", "ext-pcov": "*", "ext-posix": "*", - "phpstan/phpstan": "^2.1.31", + "phpstan/phpstan": "^2.1.33", "phpstan/phpstan-deprecation-rules": "^2.0.3", - "phpstan/phpstan-phpunit": "^2.0.7", + "phpstan/phpstan-phpunit": "^2.0.11", "phpstan/phpstan-strict-rules": "^2.0.7", - "symfony/filesystem": "^6.4.13 || ^7.3.2" + "symfony/filesystem": "^7.3.2 || ^8.0.0" }, "bin": [ "bin/paratest", @@ -8687,7 +8699,7 @@ ], "support": { "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.14.2" + "source": "https://github.com/paratestphp/paratest/tree/v7.16.1" }, "funding": [ { @@ -8699,7 +8711,7 @@ "type": "paypal" } ], - "time": "2025-10-24T07:20:53+00:00" + "time": "2026-01-08T07:23:06+00:00" }, { "name": "daverandom/libdns", @@ -9099,34 +9111,34 @@ }, { "name": "laravel/boost", - "version": "v1.7.1", + "version": "v1.8.7", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "355f7c27952862aab3f61adec27773fd4d41a582" + "reference": "7a5709a8134ed59d3e7f34fccbd74689830e296c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/355f7c27952862aab3f61adec27773fd4d41a582", - "reference": "355f7c27952862aab3f61adec27773fd4d41a582", + "url": "https://api.github.com/repos/laravel/boost/zipball/7a5709a8134ed59d3e7f34fccbd74689830e296c", + "reference": "7a5709a8134ed59d3e7f34fccbd74689830e296c", "shasum": "" }, "require": { - "guzzlehttp/guzzle": "^7.10", - "illuminate/console": "^10.49.0|^11.45.3|^12.28.1", - "illuminate/contracts": "^10.49.0|^11.45.3|^12.28.1", - "illuminate/routing": "^10.49.0|^11.45.3|^12.28.1", - "illuminate/support": "^10.49.0|^11.45.3|^12.28.1", - "laravel/mcp": "^0.3.2", + "guzzlehttp/guzzle": "^7.9", + "illuminate/console": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/contracts": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/routing": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/support": "^10.49.0|^11.45.3|^12.41.1", + "laravel/mcp": "^0.5.1", "laravel/prompts": "0.1.25|^0.3.6", "laravel/roster": "^0.2.9", "php": "^8.1" }, "require-dev": { - "laravel/pint": "1.20", + "laravel/pint": "^1.20.0", "mockery/mockery": "^1.6.12", "orchestra/testbench": "^8.36.0|^9.15.0|^10.6", - "pestphp/pest": "^2.36.0|^3.8.4", + "pestphp/pest": "^2.36.0|^3.8.4|^4.1.5", "phpstan/phpstan": "^2.1.27", "rector/rector": "^2.1" }, @@ -9161,38 +9173,38 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2025-11-05T21:41:46+00:00" + "time": "2025-12-19T15:04:12+00:00" }, { "name": "laravel/mcp", - "version": "v0.3.2", + "version": "v0.5.2", "source": { "type": "git", "url": "https://github.com/laravel/mcp.git", - "reference": "dc722a4c388f172365dec70461f0413ac366f360" + "reference": "b9bdd8d6f8b547c8733fe6826b1819341597ba3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/dc722a4c388f172365dec70461f0413ac366f360", - "reference": "dc722a4c388f172365dec70461f0413ac366f360", + "url": "https://api.github.com/repos/laravel/mcp/zipball/b9bdd8d6f8b547c8733fe6826b1819341597ba3c", + "reference": "b9bdd8d6f8b547c8733fe6826b1819341597ba3c", "shasum": "" }, "require": { "ext-json": "*", "ext-mbstring": "*", - "illuminate/console": "^10.49.0|^11.45.3|^12.28.1", - "illuminate/container": "^10.49.0|^11.45.3|^12.28.1", - "illuminate/contracts": "^10.49.0|^11.45.3|^12.28.1", - "illuminate/http": "^10.49.0|^11.45.3|^12.28.1", - "illuminate/json-schema": "^12.28.1", - "illuminate/routing": "^10.49.0|^11.45.3|^12.28.1", - "illuminate/support": "^10.49.0|^11.45.3|^12.28.1", - "illuminate/validation": "^10.49.0|^11.45.3|^12.28.1", + "illuminate/console": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/container": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/contracts": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/http": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/json-schema": "^12.41.1", + "illuminate/routing": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/support": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/validation": "^10.49.0|^11.45.3|^12.41.1", "php": "^8.1" }, "require-dev": { - "laravel/pint": "1.20.0", - "orchestra/testbench": "^8.36.0|^9.15.0|^10.6.0", + "laravel/pint": "^1.20", + "orchestra/testbench": "^8.36|^9.15|^10.8", "pestphp/pest": "^2.36.0|^3.8.4|^4.1.0", "phpstan/phpstan": "^2.1.27", "rector/rector": "^2.2.4" @@ -9234,20 +9246,20 @@ "issues": "https://github.com/laravel/mcp/issues", "source": "https://github.com/laravel/mcp" }, - "time": "2025-10-29T14:26:01+00:00" + "time": "2025-12-19T19:32:34+00:00" }, { "name": "laravel/pail", - "version": "v1.2.3", + "version": "v1.2.4", "source": { "type": "git", "url": "https://github.com/laravel/pail.git", - "reference": "8cc3d575c1f0e57eeb923f366a37528c50d2385a" + "reference": "49f92285ff5d6fc09816e976a004f8dec6a0ea30" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pail/zipball/8cc3d575c1f0e57eeb923f366a37528c50d2385a", - "reference": "8cc3d575c1f0e57eeb923f366a37528c50d2385a", + "url": "https://api.github.com/repos/laravel/pail/zipball/49f92285ff5d6fc09816e976a004f8dec6a0ea30", + "reference": "49f92285ff5d6fc09816e976a004f8dec6a0ea30", "shasum": "" }, "require": { @@ -9264,9 +9276,9 @@ "require-dev": { "laravel/framework": "^10.24|^11.0|^12.0", "laravel/pint": "^1.13", - "orchestra/testbench-core": "^8.13|^9.0|^10.0", - "pestphp/pest": "^2.20|^3.0", - "pestphp/pest-plugin-type-coverage": "^2.3|^3.0", + "orchestra/testbench-core": "^8.13|^9.17|^10.8", + "pestphp/pest": "^2.20|^3.0|^4.0", + "pestphp/pest-plugin-type-coverage": "^2.3|^3.0|^4.0", "phpstan/phpstan": "^1.12.27", "symfony/var-dumper": "^6.3|^7.0" }, @@ -9313,20 +9325,20 @@ "issues": "https://github.com/laravel/pail/issues", "source": "https://github.com/laravel/pail" }, - "time": "2025-06-05T13:55:57+00:00" + "time": "2025-11-20T16:29:35+00:00" }, { "name": "laravel/pint", - "version": "v1.25.1", + "version": "v1.27.0", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "5016e263f95d97670d71b9a987bd8996ade6d8d9" + "reference": "c67b4195b75491e4dfc6b00b1c78b68d86f54c90" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/5016e263f95d97670d71b9a987bd8996ade6d8d9", - "reference": "5016e263f95d97670d71b9a987bd8996ade6d8d9", + "url": "https://api.github.com/repos/laravel/pint/zipball/c67b4195b75491e4dfc6b00b1c78b68d86f54c90", + "reference": "c67b4195b75491e4dfc6b00b1c78b68d86f54c90", "shasum": "" }, "require": { @@ -9337,13 +9349,13 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.87.2", - "illuminate/view": "^11.46.0", - "larastan/larastan": "^3.7.1", - "laravel-zero/framework": "^11.45.0", + "friendsofphp/php-cs-fixer": "^3.92.4", + "illuminate/view": "^12.44.0", + "larastan/larastan": "^3.8.1", + "laravel-zero/framework": "^12.0.4", "mockery/mockery": "^1.6.12", - "nunomaduro/termwind": "^2.3.1", - "pestphp/pest": "^2.36.0" + "nunomaduro/termwind": "^2.3.3", + "pestphp/pest": "^3.8.4" }, "bin": [ "builds/pint" @@ -9369,6 +9381,7 @@ "description": "An opinionated code formatter for PHP.", "homepage": "https://laravel.com", "keywords": [ + "dev", "format", "formatter", "lint", @@ -9379,7 +9392,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2025-09-19T02:57:12+00:00" + "time": "2026-01-05T16:49:17+00:00" }, { "name": "laravel/roster", @@ -9444,16 +9457,16 @@ }, { "name": "laravel/sail", - "version": "v1.47.0", + "version": "v1.52.0", "source": { "type": "git", "url": "https://github.com/laravel/sail.git", - "reference": "9a11e822238167ad8b791e4ea51155d25cf4d8f2" + "reference": "64ac7d8abb2dbcf2b76e61289451bae79066b0b3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/9a11e822238167ad8b791e4ea51155d25cf4d8f2", - "reference": "9a11e822238167ad8b791e4ea51155d25cf4d8f2", + "url": "https://api.github.com/repos/laravel/sail/zipball/64ac7d8abb2dbcf2b76e61289451bae79066b0b3", + "reference": "64ac7d8abb2dbcf2b76e61289451bae79066b0b3", "shasum": "" }, "require": { @@ -9503,24 +9516,24 @@ "issues": "https://github.com/laravel/sail/issues", "source": "https://github.com/laravel/sail" }, - "time": "2025-10-28T13:55:29+00:00" + "time": "2026-01-01T02:46:03+00:00" }, { "name": "league/uri-components", - "version": "7.5.1", + "version": "7.8.0", "source": { "type": "git", "url": "https://github.com/thephpleague/uri-components.git", - "reference": "4aabf0e2f2f9421ffcacab35be33e4fb5e63c44f" + "reference": "8b5ffcebcc0842b76eb80964795bd56a8333b2ba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri-components/zipball/4aabf0e2f2f9421ffcacab35be33e4fb5e63c44f", - "reference": "4aabf0e2f2f9421ffcacab35be33e4fb5e63c44f", + "url": "https://api.github.com/repos/thephpleague/uri-components/zipball/8b5ffcebcc0842b76eb80964795bd56a8333b2ba", + "reference": "8b5ffcebcc0842b76eb80964795bd56a8333b2ba", "shasum": "" }, "require": { - "league/uri": "^7.5", + "league/uri": "^7.8", "php": "^8.1" }, "suggest": { @@ -9529,8 +9542,10 @@ "ext-gmp": "to improve IPV4 host parsing", "ext-intl": "to handle IDN host with the best performance", "ext-mbstring": "to use the sorting algorithm of URLSearchParams", - "jeremykendall/php-domain-parser": "to resolve Public Suffix and Top Level Domain", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" }, "type": "library", @@ -9577,7 +9592,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri-components/tree/7.5.1" + "source": "https://github.com/thephpleague/uri-components/tree/7.8.0" }, "funding": [ { @@ -9585,7 +9600,7 @@ "type": "github" } ], - "time": "2024-12-08T08:40:02+00:00" + "time": "2026-01-14T17:24:56+00:00" }, { "name": "mockery/mockery", @@ -9732,16 +9747,16 @@ }, { "name": "nunomaduro/collision", - "version": "v8.8.2", + "version": "v8.8.3", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "60207965f9b7b7a4ce15a0f75d57f9dadb105bdb" + "reference": "1dc9e88d105699d0fee8bb18890f41b274f6b4c4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/60207965f9b7b7a4ce15a0f75d57f9dadb105bdb", - "reference": "60207965f9b7b7a4ce15a0f75d57f9dadb105bdb", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/1dc9e88d105699d0fee8bb18890f41b274f6b4c4", + "reference": "1dc9e88d105699d0fee8bb18890f41b274f6b4c4", "shasum": "" }, "require": { @@ -9763,7 +9778,7 @@ "laravel/sanctum": "^4.1.1", "laravel/tinker": "^2.10.1", "orchestra/testbench-core": "^9.12.0 || ^10.4", - "pestphp/pest": "^3.8.2", + "pestphp/pest": "^3.8.2 || ^4.0.0", "sebastian/environment": "^7.2.1 || ^8.0" }, "type": "library", @@ -9827,45 +9842,45 @@ "type": "patreon" } ], - "time": "2025-06-25T02:12:12+00:00" + "time": "2025-11-20T02:55:25+00:00" }, { "name": "pestphp/pest", - "version": "v4.1.3", + "version": "v4.3.1", "source": { "type": "git", "url": "https://github.com/pestphp/pest.git", - "reference": "477d20a54fd9329ddfb0f8d4eb90dca7bc81b027" + "reference": "bc57a84e77afd4544ff9643a6858f68d05aeab96" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest/zipball/477d20a54fd9329ddfb0f8d4eb90dca7bc81b027", - "reference": "477d20a54fd9329ddfb0f8d4eb90dca7bc81b027", + "url": "https://api.github.com/repos/pestphp/pest/zipball/bc57a84e77afd4544ff9643a6858f68d05aeab96", + "reference": "bc57a84e77afd4544ff9643a6858f68d05aeab96", "shasum": "" }, "require": { - "brianium/paratest": "^7.14.0", - "nunomaduro/collision": "^8.8.2", - "nunomaduro/termwind": "^2.3.1", + "brianium/paratest": "^7.16.0", + "nunomaduro/collision": "^8.8.3", + "nunomaduro/termwind": "^2.3.3", "pestphp/pest-plugin": "^4.0.0", "pestphp/pest-plugin-arch": "^4.0.0", "pestphp/pest-plugin-mutate": "^4.0.1", - "pestphp/pest-plugin-profanity": "^4.1.0", + "pestphp/pest-plugin-profanity": "^4.2.1", "php": "^8.3.0", - "phpunit/phpunit": "^12.4.1", - "symfony/process": "^7.3.4" + "phpunit/phpunit": "^12.5.4", + "symfony/process": "^7.4.3|^8.0.0" }, "conflict": { "filp/whoops": "<2.18.3", - "phpunit/phpunit": ">12.4.1", + "phpunit/phpunit": ">12.5.4", "sebastian/exporter": "<7.0.0", "webmozart/assert": "<1.11.0" }, "require-dev": { "pestphp/pest-dev-tools": "^4.0.0", "pestphp/pest-plugin-browser": "^4.1.1", - "pestphp/pest-plugin-type-coverage": "^4.0.2", - "psy/psysh": "^0.12.12" + "pestphp/pest-plugin-type-coverage": "^4.0.3", + "psy/psysh": "^0.12.18" }, "bin": [ "bin/pest" @@ -9931,7 +9946,7 @@ ], "support": { "issues": "https://github.com/pestphp/pest/issues", - "source": "https://github.com/pestphp/pest/tree/v4.1.3" + "source": "https://github.com/pestphp/pest/tree/v4.3.1" }, "funding": [ { @@ -9943,7 +9958,7 @@ "type": "github" } ], - "time": "2025-10-29T22:45:27+00:00" + "time": "2026-01-04T16:29:59+00:00" }, { "name": "pestphp/pest-plugin", @@ -10087,16 +10102,16 @@ }, { "name": "pestphp/pest-plugin-browser", - "version": "v4.1.1", + "version": "v4.2.1", "source": { "type": "git", "url": "https://github.com/pestphp/pest-plugin-browser.git", - "reference": "da70fce21e4b33ba22bef1276f654e77676213d7" + "reference": "0ed837ab7e80e6fc78d36913cc0b006f8819336d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest-plugin-browser/zipball/da70fce21e4b33ba22bef1276f654e77676213d7", - "reference": "da70fce21e4b33ba22bef1276f654e77676213d7", + "url": "https://api.github.com/repos/pestphp/pest-plugin-browser/zipball/0ed837ab7e80e6fc78d36913cc0b006f8819336d", + "reference": "0ed837ab7e80e6fc78d36913cc0b006f8819336d", "shasum": "" }, "require": { @@ -10104,20 +10119,20 @@ "amphp/http-server": "^3.4.3", "amphp/websocket-client": "^2.0.2", "ext-sockets": "*", - "pestphp/pest": "^4.1.0", + "pestphp/pest": "^4.3.1", "pestphp/pest-plugin": "^4.0.0", "php": "^8.3", - "symfony/process": "^7.3.4" + "symfony/process": "^7.4.3" }, "require-dev": { "ext-pcntl": "*", "ext-posix": "*", - "livewire/livewire": "^3.6.4", - "nunomaduro/collision": "^8.8.2", - "orchestra/testbench": "^10.6.0", + "livewire/livewire": "^3.7.3", + "nunomaduro/collision": "^8.8.3", + "orchestra/testbench": "^10.8.0", "pestphp/pest-dev-tools": "^4.0.0", "pestphp/pest-plugin-laravel": "^4.0", - "pestphp/pest-plugin-type-coverage": "^4.0.2" + "pestphp/pest-plugin-type-coverage": "^4.0.3" }, "type": "library", "extra": { @@ -10150,7 +10165,7 @@ "unit" ], "support": { - "source": "https://github.com/pestphp/pest-plugin-browser/tree/v4.1.1" + "source": "https://github.com/pestphp/pest-plugin-browser/tree/v4.2.1" }, "funding": [ { @@ -10166,7 +10181,7 @@ "type": "patreon" } ], - "time": "2025-09-29T01:31:33+00:00" + "time": "2026-01-11T20:32:34+00:00" }, { "name": "pestphp/pest-plugin-laravel", @@ -10320,16 +10335,16 @@ }, { "name": "pestphp/pest-plugin-profanity", - "version": "v4.2.0", + "version": "v4.2.1", "source": { "type": "git", "url": "https://github.com/pestphp/pest-plugin-profanity.git", - "reference": "c37e5e2c7136ee4eae12082e7952332bc1c6600a" + "reference": "343cfa6f3564b7e35df0ebb77b7fa97039f72b27" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest-plugin-profanity/zipball/c37e5e2c7136ee4eae12082e7952332bc1c6600a", - "reference": "c37e5e2c7136ee4eae12082e7952332bc1c6600a", + "url": "https://api.github.com/repos/pestphp/pest-plugin-profanity/zipball/343cfa6f3564b7e35df0ebb77b7fa97039f72b27", + "reference": "343cfa6f3564b7e35df0ebb77b7fa97039f72b27", "shasum": "" }, "require": { @@ -10370,9 +10385,9 @@ "unit" ], "support": { - "source": "https://github.com/pestphp/pest-plugin-profanity/tree/v4.2.0" + "source": "https://github.com/pestphp/pest-plugin-profanity/tree/v4.2.1" }, - "time": "2025-10-28T23:14:11+00:00" + "time": "2025-12-08T00:13:17+00:00" }, { "name": "phar-io/manifest", @@ -10547,16 +10562,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "5.6.3", + "version": "5.6.6", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "94f8051919d1b0369a6bcc7931d679a511c03fe9" + "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/94f8051919d1b0369a6bcc7931d679a511c03fe9", - "reference": "94f8051919d1b0369a6bcc7931d679a511c03fe9", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/5cee1d3dfc2d2aa6599834520911d246f656bcb8", + "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8", "shasum": "" }, "require": { @@ -10566,7 +10581,7 @@ "phpdocumentor/reflection-common": "^2.2", "phpdocumentor/type-resolver": "^1.7", "phpstan/phpdoc-parser": "^1.7|^2.0", - "webmozart/assert": "^1.9.1" + "webmozart/assert": "^1.9.1 || ^2" }, "require-dev": { "mockery/mockery": "~1.3.5 || ~1.6.0", @@ -10605,22 +10620,22 @@ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.3" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.6" }, - "time": "2025-08-01T19:43:32+00:00" + "time": "2025-12-22T21:13:58+00:00" }, { "name": "phpdocumentor/type-resolver", - "version": "1.10.0", + "version": "1.12.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "679e3ce485b99e84c775d28e2e96fade9a7fb50a" + "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/679e3ce485b99e84c775d28e2e96fade9a7fb50a", - "reference": "679e3ce485b99e84c775d28e2e96fade9a7fb50a", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195", + "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195", "shasum": "" }, "require": { @@ -10663,29 +10678,29 @@ "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", "support": { "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.10.0" + "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.12.0" }, - "time": "2024-11-09T15:12:26+00:00" + "time": "2025-11-21T15:09:14+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "12.4.0", + "version": "12.5.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "67e8aed88f93d0e6e1cb7effe1a2dfc2fee6022c" + "reference": "4a9739b51cbcb355f6e95659612f92e282a7077b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/67e8aed88f93d0e6e1cb7effe1a2dfc2fee6022c", - "reference": "67e8aed88f93d0e6e1cb7effe1a2dfc2fee6022c", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4a9739b51cbcb355f6e95659612f92e282a7077b", + "reference": "4a9739b51cbcb355f6e95659612f92e282a7077b", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^5.6.1", + "nikic/php-parser": "^5.7.0", "php": ">=8.3", "phpunit/php-file-iterator": "^6.0", "phpunit/php-text-template": "^5.0", @@ -10693,10 +10708,10 @@ "sebastian/environment": "^8.0.3", "sebastian/lines-of-code": "^4.0", "sebastian/version": "^6.0", - "theseer/tokenizer": "^1.2.3" + "theseer/tokenizer": "^2.0.1" }, "require-dev": { - "phpunit/phpunit": "^12.3.7" + "phpunit/phpunit": "^12.5.1" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -10705,7 +10720,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "12.4.x-dev" + "dev-main": "12.5.x-dev" } }, "autoload": { @@ -10734,7 +10749,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.4.0" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.2" }, "funding": [ { @@ -10754,7 +10769,7 @@ "type": "tidelift" } ], - "time": "2025-09-24T13:44:41+00:00" + "time": "2025-12-24T07:03:04+00:00" }, { "name": "phpunit/php-file-iterator", @@ -11003,16 +11018,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.4.1", + "version": "12.5.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "fc5413a2e6d240d2f6d9317bdf7f0a24e73de194" + "reference": "4ba0e923f9d3fc655de22f9547c01d15a41fc93a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/fc5413a2e6d240d2f6d9317bdf7f0a24e73de194", - "reference": "fc5413a2e6d240d2f6d9317bdf7f0a24e73de194", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/4ba0e923f9d3fc655de22f9547c01d15a41fc93a", + "reference": "4ba0e923f9d3fc655de22f9547c01d15a41fc93a", "shasum": "" }, "require": { @@ -11026,7 +11041,7 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.3", - "phpunit/php-code-coverage": "^12.4.0", + "phpunit/php-code-coverage": "^12.5.1", "phpunit/php-file-iterator": "^6.0.0", "phpunit/php-invoker": "^6.0.0", "phpunit/php-text-template": "^5.0.0", @@ -11048,7 +11063,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "12.4-dev" + "dev-main": "12.5-dev" } }, "autoload": { @@ -11080,7 +11095,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.4.1" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.4" }, "funding": [ { @@ -11104,20 +11119,20 @@ "type": "tidelift" } ], - "time": "2025-10-09T14:08:29+00:00" + "time": "2025-12-15T06:05:34+00:00" }, { "name": "revolt/event-loop", - "version": "v1.0.7", + "version": "v1.0.8", "source": { "type": "git", "url": "https://github.com/revoltphp/event-loop.git", - "reference": "09bf1bf7f7f574453efe43044b06fafe12216eb3" + "reference": "b6fc06dce8e9b523c9946138fa5e62181934f91c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/09bf1bf7f7f574453efe43044b06fafe12216eb3", - "reference": "09bf1bf7f7f574453efe43044b06fafe12216eb3", + "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/b6fc06dce8e9b523c9946138fa5e62181934f91c", + "reference": "b6fc06dce8e9b523c9946138fa5e62181934f91c", "shasum": "" }, "require": { @@ -11174,9 +11189,9 @@ ], "support": { "issues": "https://github.com/revoltphp/event-loop/issues", - "source": "https://github.com/revoltphp/event-loop/tree/v1.0.7" + "source": "https://github.com/revoltphp/event-loop/tree/v1.0.8" }, - "time": "2025-01-25T19:27:39+00:00" + "time": "2025-08-27T21:33:23+00:00" }, { "name": "sebastian/cli-parser", @@ -12129,28 +12144,28 @@ }, { "name": "symfony/yaml", - "version": "v7.3.5", + "version": "v7.4.1", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "90208e2fc6f68f613eae7ca25a2458a931b1bacc" + "reference": "24dd4de28d2e3988b311751ac49e684d783e2345" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/90208e2fc6f68f613eae7ca25a2458a931b1bacc", - "reference": "90208e2fc6f68f613eae7ca25a2458a931b1bacc", + "url": "https://api.github.com/repos/symfony/yaml/zipball/24dd4de28d2e3988b311751ac49e684d783e2345", + "reference": "24dd4de28d2e3988b311751ac49e684d783e2345", "shasum": "" }, "require": { "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-ctype": "^1.8" }, "conflict": { "symfony/console": "<6.4" }, "require-dev": { - "symfony/console": "^6.4|^7.0" + "symfony/console": "^6.4|^7.0|^8.0" }, "bin": [ "Resources/bin/yaml-lint" @@ -12181,7 +12196,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.3.5" + "source": "https://github.com/symfony/yaml/tree/v7.4.1" }, "funding": [ { @@ -12201,7 +12216,7 @@ "type": "tidelift" } ], - "time": "2025-09-27T09:00:46+00:00" + "time": "2025-12-04T18:11:45+00:00" }, { "name": "ta-tikoma/phpunit-architecture-test", @@ -12264,23 +12279,23 @@ }, { "name": "theseer/tokenizer", - "version": "1.2.3", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2" + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", "shasum": "" }, "require": { "ext-dom": "*", "ext-tokenizer": "*", "ext-xmlwriter": "*", - "php": "^7.2 || ^8.0" + "php": "^8.1" }, "type": "library", "autoload": { @@ -12302,7 +12317,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.2.3" + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" }, "funding": [ { @@ -12310,27 +12325,27 @@ "type": "github" } ], - "time": "2024-03-03T12:36:25+00:00" + "time": "2025-12-08T11:19:18+00:00" }, { "name": "webmozart/assert", - "version": "1.12.1", + "version": "2.1.2", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "9be6926d8b485f55b9229203f962b51ed377ba68" + "reference": "ce6a2f100c404b2d32a1dd1270f9b59ad4f57649" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68", - "reference": "9be6926d8b485f55b9229203f962b51ed377ba68", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/ce6a2f100c404b2d32a1dd1270f9b59ad4f57649", + "reference": "ce6a2f100c404b2d32a1dd1270f9b59ad4f57649", "shasum": "" }, "require": { "ext-ctype": "*", "ext-date": "*", "ext-filter": "*", - "php": "^7.2 || ^8.0" + "php": "^8.2" }, "suggest": { "ext-intl": "", @@ -12340,7 +12355,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.10-dev" + "dev-feature/2-0": "2.0-dev" } }, "autoload": { @@ -12356,6 +12371,10 @@ { "name": "Bernhard Schussek", "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" } ], "description": "Assertions to validate method input/output with nice error messages.", @@ -12366,9 +12385,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/1.12.1" + "source": "https://github.com/webmozarts/assert/tree/2.1.2" }, - "time": "2025-10-29T15:56:20+00:00" + "time": "2026-01-13T14:02:24+00:00" } ], "aliases": [], diff --git a/database/factories/BudgetFactory.php b/database/factories/BudgetFactory.php new file mode 100644 index 00000000..8144b247 --- /dev/null +++ b/database/factories/BudgetFactory.php @@ -0,0 +1,54 @@ + + */ +class BudgetFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'name' => fake()->words(2, true).' Budget', + 'period_type' => fake()->randomElement(BudgetPeriodType::cases()), + 'period_duration' => null, + 'period_start_day' => 1, + ]; + } + + public function monthly(): static + { + return $this->state(fn (array $attributes) => [ + 'period_type' => BudgetPeriodType::Monthly, + 'period_start_day' => fake()->numberBetween(1, 28), + ]); + } + + public function weekly(): static + { + return $this->state(fn (array $attributes) => [ + 'period_type' => BudgetPeriodType::Weekly, + 'period_start_day' => fake()->numberBetween(0, 6), + ]); + } + + public function custom(): static + { + return $this->state(fn (array $attributes) => [ + 'period_type' => BudgetPeriodType::Custom, + 'period_duration' => fake()->numberBetween(7, 90), + 'period_start_day' => fake()->numberBetween(1, 28), + ]); + } +} diff --git a/database/factories/BudgetPeriodFactory.php b/database/factories/BudgetPeriodFactory.php new file mode 100644 index 00000000..09be167c --- /dev/null +++ b/database/factories/BudgetPeriodFactory.php @@ -0,0 +1,30 @@ + + */ +class BudgetPeriodFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $startDate = fake()->dateTimeBetween('-1 month', '+1 month'); + $endDate = (clone $startDate)->modify('+1 month'); + + return [ + 'budget_id' => Budget::factory(), + 'start_date' => $startDate, + 'end_date' => $endDate, + 'carried_over_amount' => 0, + ]; + } +} diff --git a/database/factories/BudgetTransactionFactory.php b/database/factories/BudgetTransactionFactory.php new file mode 100644 index 00000000..d9e34b93 --- /dev/null +++ b/database/factories/BudgetTransactionFactory.php @@ -0,0 +1,27 @@ + + */ +class BudgetTransactionFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'transaction_id' => Transaction::factory(), + 'budget_period_allocation_id' => BudgetPeriodAllocation::factory(), + 'amount' => fake()->numberBetween(1000, 50000), + ]; + } +} diff --git a/database/migrations/2025_12_19_092437_create_budgets_table.php b/database/migrations/2025_12_19_092437_create_budgets_table.php new file mode 100644 index 00000000..7125f402 --- /dev/null +++ b/database/migrations/2025_12_19_092437_create_budgets_table.php @@ -0,0 +1,36 @@ +uuid('id')->primary(); + $table->foreignUuid('user_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('period_type'); + $table->integer('period_duration')->nullable(); + $table->integer('period_start_day')->nullable(); + $table->foreignUuid('category_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignUuid('label_id')->nullable()->constrained()->nullOnDelete(); + $table->string('rollover_type')->default('carry_over'); + $table->timestamps(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('budgets'); + } +}; diff --git a/database/migrations/2025_12_19_092443_create_budget_periods_table.php b/database/migrations/2025_12_19_092443_create_budget_periods_table.php new file mode 100644 index 00000000..fbb733f0 --- /dev/null +++ b/database/migrations/2025_12_19_092443_create_budget_periods_table.php @@ -0,0 +1,34 @@ +uuid('id')->primary(); + $table->foreignUuid('budget_id')->constrained()->cascadeOnDelete(); + $table->date('start_date'); + $table->date('end_date'); + $table->bigInteger('allocated_amount')->default(0); + $table->bigInteger('carried_over_amount')->default(0); + $table->timestamps(); + + $table->unique(['budget_id', 'start_date']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('budget_periods'); + } +}; diff --git a/database/migrations/2025_12_19_092446_create_budget_transactions_table.php b/database/migrations/2025_12_19_092446_create_budget_transactions_table.php new file mode 100644 index 00000000..7623413c --- /dev/null +++ b/database/migrations/2025_12_19_092446_create_budget_transactions_table.php @@ -0,0 +1,30 @@ +uuid('id')->primary(); + $table->foreignUuid('transaction_id')->constrained()->cascadeOnDelete(); + $table->foreignUuid('budget_period_id')->constrained()->cascadeOnDelete(); + $table->bigInteger('amount'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('budget_transactions'); + } +}; diff --git a/database/migrations/2025_12_29_063338_add_currency_code_to_users_table.php b/database/migrations/2025_12_29_063338_add_currency_code_to_users_table.php index df3c1a14..0cb0db47 100644 --- a/database/migrations/2025_12_29_063338_add_currency_code_to_users_table.php +++ b/database/migrations/2025_12_29_063338_add_currency_code_to_users_table.php @@ -12,7 +12,9 @@ return new class extends Migration public function up(): void { Schema::table('users', function (Blueprint $table) { - $table->string('currency_code', 3)->default('USD')->after('email'); + if (! Schema::hasColumn('users', 'currency_code')) { + $table->string('currency_code', 3)->default('USD')->after('email'); + } }); } diff --git a/resources/css/app.css b/resources/css/app.css index 9f7791ac..09b26d36 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -98,6 +98,8 @@ --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); --radius: 0.625rem; --sidebar: oklch(0.985 0 0); --sidebar-foreground: oklch(0.145 0 0); @@ -139,6 +141,8 @@ --chart-8: var(--color-zinc-900); --chart-9: var(--color-zinc-50); --chart-10: var(--color-zinc-100); + --spent: var(--color-zinc-200); + --allocated: var(--color-zinc-700); --sidebar: oklch(0.205 0 0); --sidebar-foreground: oklch(0.985 0 0); --sidebar-primary: oklch(0.985 0 0); diff --git a/resources/js/components/budgets/budget-list-card.tsx b/resources/js/components/budgets/budget-list-card.tsx new file mode 100644 index 00000000..d59192a8 --- /dev/null +++ b/resources/js/components/budgets/budget-list-card.tsx @@ -0,0 +1,139 @@ +import { show } from '@/actions/App/Http/Controllers/BudgetController'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Progress } from '@/components/ui/progress'; +import { Budget, getBudgetPeriodTypeLabel } from '@/types/budget'; +import { formatCurrency } from '@/utils/currency'; +import { Link } from '@inertiajs/react'; +import { ArrowRight, Calendar } from 'lucide-react'; +import { useMemo } from 'react'; + +interface Props { + budget: Budget; + currencyCode: string; +} + +export function BudgetListCard({ budget, currencyCode }: Props) { + const currentPeriod = budget.periods?.[0]; + + const stats = useMemo(() => { + if (!currentPeriod) { + return { + totalAllocated: 0, + totalSpent: 0, + remaining: 0, + percentageUsed: 0, + }; + } + + const totalAllocated = currentPeriod.allocated_amount; + const totalSpent = + currentPeriod.budget_transactions?.reduce( + (sum, t) => sum + t.amount, + 0, + ) ?? 0; + + const remaining = totalAllocated - totalSpent; + const percentageUsed = + totalAllocated > 0 ? (totalSpent / totalAllocated) * 100 : 0; + + return { + totalAllocated, + totalSpent, + remaining, + percentageUsed, + }; + }, [currentPeriod]); + + const periodLabel = useMemo(() => { + if (!currentPeriod) return 'No active period'; + + const start = new Date(currentPeriod.start_date).toLocaleDateString( + 'en-US', + { month: 'short', day: 'numeric' }, + ); + const end = new Date(currentPeriod.end_date).toLocaleDateString( + 'en-US', + { month: 'short', day: 'numeric' }, + ); + + return `${start} - ${end}`; + }, [currentPeriod]); + + const statusColor = useMemo(() => { + if (stats.percentageUsed >= 100) + return 'text-red-600 dark:text-red-400'; + if (stats.percentageUsed >= 80) + return 'text-yellow-600 dark:text-yellow-400'; + return 'text-green-600 dark:text-green-400'; + }, [stats.percentageUsed]); + + const trackingLabel = useMemo(() => { + if (budget.category) return budget.category.name; + if (budget.label) return budget.label.name; + return 'No tracking'; + }, [budget]); + + return ( + + +
+
+ {budget.name} + + + {periodLabel} + +
+ + {getBudgetPeriodTypeLabel(budget.period_type)} + +
+
+ +
+
+ Spent + + {formatCurrency(stats.totalSpent, currencyCode)} of{' '} + {formatCurrency(stats.totalAllocated, currencyCode)} + +
+ +
+ Remaining + + {formatCurrency(stats.remaining, currencyCode)} + +
+
+ +
+ + Tracking: {trackingLabel} + + + + +
+
+
+ ); +} diff --git a/resources/js/components/budgets/budget-spending-chart.tsx b/resources/js/components/budgets/budget-spending-chart.tsx new file mode 100644 index 00000000..02d59944 --- /dev/null +++ b/resources/js/components/budgets/budget-spending-chart.tsx @@ -0,0 +1,273 @@ +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { + ChartContainer, + ChartTooltip, + type ChartConfig, +} from '@/components/ui/chart'; +import { BudgetPeriod } from '@/types/budget'; +import { formatCurrency } from '@/utils/currency'; +import { useMemo } from 'react'; +import { Area, AreaChart, XAxis } from 'recharts'; + +interface Props { + currentPeriod: BudgetPeriod; + budgetName: string; + currencyCode: string; +} + +interface CustomTooltipProps { + active?: boolean; + payload?: Array<{ + payload: { + date: string; + spent: number; + allocated: number; + remaining: number; + }; + }>; + label?: string; + currencyCode: string; +} + +function CustomTooltip({ + active, + payload, + label, + currencyCode, +}: CustomTooltipProps) { + if (!active || !payload || !payload.length || !label) { + return null; + } + + const data = payload[0].payload; + const allocated = data.allocated; + const spent = data.spent; + const available = data.remaining; + const percentage = + allocated > 0 ? Math.round((available / allocated) * 100) : 0; + + return ( +
+

+ {new Date(label).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + })} +

+
+
+ Allocated: + + {formatCurrency(allocated, currencyCode)} + +
+
+ Spent: + + {formatCurrency(spent, currencyCode)} + +
+
+
+ Available: +
+ + {percentage}% / + + + {formatCurrency(available, currencyCode)} + +
+
+
+
+
+ ); +} + +export function BudgetSpendingChart({ + currentPeriod, + budgetName, + currencyCode, +}: Props) { + const chartData = useMemo(() => { + const transactions = currentPeriod.budget_transactions || []; + const startDate = new Date(currentPeriod.start_date); + const endDate = new Date(currentPeriod.end_date); + + // Group transactions by date (using the actual transaction date, not when it was assigned) + const transactionsByDate = new Map(); + transactions.forEach((t) => { + if (!t.transaction) return; + const date = new Date(t.transaction.transaction_date) + .toISOString() + .split('T')[0]; + transactionsByDate.set( + date, + (transactionsByDate.get(date) || 0) + t.amount, + ); + }); + + // Generate daily data points + const data = []; + let cumulativeSpent = 0; + const currentDate = new Date(startDate); + + while (currentDate <= endDate && currentDate <= new Date()) { + const dateStr = currentDate.toISOString().split('T')[0]; + const dailySpent = transactionsByDate.get(dateStr) || 0; + cumulativeSpent += dailySpent; + + data.push({ + date: dateStr, + spent: cumulativeSpent, + allocated: currentPeriod.allocated_amount, + remaining: currentPeriod.allocated_amount - cumulativeSpent, + }); + + currentDate.setDate(currentDate.getDate() + 1); + } + + return data; + }, [currentPeriod]); + + const chartConfig = { + spent: { + label: 'Spent', + color: 'var(--spent)', + }, + allocated: { + label: 'Budget', + color: 'var(--allocated)', + }, + } satisfies ChartConfig; + + const periodLabel = useMemo(() => { + const start = new Date(currentPeriod.start_date).toLocaleDateString( + 'en-US', + { month: 'short', day: 'numeric' }, + ); + const end = new Date(currentPeriod.end_date).toLocaleDateString( + 'en-US', + { month: 'short', day: 'numeric', year: 'numeric' }, + ); + return `${start} - ${end}`; + }, [currentPeriod]); + + return ( + + + Budget Spending + + Tracking spending for {budgetName} · {periodLabel} + + + + + + + + + + + + + + + + + + { + const date = new Date(value); + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + }} + /> + + } + /> + + + + + + + ); +} diff --git a/resources/js/components/budgets/create-budget-dialog.tsx b/resources/js/components/budgets/create-budget-dialog.tsx new file mode 100644 index 00000000..657e62a9 --- /dev/null +++ b/resources/js/components/budgets/create-budget-dialog.tsx @@ -0,0 +1,390 @@ +import { store } from '@/actions/App/Http/Controllers/BudgetController'; +import { AmountInput } from '@/components/ui/amount-input'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label as UILabel } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { cn } from '@/lib/utils'; +import { SharedData } from '@/types'; +import { + BUDGET_PERIOD_TYPES, + BudgetPeriodType, + getBudgetPeriodTypeLabel, + getRolloverTypeLabel, + ROLLOVER_TYPES, + RolloverType, +} from '@/types/budget'; +import { Category } from '@/types/category'; +import { Label } from '@/types/label'; +import { router, usePage } from '@inertiajs/react'; +import { Plus, X } from 'lucide-react'; +import { useState } from 'react'; +import { Card, CardContent } from '../ui/card'; + +interface Props { + className?: string; + currencyCode?: string; +} + +export function CreateBudgetDialog({ + className = '', + currencyCode = 'USD', +}: Props) { + const page = usePage(); + const [open, setOpen] = useState(false); + const [name, setName] = useState(''); + const [periodType, setPeriodType] = useState('monthly'); + const [periodDuration, setPeriodDuration] = useState(null); + const [periodStartDay, setPeriodStartDay] = useState(1); + const [selectedCategoryId, setSelectedCategoryId] = useState(''); + const [selectedLabelId, setSelectedLabelId] = useState(''); + const [allocatedAmount, setAllocatedAmount] = useState(0); + const [rolloverType, setRolloverType] = + useState('carry_over'); + const [isSubmitting, setIsSubmitting] = useState(false); + const [errors, setErrors] = useState>({}); + + const allCategories = (page.props.categories as Category[]) || []; + const allLabels = (page.props.labels as Label[]) || []; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setErrors({}); + + const newErrors: Record = {}; + + if (!selectedCategoryId && !selectedLabelId) { + newErrors.selection = + 'You must select either a category or a label.'; + } + + if (Object.keys(newErrors).length > 0) { + setErrors(newErrors); + return; + } + + setIsSubmitting(true); + + router.post( + store().url, + { + name, + period_type: periodType, + period_duration: periodDuration, + period_start_day: periodStartDay, + category_id: selectedCategoryId || null, + label_id: selectedLabelId || null, + rollover_type: rolloverType, + allocated_amount: allocatedAmount, + }, + { + onSuccess: () => { + setOpen(false); + setName(''); + setPeriodType('monthly'); + setPeriodDuration(null); + setPeriodStartDay(1); + setSelectedCategoryId(''); + setSelectedLabelId(''); + setAllocatedAmount(0); + setRolloverType('carry_over'); + setErrors({}); + }, + onError: (errors) => { + setErrors(errors as Record); + }, + onFinish: () => setIsSubmitting(false), + }, + ); + }; + + return ( + + + + +
+ + Create Budget +
+
+
+
+ +
+ + Create Budget + + Set up a spending limit for a category or label. + + + +
+
+ Budget Name + setName(e.target.value)} + placeholder="e.g., Padel Budget" + required + /> +
+ +
+ Period Type + +
+ + {periodType === 'custom' && ( +
+ + Period Duration (days) + + + setPeriodDuration( + e.target.value + ? parseInt(e.target.value) + : null, + ) + } + required={periodType === 'custom'} + /> +
+ )} + +
+ + {periodType === 'monthly' + ? 'Start Day of Month' + : 'Start Day'} + + + setPeriodStartDay(parseInt(e.target.value)) + } + /> +

+ {periodType === 'monthly' + ? 'Day of the month when the period starts (1-31)' + : periodType === 'weekly' || + periodType === 'biweekly' + ? 'Day of week (0=Sunday, 6=Saturday)' + : 'Starting day'} +

+
+ +
+ {errors.selection && ( +
+ {errors.selection} +
+ )} + +
+ + Category (Optional) + +
+ + {selectedCategoryId && ( + + )} +
+
+ +
+ + Label (Optional) + +
+ + {selectedLabelId && ( + + )} +
+

+ Select at least a category or a label to + track. +

+
+ +
+ + Allocated Amount + + +

+ How much do you want to budget per period? +

+
+ +
+ + Rollover Type + + +

+ {rolloverType === 'carry_over' + ? 'Unused budget will carry over to the next period.' + : 'Budget resets to zero at the start of each period.'} +

+
+
+
+ + + + + +
+
+
+ ); +} diff --git a/resources/js/components/budgets/delete-budget-dialog.tsx b/resources/js/components/budgets/delete-budget-dialog.tsx new file mode 100644 index 00000000..3eaeb67f --- /dev/null +++ b/resources/js/components/budgets/delete-budget-dialog.tsx @@ -0,0 +1,72 @@ +import { destroy } from '@/actions/App/Http/Controllers/BudgetController'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { Budget } from '@/types/budget'; +import { router } from '@inertiajs/react'; +import { useState } from 'react'; + +interface Props { + budget: Budget; + open: boolean; + onOpenChange: (open: boolean) => void; + redirectTo?: string; +} + +export function DeleteBudgetDialog({ + budget, + open, + onOpenChange, + redirectTo = '/budgets', +}: Props) { + const [isDeleting, setIsDeleting] = useState(false); + + const handleDelete = () => { + setIsDeleting(true); + + router.delete(destroy({ budget: budget.id }).url, { + onSuccess: () => { + onOpenChange(false); + if (redirectTo) { + router.visit(redirectTo); + } + }, + onFinish: () => setIsDeleting(false), + }); + }; + + return ( + + + + Delete Budget + + Are you sure you want to delete "{budget.name}"? This + action cannot be undone. All budget periods, + allocations, and transaction assignments will be + permanently removed. + + + + + Cancel + + + {isDeleting ? 'Deleting...' : 'Delete'} + + + + + ); +} diff --git a/resources/js/components/budgets/edit-budget-dialog.tsx b/resources/js/components/budgets/edit-budget-dialog.tsx new file mode 100644 index 00000000..31d8deff --- /dev/null +++ b/resources/js/components/budgets/edit-budget-dialog.tsx @@ -0,0 +1,266 @@ +import { update } from '@/actions/App/Http/Controllers/BudgetController'; +import { AmountInput } from '@/components/ui/amount-input'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Budget, + BUDGET_PERIOD_TYPES, + BudgetPeriodType, + getBudgetPeriodTypeLabel, + getRolloverTypeLabel, + ROLLOVER_TYPES, + RolloverType, +} from '@/types/budget'; +import { router } from '@inertiajs/react'; +import { useEffect, useState } from 'react'; + +interface Props { + budget: Budget; + currentPeriod: { allocated_amount: number }; + currencyCode?: string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function EditBudgetDialog({ + budget, + currentPeriod, + currencyCode = 'USD', + open, + onOpenChange, +}: Props) { + const [name, setName] = useState(budget.name); + const [periodType, setPeriodType] = useState( + budget.period_type as BudgetPeriodType, + ); + const [periodDuration, setPeriodDuration] = useState( + budget.period_duration, + ); + const [periodStartDay, setPeriodStartDay] = useState( + budget.period_start_day || 1, + ); + const [allocatedAmount, setAllocatedAmount] = useState( + currentPeriod.allocated_amount, + ); + const [rolloverType, setRolloverType] = useState( + budget.rollover_type as RolloverType, + ); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + if (open && budget) { + setName(budget.name); + setPeriodType(budget.period_type as BudgetPeriodType); + setPeriodDuration(budget.period_duration); + setPeriodStartDay(budget.period_start_day || 1); + setAllocatedAmount(currentPeriod.allocated_amount); + setRolloverType(budget.rollover_type as RolloverType); + } + }, [open, budget, currentPeriod]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setIsSubmitting(true); + + router.patch( + update({ budget: budget.id }).url, + { + name, + period_type: periodType, + period_duration: periodDuration, + period_start_day: periodStartDay, + allocated_amount: allocatedAmount, + rollover_type: rolloverType, + }, + { + onSuccess: () => { + onOpenChange(false); + }, + onFinish: () => setIsSubmitting(false), + }, + ); + }; + + return ( + + +
+ + Edit Budget + + Update your budget settings. To change the allocated + amount or tracking, use the budget page directly. + + + +
+
+ + setName(e.target.value)} + placeholder="e.g., Monthly Budget" + required + /> +
+ +
+ +
+ +
+
+ + {periodType === 'custom' && ( +
+ + + setPeriodDuration( + e.target.value + ? parseInt(e.target.value) + : null, + ) + } + required={periodType === 'custom'} + /> +
+ )} + +
+ + + setPeriodStartDay(parseInt(e.target.value)) + } + /> +

+ {periodType === 'monthly' + ? 'Day of the month when the period starts (1-31)' + : periodType === 'weekly' || + periodType === 'biweekly' + ? 'Day of week (0=Sunday, 6=Saturday)' + : 'Starting day'} +

+
+ +
+ + +

+ This will update the allocated amount for the + current and future periods. +

+
+ +
+ +
+ +
+

+ {rolloverType === 'carry_over' + ? 'Unused budget will carry over to the next period.' + : 'Budget resets to zero at the start of each period.'} +

+
+
+ + + + + +
+
+
+ ); +} diff --git a/resources/js/components/heading-small.tsx b/resources/js/components/heading-small.tsx index a545f1a2..80e8baa3 100644 --- a/resources/js/components/heading-small.tsx +++ b/resources/js/components/heading-small.tsx @@ -1,16 +1,19 @@ +import { isValidElement, ReactNode } from 'react'; + export default function HeadingSmall({ title, description, }: { title: string; - description?: string; + description?: string | ReactNode; }) { return (

{title}

- {description && ( + {typeof description === 'string' && (

{description}

)} + {isValidElement(description) && description}
); } diff --git a/resources/js/components/transactions/transaction-list.tsx b/resources/js/components/transactions/transaction-list.tsx index 336277af..728ee71c 100644 --- a/resources/js/components/transactions/transaction-list.tsx +++ b/resources/js/components/transactions/transaction-list.tsx @@ -219,6 +219,7 @@ export interface TransactionListProps { labels?: Label[]; automationRules?: AutomationRule[]; accountId?: UUID; + transactions?: Transaction[]; // Optional: if provided, use these instead of fetching from Dexie pageSize?: number; hideAccountFilter?: boolean; showActionsMenu?: boolean; @@ -234,6 +235,7 @@ export function TransactionList({ labels: initialLabels = [], automationRules = [], accountId, + transactions: providedTransactions, pageSize = 25, hideAccountFilter = false, showActionsMenu = true, @@ -312,7 +314,84 @@ export function TransactionList({ ); useEffect(() => { - async function fetchTransactions() { + async function processTransactions() { + // If transactions are provided directly, use them as-is (already decrypted from backend) + if (providedTransactions) { + setIsLoading(true); + try { + const keyString = getStoredKey(); + let key: CryptoKey | null = null; + + if (keyString && isKeySet) { + try { + key = await importKey(keyString); + } catch (error) { + console.error( + 'Failed to import encryption key:', + error, + ); + } + } + + const decrypted = await Promise.all( + providedTransactions.map(async (transaction) => { + try { + let decryptedDescription = ''; + let decryptedNotes: string | null = null; + + if (key) { + try { + decryptedDescription = await decrypt( + transaction.description, + key, + transaction.description_iv, + ); + if ( + transaction.notes && + transaction.notes_iv + ) { + decryptedNotes = await decrypt( + transaction.notes, + key, + transaction.notes_iv, + ); + } + } catch (error) { + console.error( + 'Failed to decrypt transaction:', + error, + ); + } + } + + return { + ...transaction, + decryptedDescription, + decryptedNotes, + } as DecryptedTransaction; + } catch (error) { + console.error( + 'Error processing transaction:', + error, + ); + return null; + } + }), + ); + + const validTransactions = decrypted.filter( + (t): t is DecryptedTransaction => t !== null, + ); + + setTransactions(validTransactions); + } catch (error) { + console.error('Error processing transactions:', error); + } finally { + setIsLoading(false); + } + return; + } + setIsLoading(true); try { const response = await axios.get('/api/sync/transactions'); @@ -451,8 +530,16 @@ export function TransactionList({ } } - fetchTransactions(); - }, [refreshKey, accounts, banks, categories, isKeySet, accountId]); + processTransactions(); + }, [ + refreshKey, + accounts, + banks, + categories, + isKeySet, + accountId, + providedTransactions, + ]); useEffect(() => { try { diff --git a/resources/js/components/ui/input.tsx b/resources/js/components/ui/input.tsx index 9d53c5a5..174e8b23 100644 --- a/resources/js/components/ui/input.tsx +++ b/resources/js/components/ui/input.tsx @@ -10,7 +10,7 @@ const Input = React.forwardRef>( type={type} data-slot="input" className={cn( - "border-input file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + "border-input file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", className diff --git a/resources/js/layouts/settings/layout.tsx b/resources/js/layouts/settings/layout.tsx index ca8f9d9e..6ed9f218 100644 --- a/resources/js/layouts/settings/layout.tsx +++ b/resources/js/layouts/settings/layout.tsx @@ -8,6 +8,7 @@ import { Separator } from '@/components/ui/separator'; import { cn, isSameUrl, resolveUrl } from '@/lib/utils'; import { edit as editAccount } from '@/routes/account'; import { edit as editAppearance } from '@/routes/appearance'; +import { settings as budgetsSettings } from '@/routes/budgets'; import { edit as editDeleteAccount } from '@/routes/delete-account'; import { billing } from '@/routes/settings'; import { @@ -47,6 +48,12 @@ const getNavItems = ( href: labelsIndex(), icon: null, }, + { + type: 'nav-item', + title: 'Budgets', + href: budgetsSettings(), + icon: null, + }, { type: 'divider' }, { type: 'section-header', diff --git a/resources/js/lib/dexie-db.ts b/resources/js/lib/dexie-db.ts index ef7a38c8..0dfc9851 100644 --- a/resources/js/lib/dexie-db.ts +++ b/resources/js/lib/dexie-db.ts @@ -1,3 +1,9 @@ +import type { + Budget, + BudgetCategory, + BudgetPeriod, + BudgetPeriodAllocation, +} from '@/types/budget'; import type { Transaction } from '@/types/transaction'; import Dexie, { type EntityTable } from 'dexie'; @@ -8,6 +14,10 @@ export interface SyncMetadata { type WhisperMoneyDB = Dexie & { transactions: EntityTable; + budgets: EntityTable; + budget_categories: EntityTable; + budget_periods: EntityTable; + budget_period_allocations: EntityTable; sync_metadata: EntityTable; }; @@ -59,6 +69,17 @@ function initializeDatabase(): WhisperMoneyDB { sync_metadata: 'key', }); + // Version 9: Add budget tables + database.version(9).stores({ + transactions: 'id, user_id, account_id, updated_at', + budgets: 'id, user_id, updated_at', + budget_categories: 'id, budget_id, updated_at', + budget_periods: 'id, budget_id, start_date, updated_at', + budget_period_allocations: + 'id, budget_period_id, budget_category_id, updated_at', + sync_metadata: 'key', + }); + return database; } diff --git a/resources/js/pages/budgets/index.tsx b/resources/js/pages/budgets/index.tsx new file mode 100644 index 00000000..d15e1907 --- /dev/null +++ b/resources/js/pages/budgets/index.tsx @@ -0,0 +1,57 @@ +import { index } from '@/actions/App/Http/Controllers/BudgetController'; +import { BudgetListCard } from '@/components/budgets/budget-list-card'; +import { CreateBudgetDialog } from '@/components/budgets/create-budget-dialog'; +import HeadingSmall from '@/components/heading-small'; +import AppSidebarLayout from '@/layouts/app/app-sidebar-layout'; +import { BreadcrumbItem } from '@/types'; +import { Budget } from '@/types/budget'; +import { Head } from '@inertiajs/react'; + +const breadcrumbs: BreadcrumbItem[] = [ + { + title: 'Budgets', + href: index().url, + }, +]; + +interface Props { + budgets: Budget[]; + currencyCode: string; +} + +export default function BudgetsIndex({ budgets, currencyCode }: Props) { + return ( + + + +
+
+ +
+ + {budgets.length > 0 ? ( +
+ {budgets.map((budget) => ( + + ))} + +
+ ) : ( +
+ +
+ )} +
+
+ ); +} diff --git a/resources/js/pages/budgets/show.tsx b/resources/js/pages/budgets/show.tsx new file mode 100644 index 00000000..44b06195 --- /dev/null +++ b/resources/js/pages/budgets/show.tsx @@ -0,0 +1,184 @@ +import { index, show } from '@/actions/App/Http/Controllers/BudgetController'; +import { BudgetSpendingChart } from '@/components/budgets/budget-spending-chart'; +import { DeleteBudgetDialog } from '@/components/budgets/delete-budget-dialog'; +import { EditBudgetDialog } from '@/components/budgets/edit-budget-dialog'; +import HeadingSmall from '@/components/heading-small'; +import { TransactionList } from '@/components/transactions/transaction-list'; +import { Button } from '@/components/ui/button'; +import { ButtonGroup } from '@/components/ui/button-group'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import AppSidebarLayout from '@/layouts/app/app-sidebar-layout'; +import { BreadcrumbItem } from '@/types'; +import { Account, Bank } from '@/types/account'; +import { Budget, BudgetPeriod, getBudgetPeriodTypeLabel } from '@/types/budget'; +import { Category } from '@/types/category'; +import { Head } from '@inertiajs/react'; +import { ChevronDown } from 'lucide-react'; +import { useMemo, useState } from 'react'; + +interface Props { + budget: Budget; + currentPeriod: BudgetPeriod; + categories: Category[]; + accounts: Account[]; + banks: Bank[]; + currencyCode: string; +} + +export default function BudgetShow({ + budget, + currentPeriod, + categories, + accounts, + banks, + currencyCode, +}: Props) { + const [editOpen, setEditOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + + const breadcrumbs: BreadcrumbItem[] = [ + { + title: 'Budgets', + href: index().url, + }, + { + title: budget.name, + href: show({ budget: budget.id }).url, + }, + ]; + + const periodLabel = useMemo(() => { + const start = new Date(currentPeriod.start_date).toLocaleDateString( + 'en-US', + { month: 'short', day: 'numeric', year: '2-digit' }, + ); + const end = new Date(currentPeriod.end_date).toLocaleDateString( + 'en-US', + { month: 'short', day: 'numeric', year: '2-digit' }, + ); + return `${start} - ${end}`; + }, [currentPeriod]); + + const trackingLabel = useMemo((): string | null => { + if (budget.category) return budget.category.name; + if (budget.label) return budget.label.name; + return null; + }, [budget]); + + const periodTransactions = useMemo(() => { + return ( + currentPeriod.budget_transactions + ?.map((bt) => bt.transaction) + .filter((t) => t !== undefined && t !== null) || [] + ); + }, [currentPeriod]); + + return ( + + + +
+
+
+ +
+ {trackingLabel !== null ? ( + <> + + Tracking{' '} + + {trackingLabel} + + ) : ( + + No tracking + + )} +
+ / +
+ {periodLabel} + + ( + {getBudgetPeriodTypeLabel( + budget.period_type, + )} + ) + +
+
+ } + /> +
+ + + + + + + + + setDeleteOpen(true)} + variant="destructive" + > + Delete + + + + +
+ + + + + + + + + +
+ ); +} diff --git a/resources/js/pages/transactions/index.tsx b/resources/js/pages/transactions/index.tsx index 303596f8..1edcf4fe 100644 --- a/resources/js/pages/transactions/index.tsx +++ b/resources/js/pages/transactions/index.tsx @@ -1719,7 +1719,9 @@ export default function Transactions({ labels={labels} open={createDialogOpen} onOpenChange={setCreateDialogOpen} - onSuccess={() => {}} + onSuccess={(transaction) => { + setTransactions((prev) => [transaction, ...prev]); + }} mode="create" /> diff --git a/resources/js/providers/menu-item-provider.tsx b/resources/js/providers/menu-item-provider.tsx index fb427a6e..44f307c1 100644 --- a/resources/js/providers/menu-item-provider.tsx +++ b/resources/js/providers/menu-item-provider.tsx @@ -1,4 +1,5 @@ import { index as accountsIndex } from '@/actions/App/Http/Controllers/AccountController'; +import { index as budgetsIndex } from '@/actions/App/Http/Controllers/BudgetController'; import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController'; import DiscordIcon from '@/components/icons/DiscordIcon'; import { cashflow, dashboard } from '@/routes'; @@ -7,6 +8,7 @@ import { CreditCard, Github, LayoutGrid, + PiggyBank, Receipt, TrendingUp, } from 'lucide-react'; @@ -45,6 +47,15 @@ export function getMainNavItems(features: Features): NavItem[] { }, ); + if (features.budgets) { + items.push({ + type: 'nav-item', + title: 'Budgets', + href: budgetsIndex(), + icon: PiggyBank, + }); + } + return items; } diff --git a/resources/js/types/budget.ts b/resources/js/types/budget.ts new file mode 100644 index 00000000..80e52c54 --- /dev/null +++ b/resources/js/types/budget.ts @@ -0,0 +1,90 @@ +import { Category } from './category'; +import { Label } from './label'; +import { Transaction } from './transaction'; +import { UUID } from './uuid'; + +export const BUDGET_PERIOD_TYPES = [ + 'monthly', + 'weekly', + 'biweekly', + 'custom', +] as const; + +export type BudgetPeriodType = (typeof BUDGET_PERIOD_TYPES)[number]; + +export const ROLLOVER_TYPES = ['carry_over', 'reset'] as const; + +export type RolloverType = (typeof ROLLOVER_TYPES)[number]; + +export interface Budget { + id: UUID; + user_id: UUID; + name: string; + period_type: BudgetPeriodType; + period_duration: number | null; + period_start_day: number | null; + category_id: UUID | null; + label_id: UUID | null; + rollover_type: RolloverType; + created_at: string; + updated_at: string; + deleted_at: string | null; + category?: Category; + label?: Label; + periods?: BudgetPeriod[]; +} + +export interface BudgetPeriod { + id: UUID; + budget_id: UUID; + start_date: string; + end_date: string; + allocated_amount: number; + carried_over_amount: number; + created_at: string; + updated_at: string; + budget_transactions?: BudgetTransaction[]; +} + +export interface BudgetTransaction { + id: UUID; + transaction_id: UUID; + budget_period_id: UUID; + amount: number; + created_at: string; + updated_at: string; + transaction?: Transaction; +} + +export interface BudgetHistoryData { + period_start: string; + period_end: string; + budgeted: number; + spent: number; +} + +export function getBudgetPeriodTypeLabel(type: BudgetPeriodType): string { + const labels: Record = { + monthly: 'Monthly', + weekly: 'Weekly', + biweekly: 'Bi-weekly', + custom: 'Custom', + }; + return labels[type]; +} + +export function getRolloverTypeLabel(type: RolloverType): string { + const labels: Record = { + carry_over: 'Carry Over', + reset: 'Reset/Pool', + }; + return labels[type]; +} + +export function getRolloverTypeDescription(type: RolloverType): string { + const descriptions: Record = { + carry_over: 'Remaining balance carries over to next period', + reset: 'Remaining balance returns to available money pool', + }; + return descriptions[type]; +} diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index c8fa9066..5a5e3318 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -40,6 +40,7 @@ export interface NavDivider { export interface Features { cashflow: boolean; + budgets: boolean; } export interface SharedData { @@ -63,6 +64,7 @@ export interface User { avatar?: string; email_verified_at: string | null; two_factor_enabled?: boolean; + currency_code?: string | null; created_at: string; updated_at: string; [key: string]: unknown; diff --git a/resources/js/utils/currency.ts b/resources/js/utils/currency.ts new file mode 100644 index 00000000..57ec470c --- /dev/null +++ b/resources/js/utils/currency.ts @@ -0,0 +1,22 @@ +export function formatCurrency( + valueInCents: number, + currencyCode = 'USD', +): string { + const amount = valueInCents / 100; + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currencyCode, + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(amount); +} + +export function getCurrencySymbol(currencyCode: string): string { + const symbols: Record = { + USD: '$', + EUR: '€', + GBP: '£', + JPY: '¥', + }; + return symbols[currencyCode] || currencyCode; +} diff --git a/routes/console.php b/routes/console.php index 8e45af6d..8d03fa99 100644 --- a/routes/console.php +++ b/routes/console.php @@ -3,3 +3,5 @@ use Illuminate\Support\Facades\Schedule; Schedule::command('demo:reset')->twiceDaily(); +Schedule::command('horizon:snapshot')->everyFiveMinutes(); +Schedule::command('budgets:generate-periods')->daily(); diff --git a/routes/settings.php b/routes/settings.php index d28e1cec..5cdd2dee 100644 --- a/routes/settings.php +++ b/routes/settings.php @@ -45,6 +45,10 @@ Route::middleware('auth')->group(function () { Route::patch('settings/labels/{label}', [LabelController::class, 'update'])->name('labels.update'); Route::delete('settings/labels/{label}', [LabelController::class, 'destroy'])->name('labels.destroy'); + Route::get('settings/budgets', function () { + return Inertia::render('settings/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'); diff --git a/routes/web.php b/routes/web.php index d8e4cb7f..ea55a33d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ group(functi Route::delete('transactions/{transaction}', [TransactionController::class, 'destroy'])->name('transactions.destroy'); }); +Route::middleware(['auth', 'verified', 'onboarded', 'subscribed', 'budgets'])->group(function () { + Route::get('budgets', [BudgetController::class, 'index'])->name('budgets.index'); + Route::post('budgets', [BudgetController::class, 'store'])->name('budgets.store'); + Route::get('budgets/{budget}', [BudgetController::class, 'show'])->name('budgets.show'); + Route::patch('budgets/{budget}', [BudgetController::class, 'update'])->name('budgets.update'); + Route::delete('budgets/{budget}', [BudgetController::class, 'destroy'])->name('budgets.destroy'); +}); + require __DIR__.'/settings.php'; diff --git a/tests/Browser/AmountInputTest.php b/tests/Browser/AmountInputTest.php index e5f32d50..b0ae162d 100644 --- a/tests/Browser/AmountInputTest.php +++ b/tests/Browser/AmountInputTest.php @@ -56,6 +56,7 @@ it('can create a transaction with amount input', function () { actingAs($user); $page = $this->visitWithEncryptionKey('/transactions'); + $page->wait(3); // Extra wait for IndexedDB to sync $page->assertSee('Transactions') ->click('Add Transaction') @@ -71,8 +72,10 @@ it('can create a transaction with amount input', function () { ->click($category->name) ->wait(1) ->click('[data-testid="submit-transaction"]') - ->wait(3) - ->waitForText('Test Transaction') + ->wait(4) // Wait for form submission and navigation + ->assertPathIs('/transactions') + ->wait(2) // Extra wait for IndexedDB sync after creation + ->waitForText('Test Transaction', 15) ->assertSee('$123.45') ->wait(1) ->assertNoJavascriptErrors(); @@ -96,6 +99,7 @@ it('formats amount when pressing enter', function () { $page = visit('/transactions'); $this->setupEncryptionKey($page); + $page->wait(3); // Extra wait for IndexedDB to sync $page->assertSee('Transactions') ->click('Add Transaction') @@ -108,8 +112,10 @@ it('formats amount when pressing enter', function () { ->click($category->name) ->wait(0.5) ->click('[data-testid="submit-transaction"]') - ->wait(2) - ->waitForText('Test Transaction Enter') + ->wait(4) // Wait for form submission and navigation + ->assertPathIs('/transactions') + ->wait(2) // Extra wait for IndexedDB sync after creation + ->waitForText('Test Transaction Enter', 15) ->wait(1) ->assertNoJavascriptErrors(); @@ -132,6 +138,7 @@ it('accepts negative amounts', function () { $page = visit('/transactions'); $this->setupEncryptionKey($page); + $page->wait(3); // Extra wait for IndexedDB to sync $page->assertSee('Transactions') ->click('Add Transaction') @@ -143,8 +150,10 @@ it('accepts negative amounts', function () { ->click($category->name) ->wait(0.5) ->click('[data-testid="submit-transaction"]') - ->wait(3) - ->waitForText('Test Negative Amount') + ->wait(4) // Wait for form submission and navigation + ->assertPathIs('/transactions') + ->wait(2) // Extra wait for IndexedDB sync after creation + ->waitForText('Test Negative Amount', 15) ->wait(1) ->assertNoJavascriptErrors(); diff --git a/tests/Browser/BudgetCrudTest.php b/tests/Browser/BudgetCrudTest.php new file mode 100644 index 00000000..a2ecd6bf --- /dev/null +++ b/tests/Browser/BudgetCrudTest.php @@ -0,0 +1,190 @@ +create(['onboarded_at' => now()]); + Feature::for($user)->activate('budgets'); + + $category = Category::factory()->create([ + 'user_id' => $user->id, + 'name' => 'Groceries', + ]); + + $page = $this->actingAs($user)->visit('/budgets'); + $page->wait(2); // Wait for page to fully load + + $page->assertSee('Budgets') + ->waitForText('Create Budget', 10) + ->wait(1) // Extra wait before clicking + ->click('Create Budget') + ->wait(3) // Wait for dialog to open + ->assertSee('Create Budget') + ->wait(1) // Wait for form to be ready + ->fill('name', 'Monthly Groceries') + ->wait(1) + ->click('#period-type') + ->wait(0.5) + ->click('[role="option"]:has-text("Monthly")') + ->wait(1) + ->click('button:has-text("Select a category")') + ->wait(0.5) + ->click('[role="option"]:has-text("'.$category->name.'")') + ->wait(1) + ->click('button:has-text("Carry Over")') + ->wait(0.5) + ->click('[role="option"]:has-text("Reset/Pool")') + ->wait(1) + ->fill('#allocated-amount', '500') + ->click('label:has-text("Rollover Type")') // Click elsewhere to blur the amount input + ->wait(2) // Wait for state to update + ->click('[role="dialog"] button[type="submit"]') + ->wait(4) // Wait for form submission + ->assertPathBeginsWith('/budgets/') + ->wait(2) // Wait for page to update + ->waitForText('Monthly Groceries', 15) + ->assertSee('Budget Spending') + ->assertNoJavascriptErrors(); + + $this->assertDatabaseHas('budgets', [ + 'user_id' => $user->id, + 'name' => 'Monthly Groceries', + 'category_id' => $category->id, + ]); +}); + +test('user can update budget name', function () { + $user = User::factory()->create(['onboarded_at' => now()]); + Feature::for($user)->activate('budgets'); + + $category = Category::factory()->create(['user_id' => $user->id]); + $budget = Budget::factory()->create([ + 'user_id' => $user->id, + 'category_id' => $category->id, + 'name' => 'Old Name', + ]); + + $page = $this->actingAs($user)->visit("/budgets/{$budget->id}"); + $page->wait(2); // Wait for page to fully load + + $page->assertSee('Old Name') + ->wait(2) + ->waitForText('Edit budget', 10) + ->wait(1) // Extra wait before clicking + ->click('Edit budget') + ->wait(3) // Wait for dialog to open + ->assertSee('Edit Budget') + ->wait(1) // Wait for form to be ready + ->fill('name', 'New Budget Name') + ->wait(1) + ->fill('#allocated-amount', '500') + ->wait(2) + ->click('button:has-text("Save Changes")') + ->wait(4) // Wait for form submission + ->waitForText('New Budget Name', 15) + ->assertDontSee('Old Name') + ->assertNoJavascriptErrors(); + + $this->assertDatabaseHas('budgets', [ + 'id' => $budget->id, + 'name' => 'New Budget Name', + ]); +}); + +test('user can delete a budget', function () { + $user = User::factory()->create(['onboarded_at' => now()]); + Feature::for($user)->activate('budgets'); + + $category = Category::factory()->create(['user_id' => $user->id]); + $budget = Budget::factory()->create([ + 'user_id' => $user->id, + 'category_id' => $category->id, + 'name' => 'Budget to Delete', + ]); + + $page = $this->actingAs($user)->visit("/budgets/{$budget->id}"); + $page->wait(2); // Wait for page to fully load + + $page->assertSee('Budget to Delete') + ->wait(2) + ->waitForText('Edit budget', 10) + ->wait(1) // Extra wait before clicking + ->click('[aria-label="More options"]') + ->wait(1) // Wait for dropdown to open + ->click('Delete') + ->wait(3) // Wait for dialog to open + ->assertSee('Delete Budget') + ->assertSee('Are you sure') + ->wait(2) + ->click('button:has-text("Delete")') + ->wait(4) // Wait for deletion + ->assertPathIs('/budgets') + ->wait(2) // Wait for page to update + ->assertDontSee('Budget to Delete') + ->assertNoJavascriptErrors(); + + $this->assertSoftDeleted('budgets', [ + 'id' => $budget->id, + ]); +}); + +test('budget creation validates required fields', function () { + $user = User::factory()->create(['onboarded_at' => now()]); + Feature::for($user)->activate('budgets'); + + $page = $this->actingAs($user)->visit('/budgets'); + $page->wait(2); // Wait for page to fully load + + $page->assertSee('Budgets') + ->waitForText('Create Budget', 10) + ->wait(1) // Extra wait before clicking + ->click('Create Budget') + ->wait(3) // Wait for dialog to open + ->assertSee('Create Budget') + ->wait(2) // Wait for form to be ready + ->assertAttribute('button[type="submit"]:has-text("Create Budget")', 'disabled', '') + ->assertNoJavascriptErrors(); +}); + +test('budget shows current period information', function () { + $user = User::factory()->create(['onboarded_at' => now()]); + Feature::for($user)->activate('budgets'); + + $category = Category::factory()->create(['user_id' => $user->id]); + $budget = Budget::factory()->create([ + 'user_id' => $user->id, + 'category_id' => $category->id, + ]); + + $page = $this->actingAs($user)->visit("/budgets/{$budget->id}"); + + $page->assertSee($budget->name) + ->assertSee('Tracking') + ->assertNoJavascriptErrors(); +}); + +test('user can navigate back to budgets list from budget detail', function () { + $user = User::factory()->create(['onboarded_at' => now()]); + Feature::for($user)->activate('budgets'); + + $category = Category::factory()->create(['user_id' => $user->id]); + $budget = Budget::factory()->create([ + 'user_id' => $user->id, + 'category_id' => $category->id, + ]); + + $page = $this->actingAs($user)->visit("/budgets/{$budget->id}"); + $page->wait(2); // Wait for page to fully load + + $page->assertSee($budget->name) + ->wait(2) + ->waitForText('Budgets', 10) + ->wait(1) // Extra wait before clicking + ->click('nav[aria-label="breadcrumb"] a:has-text("Budgets")') + ->wait(4) // Wait for navigation + ->assertPathIs('/budgets') + ->assertNoJavascriptErrors(); +}); diff --git a/tests/Browser/BudgetsFeatureNavigationTest.php b/tests/Browser/BudgetsFeatureNavigationTest.php new file mode 100644 index 00000000..bf8a958c --- /dev/null +++ b/tests/Browser/BudgetsFeatureNavigationTest.php @@ -0,0 +1,190 @@ +create(['onboarded_at' => now()]); + Feature::for($user)->deactivate('budgets'); + + $page = $this->actingAs($user)->visit('/dashboard'); + + $page->assertDontSee('Budgets') + ->assertNoJavascriptErrors(); +}); + +test('budgets menu item visible when feature enabled', function () { + $user = User::factory()->create(['onboarded_at' => now()]); + Feature::for($user)->activate('budgets'); + + $page = $this->actingAs($user)->visit('/dashboard'); + + $page->assertSee('Budgets') + ->assertNoJavascriptErrors(); +}); + +test('user cannot access budgets page when feature disabled', function () { + $user = User::factory()->create(['onboarded_at' => now()]); + Feature::for($user)->deactivate('budgets'); + + $response = $this->actingAs($user)->get('/budgets'); + + $response->assertNotFound(); +}); + +test('user can navigate to budgets index page', function () { + $user = User::factory()->create(['onboarded_at' => now()]); + Feature::for($user)->activate('budgets'); + + $page = $this->actingAs($user)->visit('/budgets'); + + $page->assertPathIs('/budgets') + ->assertSee('Budgets') + ->assertNoJavascriptErrors(); +}); + +test('user can view empty budgets list', function () { + $user = User::factory()->create(['onboarded_at' => now()]); + Feature::for($user)->activate('budgets'); + + $page = $this->actingAs($user)->visit('/budgets'); + + $page->assertSee('Budgets') + ->assertNoJavascriptErrors(); +}); + +test('user can view budgets list with existing budgets', function () { + $user = User::factory()->create(['onboarded_at' => now()]); + Feature::for($user)->activate('budgets'); + + $category = Category::factory()->create(['user_id' => $user->id]); + $budget = Budget::factory()->create([ + 'user_id' => $user->id, + 'category_id' => $category->id, + 'name' => 'Test Budget', + ]); + + $page = $this->actingAs($user)->visit('/budgets'); + + $page->assertSee('Budgets') + ->assertSee('Test Budget') + ->assertNoJavascriptErrors(); +}); + +test('user can open create budget dialog', function () { + $user = User::factory()->create(['onboarded_at' => now()]); + Feature::for($user)->activate('budgets'); + + $category = Category::factory()->create(['user_id' => $user->id]); + + $page = $this->actingAs($user)->visit('/budgets'); + + $page->assertSee('Budgets') + ->click('Create Budget') + ->wait(1) + ->assertSee('Create Budget') + ->assertNoJavascriptErrors(); +}); + +test('user can view a specific budget', function () { + $user = User::factory()->create(['onboarded_at' => now()]); + Feature::for($user)->activate('budgets'); + + $category = Category::factory()->create(['user_id' => $user->id]); + $budget = Budget::factory()->create([ + 'user_id' => $user->id, + 'category_id' => $category->id, + 'name' => 'My Monthly Budget', + ]); + + $page = $this->actingAs($user)->visit('/budgets'); + + $page->assertSee('My Monthly Budget') + ->click('View Details') + ->wait(2) + ->assertPathIs("/budgets/{$budget->id}") + ->assertSee('My Monthly Budget') + ->assertNoJavascriptErrors(); +}); + +test('user can open edit budget dialog', function () { + $user = User::factory()->create(['onboarded_at' => now()]); + Feature::for($user)->activate('budgets'); + + $category = Category::factory()->create(['user_id' => $user->id]); + $budget = Budget::factory()->create([ + 'user_id' => $user->id, + 'category_id' => $category->id, + 'name' => 'Original Name', + ]); + + $page = $this->actingAs($user)->visit("/budgets/{$budget->id}"); + + $page->assertSee('Original Name') + ->click('Edit budget') + ->wait(1) + ->assertSee('Edit Budget') + ->assertNoJavascriptErrors(); +}); + +test('user can open delete budget dialog', function () { + $user = User::factory()->create(['onboarded_at' => now()]); + Feature::for($user)->activate('budgets'); + + $category = Category::factory()->create(['user_id' => $user->id]); + $budget = Budget::factory()->create([ + 'user_id' => $user->id, + 'category_id' => $category->id, + 'name' => 'Budget to Delete', + ]); + + $page = $this->actingAs($user)->visit("/budgets/{$budget->id}"); + + $page->assertSee('Budget to Delete') + ->click('//button[@aria-label="More options"]') + ->wait(0.5) + ->click('Delete') + ->wait(1) + ->assertSee('Delete Budget') + ->assertNoJavascriptErrors(); +}); + +test('user cannot access another users budget', function () { + $user1 = User::factory()->create(['onboarded_at' => now()]); + $user2 = User::factory()->create(['onboarded_at' => now()]); + Feature::for($user1)->activate('budgets'); + Feature::for($user2)->activate('budgets'); + + $category = Category::factory()->create(['user_id' => $user1->id]); + $budget = Budget::factory()->create([ + 'user_id' => $user1->id, + 'category_id' => $category->id, + ]); + + $response = $this->actingAs($user2)->get("/budgets/{$budget->id}"); + + $response->assertForbidden(); +}); + +test('budgets navigation works from sidebar', function () { + $user = User::factory()->create(['onboarded_at' => now()]); + Feature::for($user)->activate('budgets'); + + $page = $this->actingAs($user)->visit('/dashboard'); + + $page->assertSee('Budgets') + ->assertNoJavascriptErrors(); +}); + +test('budgets page shows correct feature flag state', function () { + $user = User::factory()->create(['onboarded_at' => now()]); + Feature::for($user)->activate('budgets'); + + $page = $this->actingAs($user)->visit('/budgets'); + + $page->assertSee('Budgets') + ->assertNoJavascriptErrors() + ->assertNoConsoleLogs(); +}); diff --git a/tests/Feature/BudgetFeatureFlagTest.php b/tests/Feature/BudgetFeatureFlagTest.php new file mode 100644 index 00000000..eb7aaac3 --- /dev/null +++ b/tests/Feature/BudgetFeatureFlagTest.php @@ -0,0 +1,114 @@ +create(['onboarded_at' => now()]); + + Feature::for($user)->deactivate('budgets'); + + $response = $this->actingAs($user)->get('/budgets'); + + $response->assertNotFound(); +}); + +test('users with budgets feature can access budgets index', function () { + $user = User::factory()->create(['onboarded_at' => now()]); + + Feature::for($user)->activate('budgets'); + + $response = $this->actingAs($user)->get('/budgets'); + + $response->assertOk(); +}); + +test('users without budgets feature get 404 on budgets show', function () { + $user = User::factory()->create(['onboarded_at' => now()]); + $budget = Budget::factory()->create(['user_id' => $user->id]); + + Feature::for($user)->deactivate('budgets'); + + $response = $this->actingAs($user)->get("/budgets/{$budget->id}"); + + $response->assertNotFound(); +}); + +test('users without budgets feature get 404 on budgets store', function () { + $user = User::factory()->create(['onboarded_at' => now()]); + $category = Category::factory()->create(['user_id' => $user->id]); + + Feature::for($user)->deactivate('budgets'); + + $response = $this->actingAs($user)->post('/budgets', [ + 'name' => 'Test Budget', + 'period_type' => 'monthly', + 'period_start_day' => 1, + 'category_id' => $category->id, + 'rollover_type' => 'reset', + 'allocated_amount' => 100000, + ]); + + $response->assertNotFound(); +}); + +test('users without budgets feature get 404 on budgets update', function () { + $user = User::factory()->create(['onboarded_at' => now()]); + $budget = Budget::factory()->create(['user_id' => $user->id]); + + Feature::for($user)->deactivate('budgets'); + + $response = $this->actingAs($user)->patch("/budgets/{$budget->id}", [ + 'name' => 'Updated Budget', + ]); + + $response->assertNotFound(); +}); + +test('users without budgets feature get 404 on budgets destroy', function () { + $user = User::factory()->create(['onboarded_at' => now()]); + $budget = Budget::factory()->create(['user_id' => $user->id]); + + Feature::for($user)->deactivate('budgets'); + + $response = $this->actingAs($user)->delete("/budgets/{$budget->id}"); + + $response->assertNotFound(); +}); + +test('budgets feature flag is shared with frontend when enabled', function () { + $user = User::factory()->create(['onboarded_at' => now()]); + + Feature::for($user)->activate('budgets'); + + $response = $this->actingAs($user)->get('/dashboard'); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->where('features.budgets', true) + ); +}); + +test('budgets feature flag is shared with frontend when disabled', function () { + $user = User::factory()->create(['onboarded_at' => now()]); + + Feature::for($user)->deactivate('budgets'); + + $response = $this->actingAs($user)->get('/dashboard'); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->where('features.budgets', false) + ); +}); + +test('guests see budgets feature as false', function () { + $response = $this->get('/'); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->where('features.budgets', false) + ); +}); diff --git a/tests/Feature/BudgetTest.php b/tests/Feature/BudgetTest.php new file mode 100644 index 00000000..1e5ac37d --- /dev/null +++ b/tests/Feature/BudgetTest.php @@ -0,0 +1,140 @@ +create(['onboarded_at' => now()]); + Feature::for($user)->activate('budgets'); + + $category = Category::factory()->create(['user_id' => $user->id]); + + $response = $this->actingAs($user)->post('/budgets', [ + 'name' => 'Monthly Budget', + 'period_type' => 'monthly', + 'period_start_day' => 1, + 'category_id' => $category->id, + 'rollover_type' => 'reset', + 'allocated_amount' => 100000, + ]); + + $response->assertRedirect(); + + $this->assertDatabaseHas('budgets', [ + 'user_id' => $user->id, + 'name' => 'Monthly Budget', + 'period_type' => 'monthly', + 'category_id' => $category->id, + ]); + + $budget = Budget::where('user_id', $user->id)->first(); + $this->assertNotNull($budget); + $this->assertCount(1, $budget->periods); +}); + +test('user can view their budgets', function () { + $user = User::factory()->create(['onboarded_at' => now()]); + Feature::for($user)->activate('budgets'); + + $budget = Budget::factory()->create(['user_id' => $user->id]); + + $response = $this->actingAs($user)->get('/budgets'); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->component('budgets/index') + ->has('budgets', 1) + ); +}); + +test('user can view a specific budget', function () { + $user = User::factory()->create(['onboarded_at' => now()]); + Feature::for($user)->activate('budgets'); + + $category = Category::factory()->create(['user_id' => $user->id]); + $budget = Budget::factory()->create([ + 'user_id' => $user->id, + 'category_id' => $category->id, + ]); + + $response = $this->actingAs($user)->get("/budgets/{$budget->id}"); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->component('budgets/show') + ->has('budget') + ->has('currentPeriod') + ); +}); + +test('user cannot view another users budget', function () { + $user1 = User::factory()->create(['onboarded_at' => now()]); + $user2 = User::factory()->create(['onboarded_at' => now()]); + Feature::for($user1)->activate('budgets'); + Feature::for($user2)->activate('budgets'); + + $budget = Budget::factory()->create(['user_id' => $user1->id]); + + $response = $this->actingAs($user2)->get("/budgets/{$budget->id}"); + + $response->assertForbidden(); +}); + +test('user can update their budget', function () { + $user = User::factory()->create(['onboarded_at' => now()]); + Feature::for($user)->activate('budgets'); + + $budget = Budget::factory()->create(['user_id' => $user->id]); + + $response = $this->actingAs($user)->patch("/budgets/{$budget->id}", [ + 'name' => 'Updated Budget Name', + ]); + + $response->assertRedirect(); + + $this->assertDatabaseHas('budgets', [ + 'id' => $budget->id, + 'name' => 'Updated Budget Name', + ]); +}); + +test('user can delete their budget', function () { + $user = User::factory()->create(['onboarded_at' => now()]); + Feature::for($user)->activate('budgets'); + + $budget = Budget::factory()->create(['user_id' => $user->id]); + + $response = $this->actingAs($user)->delete("/budgets/{$budget->id}"); + + $response->assertRedirect(); + + $this->assertSoftDeleted('budgets', [ + 'id' => $budget->id, + ]); +}); + +test('budget period is automatically generated', function () { + $user = User::factory()->create(['onboarded_at' => now()]); + Feature::for($user)->activate('budgets'); + + $category = Category::factory()->create(['user_id' => $user->id]); + + $this->actingAs($user)->post('/budgets', [ + 'name' => 'Test Budget', + 'period_type' => 'monthly', + 'period_start_day' => 1, + 'category_id' => $category->id, + 'rollover_type' => 'reset', + 'allocated_amount' => 50000, + ]); + + $budget = Budget::where('user_id', $user->id)->first(); + $this->assertNotNull($budget); + $this->assertCount(1, $budget->periods); + + $period = $budget->periods->first(); + $this->assertNotNull($period->start_date); + $this->assertNotNull($period->end_date); +});