diff --git a/app/Services/BudgetTransactionService.php b/app/Services/BudgetTransactionService.php index a30a5da8..9afd1f37 100644 --- a/app/Services/BudgetTransactionService.php +++ b/app/Services/BudgetTransactionService.php @@ -56,6 +56,11 @@ class BudgetTransactionService // Apply changes atomically so concurrent workers cannot leave the // transaction half-assigned and the unique index guards duplicates. DB::transaction(function () use ($transaction, $matchingPeriodIds) { + Transaction::query() + ->whereKey($transaction->id) + ->lockForUpdate() + ->first(); + BudgetTransaction::query() ->where('transaction_id', $transaction->id) ->when( @@ -75,7 +80,7 @@ class BudgetTransactionService ], ); } - }); + }, attempts: 5); } public function unassignTransaction(Transaction $transaction): void diff --git a/tests/Feature/BudgetTransactionServiceTest.php b/tests/Feature/BudgetTransactionServiceTest.php index 3ec0eefa..dea4af9d 100644 --- a/tests/Feature/BudgetTransactionServiceTest.php +++ b/tests/Feature/BudgetTransactionServiceTest.php @@ -8,6 +8,7 @@ use App\Models\Label; use App\Models\Transaction; use App\Models\User; use App\Services\BudgetTransactionService; +use Illuminate\Support\Facades\DB; beforeEach(function () { $this->service = app(BudgetTransactionService::class); @@ -430,6 +431,40 @@ test('assignTransaction is idempotent when called twice (regression for PHP-LARA ->and((int) $period->budgetTransactions()->first()->amount)->toBe(1500); }); +test('assignTransaction retries deadlocks during reconciliation (regression for PHP-LARAVEL-D)', 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), + ]); + + $transaction = Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'category_id' => $category->id, + 'transaction_date' => now()->subDays(2), + 'amount' => -1500, + ]); + + $databaseManager = DB::getFacadeRoot(); + + DB::shouldReceive('transaction') + ->once() + ->withArgs(fn ($callback, $attempts) => $callback instanceof Closure && $attempts === 5) + ->andReturnUsing(fn ($callback, $attempts) => $databaseManager->transaction($callback, $attempts)); + + $this->service->assignTransaction($transaction); + + expect($period->budgetTransactions()->count())->toBe(1) + ->and((int) $period->budgetTransactions()->first()->amount)->toBe(1500); +}); + test('assignTransaction removes stale rows when category changes', function () { $oldCategory = Category::factory()->create(['user_id' => $this->user->id]); $newCategory = Category::factory()->create(['user_id' => $this->user->id]);