From 9bb2190f63f16aa459658f687b3f0be0ba99d692 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Omer=20=C5=A0abi=C4=87?= Date: Thu, 24 Jul 2025 11:48:01 +0200 Subject: [PATCH 1/4] feat: add tax class --- ..._07_24_092819_create_tax_classes_table.php | 35 +++++++++++++++++++ src/Models/TaxClass.php | 35 +++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 database/migrations/2025_07_24_092819_create_tax_classes_table.php create mode 100644 src/Models/TaxClass.php diff --git a/database/migrations/2025_07_24_092819_create_tax_classes_table.php b/database/migrations/2025_07_24_092819_create_tax_classes_table.php new file mode 100644 index 0000000..f4d4b9c --- /dev/null +++ b/database/migrations/2025_07_24_092819_create_tax_classes_table.php @@ -0,0 +1,35 @@ +id(); + + if (config('eclipse-catalogue.tenancy.model')) { + $tenantClass = config('eclipse-catalogue.tenancy.model'); + /** @var \Illuminate\Database\Eloquent\Model $tenant */ + $tenant = new $tenantClass; + $table->foreignId(config('eclipse-catalogue.tenancy.foreign_key')) + ->constrained($tenant->getTable(), $tenant->getKeyName()) + ->cascadeOnUpdate() + ->cascadeOnDelete(); + } + + $table->decimal('rate', total: 5); + $table->boolean('is_default') + ->default(false); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('pim_tax_classes'); + } +}; diff --git a/src/Models/TaxClass.php b/src/Models/TaxClass.php new file mode 100644 index 0000000..fd630cc --- /dev/null +++ b/src/Models/TaxClass.php @@ -0,0 +1,35 @@ + 'decimal:2', + 'is_default' => 'boolean', + ]; + } + + public function getFillable(): array + { + $fillable = [ + 'rate', + 'is_default', + ]; + + if (config('eclipse-catalogue.tenancy.foreign_key')) { + $fillable[] = config('eclipse-catalogue.tenancy.foreign_key'); + } + + return $fillable; + } +} From 3f0aefa42661773a86901da428e2b9700057a232 Mon Sep 17 00:00:00 2001 From: SlimDeluxe <131700+SlimDeluxe@users.noreply.github.com> Date: Thu, 24 Jul 2025 09:48:20 +0000 Subject: [PATCH 2/4] style: fix code style --- .../migrations/2025_07_24_092819_create_tax_classes_table.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/database/migrations/2025_07_24_092819_create_tax_classes_table.php b/database/migrations/2025_07_24_092819_create_tax_classes_table.php index f4d4b9c..cc273fd 100644 --- a/database/migrations/2025_07_24_092819_create_tax_classes_table.php +++ b/database/migrations/2025_07_24_092819_create_tax_classes_table.php @@ -4,7 +4,8 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration { +return new class extends Migration +{ public function up(): void { Schema::create('pim_tax_classes', function (Blueprint $table) { From becd1e9870f4e0483666705055b955432078c06a Mon Sep 17 00:00:00 2001 From: Kilian Trunk Date: Wed, 30 Jul 2025 10:39:23 +0200 Subject: [PATCH 3/4] feat(tax): implement tax classes (CT-36) * feat(tax): implement tax classes * chore(tax): fix tenancy, add negative auth test --- ...e_and_description_to_tax_classes_table.php | 23 +++ resources/lang/en/tax-class.php | 16 ++ resources/lang/sl/tax-class.php | 16 ++ src/Filament/Resources/TaxClassResource.php | 163 ++++++++++++++++++ .../TaxClassResource/Pages/CreateTaxClass.php | 33 ++++ .../TaxClassResource/Pages/EditTaxClass.php | 48 ++++++ .../TaxClassResource/Pages/ListTaxClasses.php | 19 ++ src/Models/TaxClass.php | 58 ++++++- src/Policies/TaxClassPolicy.php | 102 +++++++++++ tests/Feature/TaxClassCrudTest.php | 90 ++++++++++ .../Feature/TaxClassDefaultValidationTest.php | 91 ++++++++++ tests/Feature/TaxClassPermissionTest.php | 86 +++++++++ tests/Unit/TaxClassTest.php | 157 +++++++++++++++++ 13 files changed, 894 insertions(+), 8 deletions(-) create mode 100644 database/migrations/2025_07_28_091321_add_name_and_description_to_tax_classes_table.php create mode 100644 resources/lang/en/tax-class.php create mode 100644 resources/lang/sl/tax-class.php create mode 100644 src/Filament/Resources/TaxClassResource.php create mode 100644 src/Filament/Resources/TaxClassResource/Pages/CreateTaxClass.php create mode 100644 src/Filament/Resources/TaxClassResource/Pages/EditTaxClass.php create mode 100644 src/Filament/Resources/TaxClassResource/Pages/ListTaxClasses.php create mode 100644 src/Policies/TaxClassPolicy.php create mode 100644 tests/Feature/TaxClassCrudTest.php create mode 100644 tests/Feature/TaxClassDefaultValidationTest.php create mode 100644 tests/Feature/TaxClassPermissionTest.php create mode 100644 tests/Unit/TaxClassTest.php diff --git a/database/migrations/2025_07_28_091321_add_name_and_description_to_tax_classes_table.php b/database/migrations/2025_07_28_091321_add_name_and_description_to_tax_classes_table.php new file mode 100644 index 0000000..2fe5e37 --- /dev/null +++ b/database/migrations/2025_07_28_091321_add_name_and_description_to_tax_classes_table.php @@ -0,0 +1,23 @@ +string('name')->after('id'); + $table->text('description')->nullable()->after('name'); + }); + } + + public function down(): void + { + Schema::table('pim_tax_classes', function (Blueprint $table) { + $table->dropColumn(['name', 'description']); + }); + } +}; diff --git a/resources/lang/en/tax-class.php b/resources/lang/en/tax-class.php new file mode 100644 index 0000000..a86a49b --- /dev/null +++ b/resources/lang/en/tax-class.php @@ -0,0 +1,16 @@ + 'Tax class', + 'plural' => 'Tax classes', + 'fields' => [ + 'name' => 'Name', + 'description' => 'Description', + 'rate' => 'Rate (%)', + 'is_default' => 'Default class', + ], + 'messages' => [ + 'default_class_help' => 'Only one class can be set as default', + 'cannot_delete_default' => 'Cannot delete the default tax class.', + ], +]; diff --git a/resources/lang/sl/tax-class.php b/resources/lang/sl/tax-class.php new file mode 100644 index 0000000..ad84381 --- /dev/null +++ b/resources/lang/sl/tax-class.php @@ -0,0 +1,16 @@ + 'Davčni razred', + 'plural' => 'Davčni razredi', + 'fields' => [ + 'name' => 'Ime', + 'description' => 'Opis', + 'rate' => 'Stopnja (%)', + 'is_default' => 'Privzeti razred', + ], + 'messages' => [ + 'default_class_help' => 'Samo en razred je lahko nastavljen kot privzet', + 'cannot_delete_default' => 'Privzetega davčnega razreda ni mogoče izbrisati.', + ], +]; diff --git a/src/Filament/Resources/TaxClassResource.php b/src/Filament/Resources/TaxClassResource.php new file mode 100644 index 0000000..71d145e --- /dev/null +++ b/src/Filament/Resources/TaxClassResource.php @@ -0,0 +1,163 @@ +schema([ + TextInput::make('name') + ->label(__('eclipse-catalogue::tax-class.fields.name')) + ->required() + ->maxLength(255) + ->unique(ignoreRecord: true), + + Textarea::make('description') + ->label(__('eclipse-catalogue::tax-class.fields.description')) + ->rows(3) + ->maxLength(65535), + + TextInput::make('rate') + ->label(__('eclipse-catalogue::tax-class.fields.rate')) + ->required() + ->numeric() + ->minValue(0) + ->maxValue(100) + ->step(0.01) + ->suffix('%'), + + Toggle::make('is_default') + ->label(__('eclipse-catalogue::tax-class.fields.is_default')) + ->helperText(__('eclipse-catalogue::tax-class.messages.default_class_help')), + + Placeholder::make('created_at') + ->label('Created Date') + ->content(fn (?TaxClass $record): string => $record?->created_at?->diffForHumans() ?? '-'), + + Placeholder::make('updated_at') + ->label('Last Modified Date') + ->content(fn (?TaxClass $record): string => $record?->updated_at?->diffForHumans() ?? '-'), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('id') + ->sortable(), + + TextColumn::make('name') + ->searchable() + ->sortable(), + + TextColumn::make('description') + ->searchable() + ->limit(50) + ->toggleable(isToggledHiddenByDefault: true), + + TextColumn::make('rate') + ->suffix('%') + ->sortable(), + + IconColumn::make('is_default') + ->label('Default') + ->boolean() + ->sortable(), + + TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + + TextColumn::make('updated_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + TrashedFilter::make(), + ]) + ->actions([ + EditAction::make(), + DeleteAction::make(), + RestoreAction::make(), + ForceDeleteAction::make(), + ]); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListTaxClasses::route('/'), + 'create' => Pages\CreateTaxClass::route('/create'), + 'edit' => Pages\EditTaxClass::route('/{record}/edit'), + ]; + } + + public static function getEloquentQuery(): Builder + { + return parent::getEloquentQuery() + ->withoutGlobalScopes([ + SoftDeletingScope::class, + ]); + } + + public static function getPermissionPrefixes(): array + { + return [ + 'view_any', + 'view', + 'create', + 'update', + 'restore', + 'restore_any', + 'delete', + 'delete_any', + 'force_delete', + 'force_delete_any', + ]; + } +} diff --git a/src/Filament/Resources/TaxClassResource/Pages/CreateTaxClass.php b/src/Filament/Resources/TaxClassResource/Pages/CreateTaxClass.php new file mode 100644 index 0000000..2cbbadb --- /dev/null +++ b/src/Filament/Resources/TaxClassResource/Pages/CreateTaxClass.php @@ -0,0 +1,33 @@ +update(['is_default' => false]); + } + + // Auto-set current tenant_id if tenancy is configured + if (config('eclipse-catalogue.tenancy.foreign_key')) { + $currentTenant = Filament::getTenant(); + if ($currentTenant) { + $data[config('eclipse-catalogue.tenancy.foreign_key')] = $currentTenant->id; + } else { + throw new \Exception('Current tenant not available. Cannot create TaxClass without tenant context.'); + } + } + + return $data; + } +} diff --git a/src/Filament/Resources/TaxClassResource/Pages/EditTaxClass.php b/src/Filament/Resources/TaxClassResource/Pages/EditTaxClass.php new file mode 100644 index 0000000..ae79622 --- /dev/null +++ b/src/Filament/Resources/TaxClassResource/Pages/EditTaxClass.php @@ -0,0 +1,48 @@ +where('id', '!=', $this->record->id) + ->update(['is_default' => false]); + } + + // Ensure tenant id is preserved if tenancy is configured + if (config('eclipse-catalogue.tenancy.foreign_key') && ! isset($data[config('eclipse-catalogue.tenancy.foreign_key')])) { + // Use current tenant from Filament or preserve existing + $currentTenant = Filament::getTenant(); + if ($currentTenant) { + $data[config('eclipse-catalogue.tenancy.foreign_key')] = $currentTenant->id; + } else { + $data[config('eclipse-catalogue.tenancy.foreign_key')] = $this->record->getAttribute(config('eclipse-catalogue.tenancy.foreign_key')); + } + } + + return $data; + } +} diff --git a/src/Filament/Resources/TaxClassResource/Pages/ListTaxClasses.php b/src/Filament/Resources/TaxClassResource/Pages/ListTaxClasses.php new file mode 100644 index 0000000..ad72853 --- /dev/null +++ b/src/Filament/Resources/TaxClassResource/Pages/ListTaxClasses.php @@ -0,0 +1,19 @@ + 'decimal:2', - 'is_default' => 'boolean', - ]; - } - public function getFillable(): array { $fillable = [ + 'name', + 'description', 'rate', 'is_default', ]; @@ -32,4 +27,51 @@ public function getFillable(): array return $fillable; } + + protected function casts(): array + { + return [ + 'rate' => 'decimal:2', + 'is_default' => 'boolean', + ]; + } + + protected static function boot() + { + parent::boot(); + + static::saving(function ($model) { + // If this class is being set as default, unset all other defaults + if ($model->is_default) { + static::where('is_default', true) + ->where('id', '!=', $model->id) + ->update(['is_default' => false]); + } + }); + + static::deleting(function ($model) { + // Prevent deletion of default class + if ($model->is_default) { + throw ValidationException::withMessages([ + 'is_default' => 'Cannot delete the default tax class.', + ]); + } + }); + } + + /** + * Get the default tax class + */ + public static function getDefault(): ?self + { + return static::where('is_default', true)->first(); + } + + /** + * Check if this is the default class + */ + public function isDefault(): bool + { + return $this->is_default; + } } diff --git a/src/Policies/TaxClassPolicy.php b/src/Policies/TaxClassPolicy.php new file mode 100644 index 0000000..074d86e --- /dev/null +++ b/src/Policies/TaxClassPolicy.php @@ -0,0 +1,102 @@ +can('view_any_tax::class'); + } + + /** + * Determine whether the user can view the model. + */ + public function view(Authorizable $user, TaxClass $taxClass): bool + { + return $user->can('view_tax::class'); + } + + /** + * Determine whether the user can create models. + */ + public function create(Authorizable $user): bool + { + return $user->can('create_tax::class'); + } + + /** + * Determine whether the user can update the model. + */ + public function update(Authorizable $user, TaxClass $taxClass): bool + { + return $user->can('update_tax::class'); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(Authorizable $user, TaxClass $taxClass): bool + { + // Prevent deletion of default class + if ($taxClass->is_default) { + return false; + } + + return $user->can('delete_tax::class'); + } + + /** + * Determine whether the user can bulk delete. + */ + public function deleteAny(Authorizable $user): bool + { + return $user->can('delete_any_tax::class'); + } + + /** + * Determine whether the user can permanently delete. + */ + public function forceDelete(Authorizable $user, TaxClass $taxClass): bool + { + // Prevent force deletion of default class + if ($taxClass->is_default) { + return false; + } + + return $user->can('force_delete_tax::class'); + } + + /** + * Determine whether the user can permanently bulk delete. + */ + public function forceDeleteAny(Authorizable $user): bool + { + return $user->can('force_delete_any_tax::class'); + } + + /** + * Determine whether the user can restore. + */ + public function restore(Authorizable $user, TaxClass $taxClass): bool + { + return $user->can('restore_tax::class'); + } + + /** + * Determine whether the user can bulk restore. + */ + public function restoreAny(Authorizable $user): bool + { + return $user->can('restore_any_tax::class'); + } +} diff --git a/tests/Feature/TaxClassCrudTest.php b/tests/Feature/TaxClassCrudTest.php new file mode 100644 index 0000000..7752d71 --- /dev/null +++ b/tests/Feature/TaxClassCrudTest.php @@ -0,0 +1,90 @@ +migrate(); +}); + +it('can create a tax class', function () { + $taxClass = TaxClass::create([ + 'name' => 'Standard Rate', + 'description' => 'Standard tax rate for most products', + 'rate' => 20.00, + 'is_default' => false, + ]); + + expect($taxClass)->toBeInstanceOf(TaxClass::class); + expect($taxClass->name)->toBe('Standard Rate'); + expect($taxClass->description)->toBe('Standard tax rate for most products'); + expect($taxClass->rate)->toBe('20.00'); + expect($taxClass->is_default)->toBeFalse(); + + $this->assertDatabaseHas('pim_tax_classes', [ + 'name' => 'Standard Rate', + 'description' => 'Standard tax rate for most products', + 'rate' => '20.00', + 'is_default' => false, + ]); +}); + +it('can update a tax class', function () { + $taxClass = TaxClass::create([ + 'name' => 'Standard Rate', + 'description' => 'Standard tax rate', + 'rate' => 20.00, + 'is_default' => false, + ]); + + $taxClass->update([ + 'name' => 'Updated Standard Rate', + 'description' => 'Updated description', + 'rate' => 21.00, + 'is_default' => true, + ]); + + expect($taxClass->name)->toBe('Updated Standard Rate'); + expect($taxClass->description)->toBe('Updated description'); + expect($taxClass->rate)->toBe('21.00'); + expect($taxClass->is_default)->toBeTrue(); + + $this->assertDatabaseHas('pim_tax_classes', [ + 'id' => $taxClass->id, + 'name' => 'Updated Standard Rate', + 'description' => 'Updated description', + 'rate' => '21.00', + 'is_default' => true, + ]); +}); + +it('can soft delete a non-default tax class', function () { + $taxClass = TaxClass::create([ + 'name' => 'Reduced Rate', + 'description' => 'Reduced tax rate', + 'rate' => 5.00, + 'is_default' => false, + ]); + + $taxClass->delete(); + + $this->assertSoftDeleted('pim_tax_classes', [ + 'id' => $taxClass->id, + ]); +}); + +it('can restore a soft deleted tax class', function () { + $taxClass = TaxClass::create([ + 'name' => 'Reduced Rate', + 'description' => 'Reduced tax rate', + 'rate' => 5.00, + 'is_default' => false, + ]); + + $taxClass->delete(); + $taxClass->restore(); + + $this->assertDatabaseHas('pim_tax_classes', [ + 'id' => $taxClass->id, + 'deleted_at' => null, + ]); +}); diff --git a/tests/Feature/TaxClassDefaultValidationTest.php b/tests/Feature/TaxClassDefaultValidationTest.php new file mode 100644 index 0000000..47591af --- /dev/null +++ b/tests/Feature/TaxClassDefaultValidationTest.php @@ -0,0 +1,91 @@ +migrate(); +}); + +it('ensures only one class can be set as default when creating', function () { + // Create first default class + $firstClass = TaxClass::create([ + 'name' => 'Standard Rate', + 'description' => 'Standard tax rate', + 'rate' => 20.00, + 'is_default' => true, + ]); + + // Create second class as default + $secondClass = TaxClass::create([ + 'name' => 'Reduced Rate', + 'description' => 'Reduced tax rate', + 'rate' => 5.00, + 'is_default' => true, + ]); + + // Refresh first class from database + $firstClass->refresh(); + + // Check that only the new class is default + expect($firstClass->is_default)->toBeFalse(); + expect($secondClass->is_default)->toBeTrue(); +}); + +it('ensures only one class can be set as default when updating', function () { + $firstClass = TaxClass::create([ + 'name' => 'Standard Rate', + 'description' => 'Standard tax rate', + 'rate' => 20.00, + 'is_default' => true, + ]); + + $secondClass = TaxClass::create([ + 'name' => 'Reduced Rate', + 'description' => 'Reduced tax rate', + 'rate' => 5.00, + 'is_default' => false, + ]); + + // Update second class to be default + $secondClass->update(['is_default' => true]); + + // Refresh first class + $firstClass->refresh(); + + // Check that only the updated class is default + expect($firstClass->is_default)->toBeFalse(); + expect($secondClass->is_default)->toBeTrue(); +}); + +it('allows multiple classes to be non-default', function () { + $firstClass = TaxClass::create([ + 'name' => 'Standard Rate', + 'description' => 'Standard tax rate', + 'rate' => 20.00, + 'is_default' => false, + ]); + + $secondClass = TaxClass::create([ + 'name' => 'Reduced Rate', + 'description' => 'Reduced tax rate', + 'rate' => 5.00, + 'is_default' => false, + ]); + + // Check that both classes are non-default + expect($firstClass->is_default)->toBeFalse(); + expect($secondClass->is_default)->toBeFalse(); +}); + +it('can unset default by updating to false', function () { + $class = TaxClass::create([ + 'name' => 'Standard Rate', + 'description' => 'Standard tax rate', + 'rate' => 20.00, + 'is_default' => true, + ]); + + $class->update(['is_default' => false]); + + expect($class->is_default)->toBeFalse(); +}); diff --git a/tests/Feature/TaxClassPermissionTest.php b/tests/Feature/TaxClassPermissionTest.php new file mode 100644 index 0000000..aa6140d --- /dev/null +++ b/tests/Feature/TaxClassPermissionTest.php @@ -0,0 +1,86 @@ +migrate(); +}); + +it('policy prevents deletion of default class regardless of user permissions', function () { + $user = User::factory()->create(); + + $defaultClass = TaxClass::create([ + 'name' => 'Standard Rate', + 'description' => 'Standard tax rate', + 'rate' => 20.00, + 'is_default' => true, + ]); + + $policy = new TaxClassPolicy; + $canDelete = $policy->delete($user, $defaultClass); + + expect($canDelete)->toBeFalse(); +}); + +it('policy prevents force deletion of default class regardless of user permissions', function () { + $user = User::factory()->create(); + + $defaultClass = TaxClass::create([ + 'name' => 'Standard Rate', + 'description' => 'Standard tax rate', + 'rate' => 20.00, + 'is_default' => true, + ]); + + $policy = new TaxClassPolicy; + $canForceDelete = $policy->forceDelete($user, $defaultClass); + + expect($canForceDelete)->toBeFalse(); +}); + +test('unauthorized access can be prevented', function () { + // Create regular user with no permissions + $this->setUpCommonUser(); + + // Create test tax class + $taxClass = TaxClass::create([ + 'name' => 'Test Rate', + 'description' => 'Test tax rate', + 'rate' => 15.00, + 'is_default' => false, + ]); + + // View table + $this->get(TaxClassResource::getUrl()) + ->assertForbidden(); + + // Add direct permission to view the table, since otherwise any other action below is not available even for testing + $this->user->givePermissionTo('view_any_tax::class'); + + // Create tax class + livewire(ListTaxClasses::class) + ->assertActionDisabled('create'); + + // Edit tax class + livewire(ListTaxClasses::class) + ->assertCanSeeTableRecords([$taxClass]) + ->assertTableActionDisabled('edit', $taxClass); + + // Delete tax class + livewire(ListTaxClasses::class) + ->assertTableActionDisabled('delete', $taxClass); + + // Restore and force delete + $taxClass->delete(); + $this->assertSoftDeleted($taxClass); + + livewire(ListTaxClasses::class) + ->assertTableActionDisabled('restore', $taxClass) + ->assertTableActionDisabled('forceDelete', $taxClass); +}); diff --git a/tests/Unit/TaxClassTest.php b/tests/Unit/TaxClassTest.php new file mode 100644 index 0000000..e568ff5 --- /dev/null +++ b/tests/Unit/TaxClassTest.php @@ -0,0 +1,157 @@ +migrate(); +}); + +it('can create a tax class', function () { + $taxClass = TaxClass::create([ + 'name' => 'Standard Rate', + 'description' => 'Standard tax rate for most products', + 'rate' => 20.00, + 'is_default' => false, + ]); + + expect($taxClass)->toBeInstanceOf(TaxClass::class); + expect($taxClass->name)->toBe('Standard Rate'); + expect($taxClass->description)->toBe('Standard tax rate for most products'); + expect($taxClass->rate)->toBe('20.00'); + expect($taxClass->is_default)->toBeFalse(); +}); + +it('can set a tax class as default', function () { + $taxClass = TaxClass::create([ + 'name' => 'Standard Rate', + 'description' => 'Standard tax rate', + 'rate' => 20.00, + 'is_default' => true, + ]); + + expect($taxClass->is_default)->toBeTrue(); + expect($taxClass->isDefault())->toBeTrue(); +}); + +it('ensures only one class can be default', function () { + // Create first default class + $firstClass = TaxClass::create([ + 'name' => 'Standard Rate', + 'description' => 'Standard tax rate', + 'rate' => 20.00, + 'is_default' => true, + ]); + + // Create second class as default + $secondClass = TaxClass::create([ + 'name' => 'Reduced Rate', + 'description' => 'Reduced tax rate', + 'rate' => 5.00, + 'is_default' => true, + ]); + + // Refresh first class from database + $firstClass->refresh(); + + expect($firstClass->is_default)->toBeFalse(); + expect($secondClass->is_default)->toBeTrue(); +}); + +it('can get the default tax class', function () { + TaxClass::create([ + 'name' => 'Reduced Rate', + 'description' => 'Reduced tax rate', + 'rate' => 5.00, + 'is_default' => false, + ]); + + $defaultClass = TaxClass::create([ + 'name' => 'Standard Rate', + 'description' => 'Standard tax rate', + 'rate' => 20.00, + 'is_default' => true, + ]); + + $retrieved = TaxClass::getDefault(); + + expect($retrieved)->not->toBeNull(); + expect($retrieved->id)->toBe($defaultClass->id); + expect($retrieved->name)->toBe('Standard Rate'); +}); + +it('returns null when no default class exists', function () { + TaxClass::create([ + 'name' => 'Standard Rate', + 'description' => 'Standard tax rate', + 'rate' => 20.00, + 'is_default' => false, + ]); + + $default = TaxClass::getDefault(); + + expect($default)->toBeNull(); +}); + +it('prevents deletion of default class', function () { + $defaultClass = TaxClass::create([ + 'name' => 'Standard Rate', + 'description' => 'Standard tax rate', + 'rate' => 20.00, + 'is_default' => true, + ]); + + expect(fn () => $defaultClass->delete()) + ->toThrow(ValidationException::class, 'Cannot delete the default tax class.'); +}); + +it('allows deletion of non-default class', function () { + $class = TaxClass::create([ + 'name' => 'Reduced Rate', + 'description' => 'Reduced tax rate', + 'rate' => 5.00, + 'is_default' => false, + ]); + + expect($class->delete())->toBeTrue(); +}); + +it('can soft delete and restore classes', function () { + $class = TaxClass::create([ + 'name' => 'Reduced Rate', + 'description' => 'Reduced tax rate', + 'rate' => 5.00, + 'is_default' => false, + ]); + + $class->delete(); + expect($class->trashed())->toBeTrue(); + + $class->restore(); + expect($class->trashed())->toBeFalse(); +}); + +it('updates default when editing existing class', function () { + $firstClass = TaxClass::create([ + 'name' => 'Standard Rate', + 'description' => 'Standard tax rate', + 'rate' => 20.00, + 'is_default' => true, + ]); + + $secondClass = TaxClass::create([ + 'name' => 'Reduced Rate', + 'description' => 'Reduced tax rate', + 'rate' => 5.00, + 'is_default' => false, + ]); + + // Update second class to be default + $secondClass->update(['is_default' => true]); + + // Refresh first class + $firstClass->refresh(); + + expect($firstClass->is_default)->toBeFalse(); + expect($secondClass->is_default)->toBeTrue(); +}); From eb19b97968a152e555a74e4ad1c5007466e0ca12 Mon Sep 17 00:00:00 2001 From: Kilian Trunk Date: Mon, 4 Aug 2025 14:07:22 +0200 Subject: [PATCH 4/4] feat: implement tax classes (CT-36) * feat(tax): implement tax classes * chore(tax): fix tenancy, add negative auth test * chore(tax): make TaxClassResource tenancy-aware based on config * chore(tax): fix merge conflict * chore(tax): fix tenancy using isScopedToTenant * chore(tax): fix tenancy issues * chore(tax): fix tenancy relation --- src/Filament/Resources/TaxClassResource.php | 21 +++++++++- .../TaxClassResource/Pages/CreateTaxClass.php | 26 +++++++----- .../TaxClassResource/Pages/EditTaxClass.php | 27 ++++++++----- src/Models/TaxClass.php | 40 +++++++++++++++---- 4 files changed, 88 insertions(+), 26 deletions(-) diff --git a/src/Filament/Resources/TaxClassResource.php b/src/Filament/Resources/TaxClassResource.php index 71d145e..7fed21a 100644 --- a/src/Filament/Resources/TaxClassResource.php +++ b/src/Filament/Resources/TaxClassResource.php @@ -5,6 +5,7 @@ use BezhanSalleh\FilamentShield\Contracts\HasShieldPermissions; use Eclipse\Catalogue\Filament\Resources\TaxClassResource\Pages; use Eclipse\Catalogue\Models\TaxClass; +use Filament\Facades\Filament; use Filament\Forms\Components\Placeholder; use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; @@ -34,6 +35,10 @@ class TaxClassResource extends Resource implements HasShieldPermissions protected static ?string $recordTitleAttribute = 'name'; + protected static bool $isScopedToTenant = true; + + protected static ?string $tenantOwnershipRelationshipName = 'tenant'; + public static function getModelLabel(): string { return __('eclipse-catalogue::tax-class.singular'); @@ -52,7 +57,21 @@ public static function form(Form $form): Form ->label(__('eclipse-catalogue::tax-class.fields.name')) ->required() ->maxLength(255) - ->unique(ignoreRecord: true), + ->unique( + table: 'pim_tax_classes', + column: 'name', + ignoreRecord: true, + modifyRuleUsing: function ($rule) { + // Add tenant scope to unique validation + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key'); + $tenantId = Filament::getTenant()?->id; + if ($tenantFK && $tenantId) { + $rule->where($tenantFK, $tenantId); + } + + return $rule; + } + ), Textarea::make('description') ->label(__('eclipse-catalogue::tax-class.fields.description')) diff --git a/src/Filament/Resources/TaxClassResource/Pages/CreateTaxClass.php b/src/Filament/Resources/TaxClassResource/Pages/CreateTaxClass.php index 2cbbadb..1ed81e8 100644 --- a/src/Filament/Resources/TaxClassResource/Pages/CreateTaxClass.php +++ b/src/Filament/Resources/TaxClassResource/Pages/CreateTaxClass.php @@ -13,19 +13,27 @@ class CreateTaxClass extends CreateRecord protected function mutateFormDataBeforeCreate(array $data): array { - // If this class is being set as default, unset all other defaults + // If this class is being set as default, unset all other defaults within the same tenant if ($data['is_default'] ?? false) { - TaxClass::where('is_default', true)->update(['is_default' => false]); + $query = TaxClass::where('is_default', true); + + // Add tenant scope if tenancy is configured + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key'); + $tenantId = Filament::getTenant()?->id; + if ($tenantFK && $tenantId) { + $query->where($tenantFK, $tenantId); + } + + $query->update(['is_default' => false]); } // Auto-set current tenant_id if tenancy is configured - if (config('eclipse-catalogue.tenancy.foreign_key')) { - $currentTenant = Filament::getTenant(); - if ($currentTenant) { - $data[config('eclipse-catalogue.tenancy.foreign_key')] = $currentTenant->id; - } else { - throw new \Exception('Current tenant not available. Cannot create TaxClass without tenant context.'); - } + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key'); + $tenantId = Filament::getTenant()?->id; + if ($tenantFK && $tenantId) { + $data[$tenantFK] = $tenantId; + } elseif ($tenantFK) { + throw new \Exception('Current tenant not available. Cannot create TaxClass without tenant context.'); } return $data; diff --git a/src/Filament/Resources/TaxClassResource/Pages/EditTaxClass.php b/src/Filament/Resources/TaxClassResource/Pages/EditTaxClass.php index ae79622..54e9864 100644 --- a/src/Filament/Resources/TaxClassResource/Pages/EditTaxClass.php +++ b/src/Filament/Resources/TaxClassResource/Pages/EditTaxClass.php @@ -25,21 +25,30 @@ protected function getHeaderActions(): array protected function mutateFormDataBeforeSave(array $data): array { - // If this class is being set as default, unset all other defaults + // If this class is being set as default, unset all other defaults within the same tenant if ($data['is_default'] ?? false) { - TaxClass::where('is_default', true) - ->where('id', '!=', $this->record->id) - ->update(['is_default' => false]); + $query = TaxClass::where('is_default', true) + ->where('id', '!=', $this->record->id); + + // Add tenant scope if tenancy is configured + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key'); + $tenantId = $this->record->getAttribute($tenantFK); + if ($tenantFK && $tenantId) { + $query->where($tenantFK, $tenantId); + } + + $query->update(['is_default' => false]); } // Ensure tenant id is preserved if tenancy is configured - if (config('eclipse-catalogue.tenancy.foreign_key') && ! isset($data[config('eclipse-catalogue.tenancy.foreign_key')])) { + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key'); + if ($tenantFK && ! isset($data[$tenantFK])) { // Use current tenant from Filament or preserve existing - $currentTenant = Filament::getTenant(); - if ($currentTenant) { - $data[config('eclipse-catalogue.tenancy.foreign_key')] = $currentTenant->id; + $tenantId = Filament::getTenant()?->id; + if ($tenantId) { + $data[$tenantFK] = $tenantId; } else { - $data[config('eclipse-catalogue.tenancy.foreign_key')] = $this->record->getAttribute(config('eclipse-catalogue.tenancy.foreign_key')); + $data[$tenantFK] = $this->record->getAttribute($tenantFK); } } diff --git a/src/Models/TaxClass.php b/src/Models/TaxClass.php index afe330b..1e2191d 100644 --- a/src/Models/TaxClass.php +++ b/src/Models/TaxClass.php @@ -3,6 +3,7 @@ namespace Eclipse\Catalogue\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Validation\ValidationException; @@ -41,11 +42,19 @@ protected static function boot() parent::boot(); static::saving(function ($model) { - // If this class is being set as default, unset all other defaults + // If this class is being set as default, unset all other defaults within the same tenant if ($model->is_default) { - static::where('is_default', true) - ->where('id', '!=', $model->id) - ->update(['is_default' => false]); + $query = static::where('is_default', true) + ->where('id', '!=', $model->id); + + // Add tenant scope if tenancy is configured + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key'); + $tenantId = $model->getAttribute($tenantFK); + if ($tenantFK && $tenantId) { + $query->where($tenantFK, $tenantId); + } + + $query->update(['is_default' => false]); } }); @@ -60,11 +69,20 @@ protected static function boot() } /** - * Get the default tax class + * Get the default tax class for the current tenant */ - public static function getDefault(): ?self + public static function getDefault(?int $tenantId = null): ?self { - return static::where('is_default', true)->first(); + $query = static::where('is_default', true); + + // Add tenant scope if tenancy is configured + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key'); + $currentTenantId = $tenantId ?: \Filament\Facades\Filament::getTenant()?->id; + if ($tenantFK && $currentTenantId) { + $query->where($tenantFK, $currentTenantId); + } + + return $query->first(); } /** @@ -74,4 +92,12 @@ public function isDefault(): bool { return $this->is_default; } + + public function tenant(): BelongsTo + { + $tenantModel = config('eclipse-catalogue.tenancy.model'); + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key'); + + return $this->belongsTo($tenantModel, $tenantFK); + } }