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..cc273fd --- /dev/null +++ b/database/migrations/2025_07_24_092819_create_tax_classes_table.php @@ -0,0 +1,36 @@ +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/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..7fed21a --- /dev/null +++ b/src/Filament/Resources/TaxClassResource.php @@ -0,0 +1,182 @@ +schema([ + TextInput::make('name') + ->label(__('eclipse-catalogue::tax-class.fields.name')) + ->required() + ->maxLength(255) + ->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')) + ->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..1ed81e8 --- /dev/null +++ b/src/Filament/Resources/TaxClassResource/Pages/CreateTaxClass.php @@ -0,0 +1,41 @@ +id; + if ($tenantFK && $tenantId) { + $query->where($tenantFK, $tenantId); + } + + $query->update(['is_default' => false]); + } + + // Auto-set current tenant_id if tenancy is configured + $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 new file mode 100644 index 0000000..54e9864 --- /dev/null +++ b/src/Filament/Resources/TaxClassResource/Pages/EditTaxClass.php @@ -0,0 +1,57 @@ +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 + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key'); + if ($tenantFK && ! isset($data[$tenantFK])) { + // Use current tenant from Filament or preserve existing + $tenantId = Filament::getTenant()?->id; + if ($tenantId) { + $data[$tenantFK] = $tenantId; + } else { + $data[$tenantFK] = $this->record->getAttribute($tenantFK); + } + } + + 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', + ]; + } + + protected static function boot() + { + parent::boot(); + + static::saving(function ($model) { + // If this class is being set as default, unset all other defaults within the same tenant + if ($model->is_default) { + $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]); + } + }); + + 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 for the current tenant + */ + public static function getDefault(?int $tenantId = null): ?self + { + $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(); + } + + /** + * Check if this is the default class + */ + 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); + } +} 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(); +});