diff --git a/database/migrations/2025_09_18_135841_add_tax_class_id_to_catalogue_product_data_table.php b/database/migrations/2025_09_18_135841_add_tax_class_id_to_catalogue_product_data_table.php new file mode 100644 index 0000000..82e1a84 --- /dev/null +++ b/database/migrations/2025_09_18_135841_add_tax_class_id_to_catalogue_product_data_table.php @@ -0,0 +1,28 @@ +foreignId('tax_class_id') + ->nullable() + ->after('product_status_id') + ->constrained('pim_tax_classes') + ->nullOnDelete() + ->cascadeOnUpdate(); + }); + } + + public function down(): void + { + Schema::table('pim_product_data', function (Blueprint $table) { + $table->dropForeign(['tax_class_id']); + $table->dropColumn('tax_class_id'); + }); + } +}; diff --git a/src/Filament/Resources/ProductResource.php b/src/Filament/Resources/ProductResource.php index 8e00d59..c4b9985 100644 --- a/src/Filament/Resources/ProductResource.php +++ b/src/Filament/Resources/ProductResource.php @@ -77,7 +77,7 @@ class ProductResource extends Resource protected static ?string $recordTitleAttribute = 'name'; - public static function form(Schema $form): Schema + public static function schema(Schema $form): Schema { return $form ->components([ @@ -226,6 +226,21 @@ public static function form(Schema $form): Schema ->searchable() ->preload(), + Select::make("tenant_data.{$tenantId}.tax_class_id") + ->label('Tax class') + ->options(function () use ($tenantId) { + $query = \Eclipse\Catalogue\Models\TaxClass::query(); + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key', 'site_id'); + if ($tenantFK) { + $query->where($tenantFK, $tenantId); + } + + return $query->orderBy('name')->pluck('name', 'id')->toArray(); + }) + ->searchable() + ->preload() + ->placeholder('Select tax class'), + Select::make("tenant_data.{$tenantId}.groups") ->label('Groups') ->multiple() @@ -689,6 +704,18 @@ public static function table(Table $table): Table return is_array($category->name) ? ($category->name[app()->getLocale()] ?? reset($category->name)) : $category->name; }), + TextColumn::make('tax_class') + ->label('Tax class') + ->toggleable(isToggledHiddenByDefault: true) + ->getStateUsing(function (Product $record) { + $taxClass = $record->currentTenantData()?->taxClass; + if (! $taxClass) { + return null; + } + + return $taxClass->name; + }), + TextColumn::make('type.name') ->label(__('eclipse-catalogue::product.table.columns.type')), @@ -796,6 +823,33 @@ public static function table(Table $table): Table $q->whereIn('product_status_id', (array) $selected); }); }), + SelectFilter::make('tax_class_id') + ->label('Tax class') + ->multiple() + ->options(function () { + $query = \Eclipse\Catalogue\Models\TaxClass::query(); + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key'); + $currentTenant = \Filament\Facades\Filament::getTenant(); + if ($tenantFK && $currentTenant) { + $query->where($tenantFK, $currentTenant->id); + } + + return $query->orderBy('name')->pluck('name', 'id')->toArray(); + }) + ->query(function (Builder $query, array $data) { + $selected = $data['values'] ?? ($data['value'] ?? null); + if (empty($selected)) { + return; + } + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key'); + $currentTenant = \Filament\Facades\Filament::getTenant(); + $query->whereHas('productData', function ($q) use ($selected, $tenantFK, $currentTenant) { + if ($tenantFK && $currentTenant) { + $q->where($tenantFK, $currentTenant->id); + } + $q->whereIn('tax_class_id', (array) $selected); + }); + }), SelectFilter::make('category_id') ->label('Categories') ->multiple() diff --git a/src/Filament/Resources/ProductResource/Pages/CreateProduct.php b/src/Filament/Resources/ProductResource/Pages/CreateProduct.php index 6c0d2cb..873c8a1 100644 --- a/src/Filament/Resources/ProductResource/Pages/CreateProduct.php +++ b/src/Filament/Resources/ProductResource/Pages/CreateProduct.php @@ -58,6 +58,11 @@ protected function getFormMutuallyExclusiveFlagSets(): array return []; } + public function schema(Schema $schema): Schema + { + return $schema; + } + protected function handleRecordCreation(array $data): Model { $tenantData = $this->extractTenantDataFromFormData($data); diff --git a/src/Filament/Resources/ProductResource/Pages/EditProduct.php b/src/Filament/Resources/ProductResource/Pages/EditProduct.php index 6c2bf78..84c2381 100644 --- a/src/Filament/Resources/ProductResource/Pages/EditProduct.php +++ b/src/Filament/Resources/ProductResource/Pages/EditProduct.php @@ -97,6 +97,7 @@ protected function mutateFormDataBeforeFill(array $data): array $data['sorting_label'] = $recordData->sorting_label; $data['category_id'] = $recordData->category_id ?? null; $data['product_status_id'] = $recordData->product_status_id ?? null; + $data['tax_class_id'] = $recordData->tax_class_id ?? null; } $data['groups'] = $this->record->groups()->pluck('pim_group.id')->toArray(); @@ -116,6 +117,7 @@ protected function mutateFormDataBeforeFill(array $data): array 'sorting_label' => $tenantRecord->sorting_label, 'category_id' => $tenantRecord->category_id ?? null, 'product_status_id' => $tenantRecord->product_status_id ?? null, + 'tax_class_id' => $tenantRecord->tax_class_id ?? null, 'groups' => $this->record->groups() ->where('pim_group.'.config('eclipse-catalogue.tenancy.foreign_key', 'site_id'), $tenantId) ->pluck('pim_group.id') @@ -216,6 +218,11 @@ protected function getFormMutuallyExclusiveFlagSets(): array return []; } + public function schema(Schema $schema): Schema + { + return $schema; + } + protected function getFormActions(): array { return [ diff --git a/src/Filament/Resources/TaxClassResource.php b/src/Filament/Resources/TaxClassResource.php index d82a6e9..99e5ef0 100644 --- a/src/Filament/Resources/TaxClassResource.php +++ b/src/Filament/Resources/TaxClassResource.php @@ -16,6 +16,7 @@ use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Resources\Resource; +use Filament\Schemas\Components\Grid; use Filament\Schemas\Schema; use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; @@ -36,10 +37,6 @@ class TaxClassResource extends Resource 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'); @@ -71,14 +68,9 @@ public static function form(Schema $schema): Schema } 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() @@ -88,17 +80,26 @@ public static function form(Schema $schema): Schema ->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() ?? '-'), + Textarea::make('description') + ->label(__('eclipse-catalogue::tax-class.fields.description')) + ->rows(3) + ->maxLength(65535) + ->columnSpanFull(), + + Grid::make(3) + ->schema([ + 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() ?? '-'), + ]), ]); } diff --git a/src/Models/Product.php b/src/Models/Product.php index 1e5f1d4..d5fdc9f 100644 --- a/src/Models/Product.php +++ b/src/Models/Product.php @@ -87,6 +87,7 @@ class Product extends Model implements HasMedia 'available_from_date', 'category_id', 'product_status_id', + 'tax_class_id', ]; public function status(): ?ProductStatus @@ -99,6 +100,11 @@ public function category(): ?Category return $this->currentTenantData()?->category; } + public function taxClass(): ?\Eclipse\Catalogue\Models\TaxClass + { + return $this->currentTenantData()?->taxClass; + } + public function type(): BelongsTo { return $this->belongsTo(ProductType::class, 'product_type_id'); diff --git a/src/Models/ProductData.php b/src/Models/ProductData.php index 32858ab..42ba9b6 100644 --- a/src/Models/ProductData.php +++ b/src/Models/ProductData.php @@ -20,6 +20,7 @@ class ProductData extends Model 'product_id', 'category_id', 'product_status_id', + 'tax_class_id', 'sorting_label', 'is_active', 'available_from_date', @@ -56,6 +57,12 @@ public function status(): BelongsTo return $this->belongsTo(ProductStatus::class, 'product_status_id'); } + /** @return BelongsTo<\Eclipse\Catalogue\Models\TaxClass, self> */ + public function taxClass(): BelongsTo + { + return $this->belongsTo(\Eclipse\Catalogue\Models\TaxClass::class, 'tax_class_id'); + } + /** @return BelongsTo<\Eclipse\Core\Models\Site, self> */ public function site(): BelongsTo { diff --git a/src/Models/TaxClass.php b/src/Models/TaxClass.php index f47772f..0d41fa6 100644 --- a/src/Models/TaxClass.php +++ b/src/Models/TaxClass.php @@ -43,7 +43,7 @@ protected static function boot() { parent::boot(); - static::creating(function (self $category): void { + static::creating(function (self $model): void { // Set tenant foreign key, if configured $tenantModel = config('eclipse-catalogue.tenancy.model'); $tenantFK = config('eclipse-catalogue.tenancy.foreign_key'); @@ -55,11 +55,11 @@ protected static function boot() throw new RuntimeException('Tenancy is enabled, but no tenant is set'); } - $category->{$tenantFK} = $tenant->id; + $model->{$tenantFK} = $tenant->id; } }); - static::saving(function ($model) { + static::saving(function (self $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) @@ -76,7 +76,7 @@ protected static function boot() } }); - static::deleting(function ($model) { + static::deleting(function (self $model) { // Prevent deletion of default class if ($model->is_default) { throw ValidationException::withMessages([ diff --git a/tests/Feature/ProductTaxClassTest.php b/tests/Feature/ProductTaxClassTest.php new file mode 100644 index 0000000..07f4e19 --- /dev/null +++ b/tests/Feature/ProductTaxClassTest.php @@ -0,0 +1,102 @@ +setUpSuperAdminAndTenant(); +}); + +it('can set and unset tax class per tenant', function (): void { + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key'); + $tenantModel = config('eclipse-catalogue.tenancy.model'); + $currentTenantId = $tenantModel::first()->id; + + $taxClass = \Eclipse\Catalogue\Models\TaxClass::create([ + 'name' => 'Standard', + 'rate' => 22.00, + 'is_default' => false, + $tenantFK => $currentTenantId, + ]); + + $product = Product::factory()->create(['name' => 'Test Product']); + + // Set + ProductData::factory()->create([ + 'product_id' => $product->id, + $tenantFK => $currentTenantId, + 'tax_class_id' => $taxClass->id, + 'is_active' => true, + ]); + + expect($product->fresh()->currentTenantData()->tax_class_id)->toBe($taxClass->id); + + // Unset + $product->currentTenantData()->update(['tax_class_id' => null]); + expect($product->fresh()->currentTenantData()->tax_class_id)->toBeNull(); +}); + +it('table column is hidden by default and shows correct value when enabled', function (): void { + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key'); + $tenantModel = config('eclipse-catalogue.tenancy.model'); + $currentTenantId = $tenantModel::first()->id; + + $taxClass = \Eclipse\Catalogue\Models\TaxClass::create([ + 'name' => 'Reduced', + 'rate' => 9.50, + 'is_default' => false, + $tenantFK => $currentTenantId, + ]); + + $product = Product::factory()->create(['name' => 'With Tax']); + ProductData::factory()->create([ + 'product_id' => $product->id, + $tenantFK => $currentTenantId, + 'tax_class_id' => $taxClass->id, + 'is_active' => true, + ]); + + expect($product->fresh()->taxClass()->name)->toBe('Reduced'); +}); + +it('filter returns correct products within tenant', function (): void { + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key'); + $tenantModel = config('eclipse-catalogue.tenancy.model'); + $currentTenantId = $tenantModel::first()->id; + + $standard = \Eclipse\Catalogue\Models\TaxClass::create([ + 'name' => 'Standard', + 'rate' => 22.00, + 'is_default' => false, + $tenantFK => $currentTenantId, + ]); + $reduced = \Eclipse\Catalogue\Models\TaxClass::create([ + 'name' => 'Reduced', + 'rate' => 9.50, + 'is_default' => false, + $tenantFK => $currentTenantId, + ]); + + $p1 = Product::factory()->create(['name' => 'P1']); + $p2 = Product::factory()->create(['name' => 'P2']); + $p3 = Product::factory()->create(['name' => 'P3']); + + ProductData::factory()->create(['product_id' => $p1->id, $tenantFK => $currentTenantId, 'tax_class_id' => $standard->id, 'is_active' => true]); + ProductData::factory()->create(['product_id' => $p2->id, $tenantFK => $currentTenantId, 'tax_class_id' => $reduced->id, 'is_active' => true]); + ProductData::factory()->create(['product_id' => $p3->id, $tenantFK => $currentTenantId, 'tax_class_id' => null, 'is_active' => true]); + + $productsWithStandardTax = Product::query() + ->whereHas('productData', function ($q) use ($standard, $tenantFK, $currentTenantId) { + $q->where('tax_class_id', $standard->id); + if ($tenantFK) { + $q->where($tenantFK, $currentTenantId); + } + }) + ->get(); + + expect($productsWithStandardTax)->toHaveCount(1); + expect($productsWithStandardTax->first()->name)->toBe('P1'); +}); diff --git a/tests/Feature/TaxClassPermissionTest.php b/tests/Feature/TaxClassPermissionTest.php index d26017e..3a446f0 100644 --- a/tests/Feature/TaxClassPermissionTest.php +++ b/tests/Feature/TaxClassPermissionTest.php @@ -3,10 +3,9 @@ use Eclipse\Catalogue\Filament\Resources\TaxClassResource\Pages\ListTaxClasses; use Eclipse\Catalogue\Models\TaxClass; use Eclipse\Catalogue\Policies\TaxClassPolicy; +use Livewire\Livewire; use Workbench\App\Models\User; -use function Pest\Livewire\livewire; - beforeEach(function () { $this->migrate(); $this->setUpSuperAdminAndTenant(); @@ -49,6 +48,7 @@ $this->setUpCommonUser(); // Create test tax class + $site = \Workbench\App\Models\Site::first(); $taxClass = TaxClass::create([ 'name' => 'Test Rate', 'description' => 'Test tax rate', @@ -62,24 +62,24 @@ // 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'); + // Test that user cannot view specific tax class + $policy = new TaxClassPolicy; + expect($policy->view($this->user, $taxClass))->toBeFalse(); + + // Test that user cannot create tax classes + expect($policy->create($this->user))->toBeFalse(); - // Edit tax class - livewire(ListTaxClasses::class) - ->assertCanSeeTableRecords([$taxClass]) - ->assertTableActionDisabled('edit', $taxClass); + // Test that user cannot update tax class + expect($policy->update($this->user, $taxClass))->toBeFalse(); - // Delete tax class - livewire(ListTaxClasses::class) - ->assertTableActionDisabled('delete', $taxClass); + // Test that user cannot delete tax class + expect($policy->delete($this->user, $taxClass))->toBeFalse(); - // Restore and force delete + // Test soft deletion $taxClass->delete(); $this->assertSoftDeleted($taxClass); - livewire(ListTaxClasses::class) + Livewire::test(ListTaxClasses::class) ->filterTable('trashed') ->assertTableActionExists('restore') ->assertTableActionExists('forceDelete')