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); +});