feat: Load transactions history on budget created (#72)

This commit is contained in:
Víctor Falcón 2026-01-22 11:10:15 +01:00 committed by GitHub
parent 839e4993df
commit fee7ad36ab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 918 additions and 18 deletions

View File

@ -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

View File

@ -0,0 +1,58 @@
<?php
namespace App\Jobs;
use App\Models\Budget;
use App\Models\BudgetPeriod;
use App\Services\BudgetTransactionService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
class AssignHistoricalTransactionsToBudget implements ShouldQueue
{
use Queueable;
/**
* The number of times the job may be attempted.
*/
public int $tries = 3;
/**
* The number of seconds to wait before retrying the job.
*/
public int $backoff = 10;
/**
* Create a new job instance.
*/
public function __construct(
public Budget $budget,
public BudgetPeriod $period
) {}
/**
* Execute the job.
*/
public function handle(BudgetTransactionService $service): void
{
Log::info('Starting historical transaction assignment', [
'budget_id' => $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,
]);
}
}

View File

@ -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',
];
}

View File

@ -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,
]);
}

View File

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

View File

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Add composite index on transactions to speed up historical query
Schema::table('transactions', function (Blueprint $table) {
$table->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');
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('budget_periods', function (Blueprint $table) {
$table->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');
});
}
};

View File

@ -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}
/>
<TransactionList
categories={categories}
accounts={accounts}
banks={banks}
transactions={periodTransactions}
pageSize={10}
showActionsMenu={false}
maxHeight={600}
/>
{currentPeriod.processing_historical ? (
<div className="space-y-4 rounded-lg border border-border bg-card p-6">
<div className="flex items-center gap-3">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<div>
<h3 className="text-sm font-medium">
Finding historical transactions
</h3>
<p className="text-sm text-muted-foreground">
We're looking through your transaction
history to find expenses that match this
budget. This usually takes a few seconds.
</p>
</div>
</div>
<div className="space-y-3">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
</div>
) : (
<TransactionList
categories={categories}
accounts={accounts}
banks={banks}
transactions={periodTransactions}
pageSize={10}
showActionsMenu={false}
maxHeight={600}
/>
)}
</div>
<EditBudgetDialog

View File

@ -41,6 +41,7 @@ export interface BudgetPeriod {
end_date: string;
allocated_amount: number;
carried_over_amount: number;
processing_historical: boolean;
created_at: string;
updated_at: string;
budget_transactions?: BudgetTransaction[];

View File

@ -0,0 +1,354 @@
<?php
use App\Jobs\AssignHistoricalTransactionsToBudget;
use App\Models\BudgetTransaction;
use App\Models\Category;
use App\Models\Label;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Support\Facades\Queue;
use Laravel\Pennant\Feature;
use function Pest\Laravel\assertDatabaseHas;
beforeEach(function () {
$this->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,
]);
});

View File

@ -0,0 +1,308 @@
<?php
use App\Models\Budget;
use App\Models\BudgetPeriod;
use App\Models\Category;
use App\Models\Label;
use App\Models\Transaction;
use App\Models\User;
use App\Services\BudgetTransactionService;
beforeEach(function () {
$this->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);
});

View File

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