diff --git a/app/Http/Controllers/Settings/CategoryController.php b/app/Http/Controllers/Settings/CategoryController.php index bf6c58af..2bba4fbf 100644 --- a/app/Http/Controllers/Settings/CategoryController.php +++ b/app/Http/Controllers/Settings/CategoryController.php @@ -76,7 +76,8 @@ class CategoryController extends Controller private function throwDuplicateCategoryNameValidationException(UniqueConstraintViolationException $exception): never { - if (! str_contains($exception->getMessage(), 'categories_user_id_name_unique')) { + if (! str_contains($exception->getMessage(), 'categories_user_id_name_unique') + && ! str_contains($exception->getMessage(), 'categories_user_id_name_active_unique')) { throw $exception; } diff --git a/app/Http/Requests/Settings/StoreCategoryRequest.php b/app/Http/Requests/Settings/StoreCategoryRequest.php index f87bb99e..358b6801 100644 --- a/app/Http/Requests/Settings/StoreCategoryRequest.php +++ b/app/Http/Requests/Settings/StoreCategoryRequest.php @@ -47,7 +47,8 @@ class StoreCategoryRequest extends FormRequest 'string', 'max:255', Rule::unique('categories', 'name') - ->where('user_id', auth()->id()), + ->where('user_id', auth()->id()) + ->withoutTrashed(), ], 'icon' => ['required', 'string'], 'color' => [ diff --git a/app/Http/Requests/Settings/UpdateCategoryRequest.php b/app/Http/Requests/Settings/UpdateCategoryRequest.php index 0ecc21a5..f0964b3d 100644 --- a/app/Http/Requests/Settings/UpdateCategoryRequest.php +++ b/app/Http/Requests/Settings/UpdateCategoryRequest.php @@ -48,6 +48,7 @@ class UpdateCategoryRequest extends FormRequest 'max:255', Rule::unique('categories', 'name') ->where('user_id', auth()->id()) + ->withoutTrashed() ->ignore($this->route('category')), ], 'icon' => ['required', 'string'], diff --git a/database/migrations/2026_05_28_073203_update_categories_unique_constraint_for_soft_deletes.php b/database/migrations/2026_05_28_073203_update_categories_unique_constraint_for_soft_deletes.php new file mode 100644 index 00000000..a0d83871 --- /dev/null +++ b/database/migrations/2026_05_28_073203_update_categories_unique_constraint_for_soft_deletes.php @@ -0,0 +1,38 @@ +dropForeign(['user_id']); + $table->dropUnique(['user_id', 'name']); + $table->boolean('active_unique_marker') + ->nullable() + ->virtualAs('if(`deleted_at` is null, 1, null)'); + $table->unique(['user_id', 'name', 'active_unique_marker'], 'categories_user_id_name_active_unique'); + $table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('categories', function (Blueprint $table) { + $table->dropForeign(['user_id']); + $table->dropUnique('categories_user_id_name_active_unique'); + $table->dropColumn('active_unique_marker'); + $table->unique(['user_id', 'name']); + $table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete(); + }); + } +}; diff --git a/tests/Feature/Settings/CategoryTest.php b/tests/Feature/Settings/CategoryTest.php index 37032181..827f9674 100644 --- a/tests/Feature/Settings/CategoryTest.php +++ b/tests/Feature/Settings/CategoryTest.php @@ -129,24 +129,27 @@ test('different users can create categories with the same name', function () { ]); }); -test('category names from deleted categories remain reserved', function () { +test('users can recreate a category with the same name after deleting it', function () { $user = User::factory()->create(); $category = Category::factory()->create([ 'user_id' => $user->id, - 'name' => 'Healthcare', + 'name' => 'Supermercados', ]); $category->delete(); $response = $this->actingAs($user)->post(route('categories.store'), [ - 'name' => 'Healthcare', - 'icon' => 'Heart', - 'color' => 'pink', + 'name' => 'Supermercados', + 'icon' => 'ShoppingCart', + 'color' => 'green', 'type' => 'expense', 'cashflow_direction' => 'hidden', ]); - $response->assertSessionHasErrors(['name']); + $response->assertRedirect(route('categories.index')); + + expect($user->categories()->where('name', 'Supermercados')->count())->toBe(1) + ->and($user->categories()->withTrashed()->where('name', 'Supermercados')->count())->toBe(2); }); test('category icon is required', function () {