diff --git a/app/Http/Controllers/BudgetController.php b/app/Http/Controllers/BudgetController.php index 08858f7d..0b82f35f 100644 --- a/app/Http/Controllers/BudgetController.php +++ b/app/Http/Controllers/BudgetController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; use App\Http\Requests\StoreBudgetRequest; use App\Http\Requests\UpdateBudgetRequest; +use App\Jobs\AssignHistoricalTransactionsToBudget; use App\Models\Budget; use App\Services\BudgetPeriodService; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -87,7 +88,7 @@ class BudgetController extends Controller public function store(StoreBudgetRequest $request): \Illuminate\Http\RedirectResponse { - $budget = DB::transaction(function () use ($request) { + $result = DB::transaction(function () use ($request) { $budget = $request->user()->budgets()->create([ 'name' => $request->name, 'period_type' => $request->period_type, @@ -98,12 +99,15 @@ class BudgetController extends Controller 'rollover_type' => $request->rollover_type, ]); - $period = $this->budgetPeriodService->generatePeriod($budget, $request->allocated_amount); + $period = $this->budgetPeriodService->generatePeriod($budget, $request->allocated_amount, null, true); - return $budget; + return ['budget' => $budget, 'period' => $period]; }); - return redirect()->route('budgets.show', $budget); + // Dispatch job to assign historical transactions + AssignHistoricalTransactionsToBudget::dispatch($result['budget'], $result['period']); + + return redirect()->route('budgets.show', $result['budget']); } public function update(UpdateBudgetRequest $request, Budget $budget): \Illuminate\Http\RedirectResponse diff --git a/app/Jobs/AssignHistoricalTransactionsToBudget.php b/app/Jobs/AssignHistoricalTransactionsToBudget.php new file mode 100644 index 00000000..568eecad --- /dev/null +++ b/app/Jobs/AssignHistoricalTransactionsToBudget.php @@ -0,0 +1,58 @@ + $this->budget->id, + 'budget_period_id' => $this->period->id, + 'period_start' => $this->period->start_date, + 'period_end' => $this->period->end_date, + 'user_id' => $this->budget->user_id, + ]); + + $count = $service->assignHistoricalTransactionsToPeriod($this->period); + + // Mark processing as complete + $this->period->update(['processing_historical' => false]); + + Log::info("Assigned {$count} historical transactions to budget period", [ + 'budget_id' => $this->budget->id, + 'budget_period_id' => $this->period->id, + 'user_id' => $this->budget->user_id, + ]); + } +} diff --git a/app/Models/BudgetPeriod.php b/app/Models/BudgetPeriod.php index 4b423b89..0e680ed0 100644 --- a/app/Models/BudgetPeriod.php +++ b/app/Models/BudgetPeriod.php @@ -18,6 +18,7 @@ class BudgetPeriod extends Model 'end_date', 'allocated_amount', 'carried_over_amount', + 'processing_historical', ]; protected function casts(): array @@ -27,6 +28,7 @@ class BudgetPeriod extends Model 'end_date' => 'date', 'allocated_amount' => 'integer', 'carried_over_amount' => 'integer', + 'processing_historical' => 'boolean', ]; } diff --git a/app/Services/BudgetPeriodService.php b/app/Services/BudgetPeriodService.php index 680cbd46..589a5cb6 100644 --- a/app/Services/BudgetPeriodService.php +++ b/app/Services/BudgetPeriodService.php @@ -9,7 +9,7 @@ use Carbon\Carbon; class BudgetPeriodService { - public function generatePeriod(Budget $budget, ?int $allocatedAmount = null, ?Carbon $startDate = null): BudgetPeriod + public function generatePeriod(Budget $budget, ?int $allocatedAmount = null, ?Carbon $startDate = null, bool $processHistorical = false): BudgetPeriod { if ($startDate === null) { $startDate = $this->calculateNextPeriodStartDate($budget); @@ -29,6 +29,7 @@ class BudgetPeriodService 'end_date' => $periodEnd, 'allocated_amount' => $allocatedAmount, 'carried_over_amount' => 0, + 'processing_historical' => $processHistorical, ]); } diff --git a/app/Services/BudgetTransactionService.php b/app/Services/BudgetTransactionService.php index fc4b577d..cc117e1b 100644 --- a/app/Services/BudgetTransactionService.php +++ b/app/Services/BudgetTransactionService.php @@ -5,6 +5,7 @@ namespace App\Services; use App\Models\BudgetPeriod; use App\Models\BudgetTransaction; use App\Models\Transaction; +use Illuminate\Support\Facades\Log; class BudgetTransactionService { @@ -71,4 +72,68 @@ class BudgetTransactionService { BudgetTransaction::where('transaction_id', $transaction->id)->delete(); } + + public function assignHistoricalTransactionsToPeriod(BudgetPeriod $period): int + { + // Load the budget with its relationships + $budget = $period->budget()->with(['category', 'label'])->first(); + + if (! $budget) { + return 0; + } + + $assignedCount = 0; + + Log::info('Building query for historical transactions', [ + 'user_id' => $budget->user_id, + 'category_id' => $budget->category_id, + 'label_id' => $budget->label_id, + 'start_date' => $period->start_date->toDateString(), + 'end_date' => $period->end_date->toDateString(), + ]); + + // Build the query for matching transactions + $query = Transaction::query() + ->where('user_id', $budget->user_id) + ->whereBetween('transaction_date', [$period->start_date, $period->end_date]) + ->withoutTrashed(); + + // Filter by category OR label + $query->where(function ($q) use ($budget) { + if ($budget->category_id) { + $q->where('category_id', $budget->category_id); + } + + if ($budget->label_id) { + $q->orWhereHas('labels', function ($labelQuery) use ($budget) { + $labelQuery->where('labels.id', $budget->label_id); + }); + } + }); + + $totalCount = $query->count(); + Log::info("Found {$totalCount} transactions to process in date range"); + + // Process in chunks to prevent memory issues + $query->chunk(500, function ($transactions) use ($period, &$assignedCount) { + foreach ($transactions as $transaction) { + // Check if assignment already exists (prevent duplicates) + $exists = BudgetTransaction::where('transaction_id', $transaction->id) + ->where('budget_period_id', $period->id) + ->exists(); + + if (! $exists) { + BudgetTransaction::create([ + 'transaction_id' => $transaction->id, + 'budget_period_id' => $period->id, + 'amount' => abs($transaction->amount), + ]); + + $assignedCount++; + } + } + }); + + return $assignedCount; + } } diff --git a/database/migrations/2026_01_21_151353_add_indexes_for_budget_transaction_assignment.php b/database/migrations/2026_01_21_151353_add_indexes_for_budget_transaction_assignment.php new file mode 100644 index 00000000..3e7ccc86 --- /dev/null +++ b/database/migrations/2026_01_21_151353_add_indexes_for_budget_transaction_assignment.php @@ -0,0 +1,38 @@ +index(['user_id', 'transaction_date', 'category_id'], 'idx_transactions_budget_lookup'); + }); + + // Add unique constraint on budget_transactions to prevent duplicates + Schema::table('budget_transactions', function (Blueprint $table) { + $table->unique(['transaction_id', 'budget_period_id'], 'uq_budget_transaction_period'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('transactions', function (Blueprint $table) { + $table->dropIndex('idx_transactions_budget_lookup'); + }); + + Schema::table('budget_transactions', function (Blueprint $table) { + $table->dropUnique('uq_budget_transaction_period'); + }); + } +}; diff --git a/database/migrations/2026_01_22_071325_add_processing_historical_to_budget_periods.php b/database/migrations/2026_01_22_071325_add_processing_historical_to_budget_periods.php new file mode 100644 index 00000000..db76d406 --- /dev/null +++ b/database/migrations/2026_01_22_071325_add_processing_historical_to_budget_periods.php @@ -0,0 +1,28 @@ +boolean('processing_historical')->default(false)->after('allocated_amount'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('budget_periods', function (Blueprint $table) { + $table->dropColumn('processing_historical'); + }); + } +}; diff --git a/resources/js/pages/budgets/show.tsx b/resources/js/pages/budgets/show.tsx index 44b06195..35f1d61c 100644 --- a/resources/js/pages/budgets/show.tsx +++ b/resources/js/pages/budgets/show.tsx @@ -12,14 +12,15 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import { Skeleton } from '@/components/ui/skeleton'; 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'; +import { Head, router } from '@inertiajs/react'; +import { ChevronDown, Loader2 } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; interface Props { budget: Budget; @@ -41,6 +42,22 @@ export default function BudgetShow({ const [editOpen, setEditOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); + // Poll for updates when processing historical transactions + useEffect(() => { + if (!currentPeriod.processing_historical) { + return; + } + + const interval = setInterval(() => { + router.reload({ + only: ['currentPeriod'], + preserveScroll: true, + }); + }, 3000); // Poll every 3 seconds + + return () => clearInterval(interval); + }, [currentPeriod.processing_historical]); + const breadcrumbs: BreadcrumbItem[] = [ { title: 'Budgets', @@ -154,15 +171,39 @@ export default function BudgetShow({ currencyCode={currencyCode} /> - + {currentPeriod.processing_historical ? ( +
+
+ +
+

+ Finding historical transactions +

+

+ We're looking through your transaction + history to find expenses that match this + budget. This usually takes a few seconds. +

+
+
+ +
+ + + +
+
+ ) : ( + + )} user = User::factory()->create(['onboarded_at' => now()]); + Feature::for($this->user)->activate('budgets'); +}); + +test('budget creation dispatches the historical assignment job', function () { + Queue::fake(); + + $category = Category::factory()->create(['user_id' => $this->user->id]); + + $this->actingAs($this->user)->post('/budgets', [ + 'name' => 'Test Budget', + 'period_type' => 'monthly', + 'period_start_day' => 1, + 'category_id' => $category->id, + 'rollover_type' => 'reset', + 'allocated_amount' => 100000, + ]); + + Queue::assertPushed(AssignHistoricalTransactionsToBudget::class); +}); + +test('historical transactions matching by category are assigned', function () { + $category = Category::factory()->create(['user_id' => $this->user->id]); + + // Create historical transactions + $transaction1 = Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'category_id' => $category->id, + 'transaction_date' => now()->subDays(10), + 'amount' => -5000, + ]); + + $transaction2 = Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'category_id' => $category->id, + 'transaction_date' => now()->subDays(5), + 'amount' => -3000, + ]); + + // Create budget - this should assign the historical transactions + $response = $this->actingAs($this->user)->post('/budgets', [ + 'name' => 'Category Budget', + 'period_type' => 'monthly', + 'period_start_day' => 1, + 'category_id' => $category->id, + 'rollover_type' => 'reset', + 'allocated_amount' => 100000, + ]); + + $response->assertRedirect(); + + // Process the queued job + $this->artisan('queue:work --once'); + + // Verify assignments were created + assertDatabaseHas('budget_transactions', [ + 'transaction_id' => $transaction1->id, + ]); + + assertDatabaseHas('budget_transactions', [ + 'transaction_id' => $transaction2->id, + ]); +}); + +test('historical transactions matching by label are assigned', function () { + $label = Label::factory()->create(['user_id' => $this->user->id]); + + // Create historical transaction with label + $transaction = Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'transaction_date' => now()->subDays(7), + 'amount' => -2500, + ]); + + $transaction->labels()->attach($label->id); + + // Create budget with label + $response = $this->actingAs($this->user)->post('/budgets', [ + 'name' => 'Label Budget', + 'period_type' => 'monthly', + 'period_start_day' => 1, + 'label_id' => $label->id, + 'rollover_type' => 'reset', + 'allocated_amount' => 100000, + ]); + + $response->assertRedirect(); + + // Process the queued job + $this->artisan('queue:work --once'); + + // Verify assignment was created + assertDatabaseHas('budget_transactions', [ + 'transaction_id' => $transaction->id, + ]); +}); + +test('transactions outside the period date range are not assigned', function () { + $category = Category::factory()->create(['user_id' => $this->user->id]); + + // Create transaction outside current period (way in the past) + $oldTransaction = Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'category_id' => $category->id, + 'transaction_date' => now()->subMonths(6), + 'amount' => -5000, + ]); + + // Create transaction in current period + $currentTransaction = Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'category_id' => $category->id, + 'transaction_date' => now()->subDays(5), + 'amount' => -3000, + ]); + + // Create budget + $response = $this->actingAs($this->user)->post('/budgets', [ + 'name' => 'Date Range Budget', + 'period_type' => 'monthly', + 'period_start_day' => 1, + 'category_id' => $category->id, + 'rollover_type' => 'reset', + 'allocated_amount' => 100000, + ]); + + $response->assertRedirect(); + + // Process the queued job + $this->artisan('queue:work --once'); + + // Verify only current transaction was assigned + assertDatabaseHas('budget_transactions', [ + 'transaction_id' => $currentTransaction->id, + ]); + + // Old transaction should NOT be assigned + $this->assertDatabaseMissing('budget_transactions', [ + 'transaction_id' => $oldTransaction->id, + ]); +}); + +test('transactions on boundary dates are assigned', function () { + $category = Category::factory()->create(['user_id' => $this->user->id]); + + // Determine current period boundaries for a monthly budget starting on day 1 + $startDate = now()->startOfMonth(); + $endDate = now()->endOfMonth(); + + // Create transactions on exact boundary dates + $startTransaction = Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'category_id' => $category->id, + 'transaction_date' => $startDate, + 'amount' => -1000, + ]); + + $endTransaction = Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'category_id' => $category->id, + 'transaction_date' => $endDate, + 'amount' => -2000, + ]); + + // Create budget + $response = $this->actingAs($this->user)->post('/budgets', [ + 'name' => 'Boundary Budget', + 'period_type' => 'monthly', + 'period_start_day' => 1, + 'category_id' => $category->id, + 'rollover_type' => 'reset', + 'allocated_amount' => 100000, + ]); + + $response->assertRedirect(); + + // Process the queued job + $this->artisan('queue:work --once'); + + // Both boundary transactions should be assigned + assertDatabaseHas('budget_transactions', [ + 'transaction_id' => $startTransaction->id, + ]); + + assertDatabaseHas('budget_transactions', [ + 'transaction_id' => $endTransaction->id, + ]); +}); + +test('soft deleted transactions are not assigned', function () { + $category = Category::factory()->create(['user_id' => $this->user->id]); + + // Create and soft delete a transaction + $deletedTransaction = Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'category_id' => $category->id, + 'transaction_date' => now()->subDays(3), + 'amount' => -5000, + ]); + + $deletedTransaction->delete(); + + // Create budget + $response = $this->actingAs($this->user)->post('/budgets', [ + 'name' => 'Soft Delete Budget', + 'period_type' => 'monthly', + 'period_start_day' => 1, + 'category_id' => $category->id, + 'rollover_type' => 'reset', + 'allocated_amount' => 100000, + ]); + + $response->assertRedirect(); + + // Process the queued job + $this->artisan('queue:work --once'); + + // Soft deleted transaction should NOT be assigned + $this->assertDatabaseMissing('budget_transactions', [ + 'transaction_id' => $deletedTransaction->id, + ]); +}); + +test('duplicate assignments are prevented', function () { + $category = Category::factory()->create(['user_id' => $this->user->id]); + + $transaction = Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'category_id' => $category->id, + 'transaction_date' => now()->subDays(3), + 'amount' => -5000, + ]); + + // Create budget and process job + $response = $this->actingAs($this->user)->post('/budgets', [ + 'name' => 'Duplicate Budget', + 'period_type' => 'monthly', + 'period_start_day' => 1, + 'category_id' => $category->id, + 'rollover_type' => 'reset', + 'allocated_amount' => 100000, + ]); + + $response->assertRedirect(); + $this->artisan('queue:work --once'); + + // Get the budget and period + $budget = $this->user->budgets()->first(); + $period = $budget->periods()->first(); + + // Count initial assignments + $initialCount = BudgetTransaction::where('transaction_id', $transaction->id) + ->where('budget_period_id', $period->id) + ->count(); + + expect($initialCount)->toBe(1); + + // Try to dispatch the job again manually + AssignHistoricalTransactionsToBudget::dispatch($budget, $period); + $this->artisan('queue:work --once'); + + // Count should still be 1 (no duplicates) + $finalCount = BudgetTransaction::where('transaction_id', $transaction->id) + ->where('budget_period_id', $period->id) + ->count(); + + expect($finalCount)->toBe(1); +}); + +test('multiple budgets assign independently', function () { + $category1 = Category::factory()->create(['user_id' => $this->user->id]); + $category2 = Category::factory()->create(['user_id' => $this->user->id]); + + // Create transactions for each category + $transaction1 = Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'category_id' => $category1->id, + 'transaction_date' => now()->subDays(5), + 'amount' => -3000, + ]); + + $transaction2 = Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'category_id' => $category2->id, + 'transaction_date' => now()->subDays(4), + 'amount' => -2000, + ]); + + // Create first budget + $this->actingAs($this->user)->post('/budgets', [ + 'name' => 'Budget 1', + 'period_type' => 'monthly', + 'period_start_day' => 1, + 'category_id' => $category1->id, + 'rollover_type' => 'reset', + 'allocated_amount' => 100000, + ]); + + // Create second budget + $this->actingAs($this->user)->post('/budgets', [ + 'name' => 'Budget 2', + 'period_type' => 'monthly', + 'period_start_day' => 1, + 'category_id' => $category2->id, + 'rollover_type' => 'reset', + 'allocated_amount' => 100000, + ]); + + // Process both jobs + $this->artisan('queue:work --stop-when-empty'); + + // Verify each transaction is only assigned to its matching budget + $budget1 = $this->user->budgets()->where('name', 'Budget 1')->first(); + $budget2 = $this->user->budgets()->where('name', 'Budget 2')->first(); + + $period1 = $budget1->periods()->first(); + $period2 = $budget2->periods()->first(); + + // Transaction 1 should only be in budget 1 + assertDatabaseHas('budget_transactions', [ + 'transaction_id' => $transaction1->id, + 'budget_period_id' => $period1->id, + ]); + + $this->assertDatabaseMissing('budget_transactions', [ + 'transaction_id' => $transaction1->id, + 'budget_period_id' => $period2->id, + ]); + + // Transaction 2 should only be in budget 2 + assertDatabaseHas('budget_transactions', [ + 'transaction_id' => $transaction2->id, + 'budget_period_id' => $period2->id, + ]); + + $this->assertDatabaseMissing('budget_transactions', [ + 'transaction_id' => $transaction2->id, + 'budget_period_id' => $period1->id, + ]); +}); diff --git a/tests/Feature/BudgetTransactionServiceTest.php b/tests/Feature/BudgetTransactionServiceTest.php new file mode 100644 index 00000000..d69dc9eb --- /dev/null +++ b/tests/Feature/BudgetTransactionServiceTest.php @@ -0,0 +1,308 @@ +service = app(BudgetTransactionService::class); + $this->user = User::factory()->create(); +}); + +test('assignHistoricalTransactionsToPeriod returns correct count', function () { + $category = Category::factory()->create(['user_id' => $this->user->id]); + + // Create 5 historical transactions + for ($i = 0; $i < 5; $i++) { + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'category_id' => $category->id, + 'transaction_date' => now()->subDays($i + 1), + 'amount' => -1000, + ]); + } + + $budget = Budget::factory()->create([ + 'user_id' => $this->user->id, + 'category_id' => $category->id, + ]); + + $period = BudgetPeriod::factory()->create([ + 'budget_id' => $budget->id, + 'start_date' => now()->subDays(30), + 'end_date' => now()->addDays(30), + ]); + + $count = $this->service->assignHistoricalTransactionsToPeriod($period); + + expect($count)->toBe(5); +}); + +test('assignHistoricalTransactionsToPeriod handles empty results', function () { + $category = Category::factory()->create(['user_id' => $this->user->id]); + + $budget = Budget::factory()->create([ + 'user_id' => $this->user->id, + 'category_id' => $category->id, + ]); + + $period = BudgetPeriod::factory()->create([ + 'budget_id' => $budget->id, + 'start_date' => now()->subDays(30), + 'end_date' => now()->addDays(30), + ]); + + // No transactions created + $count = $this->service->assignHistoricalTransactionsToPeriod($period); + + expect($count)->toBe(0); +}); + +test('assignHistoricalTransactionsToPeriod processes large batches', function () { + $category = Category::factory()->create(['user_id' => $this->user->id]); + + // Create 1000 historical transactions + $transactions = collect(); + for ($i = 0; $i < 1000; $i++) { + $transactions->push([ + 'user_id' => $this->user->id, + 'category_id' => $category->id, + 'transaction_date' => now()->subDays(rand(1, 25)), + 'amount' => -rand(100, 10000), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + // Insert in batches + Transaction::insert($transactions->toArray()); + + $budget = Budget::factory()->create([ + 'user_id' => $this->user->id, + 'category_id' => $category->id, + ]); + + $period = BudgetPeriod::factory()->create([ + 'budget_id' => $budget->id, + 'start_date' => now()->subDays(30), + 'end_date' => now()->addDays(30), + ]); + + $count = $this->service->assignHistoricalTransactionsToPeriod($period); + + expect($count)->toBe(1000); +})->skip('Run only when testing performance with large datasets'); + +test('assignHistoricalTransactionsToPeriod excludes transactions outside date range', function () { + $category = Category::factory()->create(['user_id' => $this->user->id]); + + // Create transactions outside period + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'category_id' => $category->id, + 'transaction_date' => now()->subMonths(6), + 'amount' => -1000, + ]); + + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'category_id' => $category->id, + 'transaction_date' => now()->addMonths(6), + 'amount' => -1000, + ]); + + // Create transaction inside period + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'category_id' => $category->id, + 'transaction_date' => now()->subDays(5), + 'amount' => -1000, + ]); + + $budget = Budget::factory()->create([ + 'user_id' => $this->user->id, + 'category_id' => $category->id, + ]); + + $period = BudgetPeriod::factory()->create([ + 'budget_id' => $budget->id, + 'start_date' => now()->subDays(30), + 'end_date' => now()->addDays(30), + ]); + + $count = $this->service->assignHistoricalTransactionsToPeriod($period); + + expect($count)->toBe(1); +}); + +test('assignHistoricalTransactionsToPeriod works with category-based budgets', function () { + $category = Category::factory()->create(['user_id' => $this->user->id]); + $otherCategory = Category::factory()->create(['user_id' => $this->user->id]); + + // Create transaction with matching category + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'category_id' => $category->id, + 'transaction_date' => now()->subDays(5), + 'amount' => -1000, + ]); + + // Create transaction with non-matching category + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'category_id' => $otherCategory->id, + 'transaction_date' => now()->subDays(5), + 'amount' => -1000, + ]); + + $budget = Budget::factory()->create([ + 'user_id' => $this->user->id, + 'category_id' => $category->id, + ]); + + $period = BudgetPeriod::factory()->create([ + 'budget_id' => $budget->id, + 'start_date' => now()->subDays(30), + 'end_date' => now()->addDays(30), + ]); + + $count = $this->service->assignHistoricalTransactionsToPeriod($period); + + expect($count)->toBe(1); +}); + +test('assignHistoricalTransactionsToPeriod works with label-based budgets', function () { + $label = Label::factory()->create(['user_id' => $this->user->id]); + $otherLabel = Label::factory()->create(['user_id' => $this->user->id]); + + // Create transaction with matching label + $transaction1 = Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'transaction_date' => now()->subDays(5), + 'amount' => -1000, + ]); + $transaction1->labels()->attach($label->id); + + // Create transaction with non-matching label + $transaction2 = Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'transaction_date' => now()->subDays(5), + 'amount' => -1000, + ]); + $transaction2->labels()->attach($otherLabel->id); + + $budget = Budget::factory()->create([ + 'user_id' => $this->user->id, + 'label_id' => $label->id, + ]); + + $period = BudgetPeriod::factory()->create([ + 'budget_id' => $budget->id, + 'start_date' => now()->subDays(30), + 'end_date' => now()->addDays(30), + ]); + + $count = $this->service->assignHistoricalTransactionsToPeriod($period); + + expect($count)->toBe(1); +}); + +test('assignHistoricalTransactionsToPeriod works with transactions having multiple labels', function () { + $targetLabel = Label::factory()->create(['user_id' => $this->user->id]); + $otherLabel1 = Label::factory()->create(['user_id' => $this->user->id]); + $otherLabel2 = Label::factory()->create(['user_id' => $this->user->id]); + + // Create transaction with multiple labels, including the target one + $transaction = Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'transaction_date' => now()->subDays(5), + 'amount' => -1000, + ]); + $transaction->labels()->attach([$targetLabel->id, $otherLabel1->id, $otherLabel2->id]); + + $budget = Budget::factory()->create([ + 'user_id' => $this->user->id, + 'label_id' => $targetLabel->id, + ]); + + $period = BudgetPeriod::factory()->create([ + 'budget_id' => $budget->id, + 'start_date' => now()->subDays(30), + 'end_date' => now()->addDays(30), + ]); + + $count = $this->service->assignHistoricalTransactionsToPeriod($period); + + expect($count)->toBe(1); +}); + +test('assignHistoricalTransactionsToPeriod stores absolute value of amount', function () { + $category = Category::factory()->create(['user_id' => $this->user->id]); + + $transaction = Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'category_id' => $category->id, + 'transaction_date' => now()->subDays(5), + 'amount' => -5000, // Negative amount + ]); + + $budget = Budget::factory()->create([ + 'user_id' => $this->user->id, + 'category_id' => $category->id, + ]); + + $period = BudgetPeriod::factory()->create([ + 'budget_id' => $budget->id, + 'start_date' => now()->subDays(30), + 'end_date' => now()->addDays(30), + ]); + + $this->service->assignHistoricalTransactionsToPeriod($period); + + $budgetTransaction = $period->budgetTransactions()->first(); + + expect($budgetTransaction->amount)->toBe(5000); // Should be positive +}); + +test('assignHistoricalTransactionsToPeriod only assigns to correct user', function () { + $user1 = User::factory()->create(); + $user2 = User::factory()->create(); + + $category = Category::factory()->create(['user_id' => $user1->id]); + + // Create transaction for user2 + Transaction::factory()->create([ + 'user_id' => $user2->id, + 'category_id' => $category->id, + 'transaction_date' => now()->subDays(5), + 'amount' => -1000, + ]); + + // Create transaction for user1 + Transaction::factory()->create([ + 'user_id' => $user1->id, + 'category_id' => $category->id, + 'transaction_date' => now()->subDays(5), + 'amount' => -1000, + ]); + + $budget = Budget::factory()->create([ + 'user_id' => $user1->id, + 'category_id' => $category->id, + ]); + + $period = BudgetPeriod::factory()->create([ + 'budget_id' => $budget->id, + 'start_date' => now()->subDays(30), + 'end_date' => now()->addDays(30), + ]); + + $count = $this->service->assignHistoricalTransactionsToPeriod($period); + + // Should only assign user1's transaction + expect($count)->toBe(1); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php index e684b6e7..749504ca 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -39,7 +39,7 @@ abstract class TestCase extends BaseTestCase } if ($reload) { $currentUrl = $page->url(); - $page->navigate($currentUrl)->wait(0.5); + $page->navigate($currentUrl)->wait(1); } }