feat: Load transactions history on budget created (#72)
This commit is contained in:
parent
839e4993df
commit
fee7ad36ab
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue