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