diff --git a/app/Filament/Resources/PackageResource.php b/app/Filament/Resources/PackageResource.php index 40a5099..56fda11 100644 --- a/app/Filament/Resources/PackageResource.php +++ b/app/Filament/Resources/PackageResource.php @@ -11,7 +11,11 @@ use Filament\Actions; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteAction; use Filament\Actions\DeleteBulkAction; +use Filament\Actions\ForceDeleteAction; +use Filament\Actions\ForceDeleteBulkAction; use Filament\Actions\EditAction; +use Filament\Actions\RestoreAction; +use Filament\Actions\RestoreBulkAction; use Filament\Actions\ViewAction; use Filament\Forms\Components\CheckboxList; use Filament\Forms\Components\MarkdownEditor; @@ -29,8 +33,12 @@ use Filament\Schemas\Schema; use Filament\Tables; use Filament\Tables\Columns\BadgeColumn; use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Table; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\SoftDeletingScope; use Illuminate\Support\Str; +use Illuminate\Validation\Rules\Unique; use UnitEnum; class PackageResource extends Resource @@ -86,7 +94,10 @@ class PackageResource extends Resource ->label('Slug') ->required() ->maxLength(191) - ->unique(ignoreRecord: true), + ->unique( + ignoreRecord: true, + modifyRuleUsing: fn (Unique $rule) => $rule->withoutTrashed() + ), Select::make('type') ->label('Paket-Typ') ->options([ @@ -272,6 +283,7 @@ class PackageResource extends Resource 'endcustomer' => 'Endkunde', 'reseller' => 'Reseller', ]), + TrashedFilter::make(), ]) ->actions([ Actions\Action::make('syncPaddle') @@ -305,15 +317,31 @@ class PackageResource extends Resource }), ViewAction::make(), EditAction::make(), - DeleteAction::make(), + DeleteAction::make() + ->visible(fn (Package $record) => ! $record->trashed()), + RestoreAction::make() + ->visible(fn (Package $record) => $record->trashed()), + ForceDeleteAction::make() + ->visible(fn (Package $record) => $record->trashed()) + ->requiresConfirmation(), ]) ->bulkActions([ BulkActionGroup::make([ DeleteBulkAction::make(), + RestoreBulkAction::make(), + ForceDeleteBulkAction::make()->requiresConfirmation(), ]), ]); } + public static function getEloquentQuery(): Builder + { + return parent::getEloquentQuery() + ->withoutGlobalScopes([ + SoftDeletingScope::class, + ]); + } + public static function getPages(): array { return [ diff --git a/app/Models/AbandonedCheckout.php b/app/Models/AbandonedCheckout.php index 6435a2c..a843999 100644 --- a/app/Models/AbandonedCheckout.php +++ b/app/Models/AbandonedCheckout.php @@ -36,7 +36,7 @@ class AbandonedCheckout extends Model public function package(): BelongsTo { - return $this->belongsTo(Package::class); + return $this->belongsTo(Package::class)->withTrashed(); } /** diff --git a/app/Models/CheckoutSession.php b/app/Models/CheckoutSession.php index 39213d4..95b1c6d 100644 --- a/app/Models/CheckoutSession.php +++ b/app/Models/CheckoutSession.php @@ -85,7 +85,7 @@ class CheckoutSession extends Model public function package(): BelongsTo { - return $this->belongsTo(Package::class); + return $this->belongsTo(Package::class)->withTrashed(); } public function scopeActive($query) diff --git a/app/Models/EventPackage.php b/app/Models/EventPackage.php index 96500ba..189e125 100644 --- a/app/Models/EventPackage.php +++ b/app/Models/EventPackage.php @@ -39,7 +39,7 @@ class EventPackage extends Model public function package(): BelongsTo { - return $this->belongsTo(Package::class); + return $this->belongsTo(Package::class)->withTrashed(); } public function isActive(): bool diff --git a/app/Models/Package.php b/app/Models/Package.php index 889aa1e..dae4041 100644 --- a/app/Models/Package.php +++ b/app/Models/Package.php @@ -6,10 +6,12 @@ use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\SoftDeletes; class Package extends Model { use HasFactory; + use SoftDeletes; protected $fillable = [ 'name', diff --git a/app/Models/PackagePurchase.php b/app/Models/PackagePurchase.php index 43d2028..f3126b6 100644 --- a/app/Models/PackagePurchase.php +++ b/app/Models/PackagePurchase.php @@ -46,7 +46,7 @@ class PackagePurchase extends Model public function package(): BelongsTo { - return $this->belongsTo(Package::class); + return $this->belongsTo(Package::class)->withTrashed(); } public function isEndcustomerEvent(): bool diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php index a928610..37a0f1c 100644 --- a/app/Models/Tenant.php +++ b/app/Models/Tenant.php @@ -222,7 +222,7 @@ class Tenant extends Model public function getActiveResellerPackage(): ?TenantPackage { return $this->activeResellerPackage() - ->whereHas('package', fn ($query) => $query->where('type', 'reseller')) + ->whereHas('package', fn ($query) => $query->withTrashed()->where('type', 'reseller')) ->where('active', true) ->orderByDesc('expires_at') ->first(); diff --git a/app/Models/TenantPackage.php b/app/Models/TenantPackage.php index c9399ef..8d460f4 100644 --- a/app/Models/TenantPackage.php +++ b/app/Models/TenantPackage.php @@ -44,7 +44,7 @@ class TenantPackage extends Model public function package(): BelongsTo { - return $this->belongsTo(Package::class); + return $this->belongsTo(Package::class)->withTrashed(); } public function isActive(): bool diff --git a/app/Services/Checkout/CheckoutWebhookService.php b/app/Services/Checkout/CheckoutWebhookService.php index 75e3646..4bb4556 100644 --- a/app/Services/Checkout/CheckoutWebhookService.php +++ b/app/Services/Checkout/CheckoutWebhookService.php @@ -335,7 +335,7 @@ class CheckoutWebhookService protected function resolvePackageFromSubscription(array $data, array $metadata, string $subscriptionId): ?Package { if (isset($metadata['package_id'])) { - $package = Package::find((int) $metadata['package_id']); + $package = Package::withTrashed()->find((int) $metadata['package_id']); if ($package) { return $package; } @@ -344,7 +344,7 @@ class CheckoutWebhookService $priceId = Arr::get($data, 'items.0.price_id') ?? Arr::get($data, 'items.0.price.id'); if ($priceId) { - $package = Package::where('paddle_price_id', $priceId)->first(); + $package = Package::withTrashed()->where('paddle_price_id', $priceId)->first(); if ($package) { return $package; } @@ -354,7 +354,7 @@ class CheckoutWebhookService $priceId = Arr::get($subscription, 'data.items.0.price_id') ?? Arr::get($subscription, 'data.items.0.price.id'); if ($priceId) { - return Package::where('paddle_price_id', $priceId)->first(); + return Package::withTrashed()->where('paddle_price_id', $priceId)->first(); } return null; diff --git a/database/migrations/2025_11_03_115406_add_soft_deletes_to_packages_table.php b/database/migrations/2025_11_03_115406_add_soft_deletes_to_packages_table.php new file mode 100644 index 0000000..9a5a4ff --- /dev/null +++ b/database/migrations/2025_11_03_115406_add_soft_deletes_to_packages_table.php @@ -0,0 +1,32 @@ +softDeletes(); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('packages', function (Blueprint $table) { + if (Schema::hasColumn('packages', 'deleted_at')) { + $table->dropSoftDeletes(); + } + }); + } +}; diff --git a/public/lang/de/marketing.json b/public/lang/de/marketing.json index 8db640d..420c4bb 100644 --- a/public/lang/de/marketing.json +++ b/public/lang/de/marketing.json @@ -10,7 +10,7 @@ "home": { "title": "Startseite - Fotospiel", "hero_tagline": "Eventfotos ohne App-Zwang", - "hero_title": "Dein Event. Eure Fotos. Echtzeit bereit.", + "hero_title": "Dein Event. Eure Fotos.", "hero_description": "Fotospiel bündelt QR-Zugänge, Live-Galerien und Moderation in einer einzigen Plattform – für Hochzeiten, Firmenfeiern und jedes Fest, das Erinnerungen verdient.", "hero_bullets": [ "Live-Galerie in Sekunden startklar", diff --git a/tests/Feature/Packages/PackageSoftDeleteTest.php b/tests/Feature/Packages/PackageSoftDeleteTest.php new file mode 100644 index 0000000..f167807 --- /dev/null +++ b/tests/Feature/Packages/PackageSoftDeleteTest.php @@ -0,0 +1,122 @@ +create(); + $package = Package::factory()->reseller()->create([ + 'max_events_per_year' => 5, + ]); + + $tenantPackage = TenantPackage::factory() + ->for($tenant) + ->for($package) + ->create([ + 'used_events' => 1, + 'active' => true, + ]); + + $purchase = PackagePurchase::factory() + ->for($tenant) + ->for($package) + ->create([ + 'type' => 'reseller_subscription', + ]); + + $package->delete(); + + $this->assertNull(Package::find($package->id)); + $this->assertTrue(Package::onlyTrashed()->where('id', $package->id)->exists()); + + $tenantPackage->refresh()->load('package'); + $this->assertNotNull($tenantPackage->package); + $this->assertTrue($tenantPackage->package->is($package)); + + $purchase->refresh()->load('package'); + $this->assertNotNull($purchase->package); + $this->assertTrue($purchase->package->is($package)); + + $tenant->refresh(); + $activePackage = $tenant->getActiveResellerPackage(); + $this->assertNotNull($activePackage); + $this->assertTrue($activePackage->is($tenantPackage)); + } + + public function test_paddle_subscription_event_handles_soft_deleted_package(): void + { + $tenant = Tenant::factory()->create(); + $package = Package::factory()->reseller()->create([ + 'price' => 29.00, + ]); + + $package->delete(); + + $sessionService = Mockery::mock(CheckoutSessionService::class); + $assignmentService = Mockery::mock(CheckoutAssignmentService::class); + $subscriptionService = Mockery::mock(PaddleSubscriptionService::class); + + $service = new CheckoutWebhookService( + $sessionService, + $assignmentService, + $subscriptionService + ); + + Carbon::setTestNow(now()); + + $event = [ + 'event_type' => 'subscription.updated', + 'data' => [ + 'id' => 'sub_123', + 'status' => 'active', + 'metadata' => [ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + ], + 'next_billing_date' => now()->addMonth()->toIso8601String(), + 'customer_id' => 'cus_456', + ], + ]; + + $this->assertTrue($service->handlePaddleEvent($event)); + + $tenantPackage = TenantPackage::where('tenant_id', $tenant->id) + ->where('package_id', $package->id) + ->first(); + + $this->assertNotNull($tenantPackage); + $this->assertNotNull($tenantPackage->package); + $this->assertTrue($tenantPackage->package->is($package)); + $this->assertSame('sub_123', $tenantPackage->paddle_subscription_id); + $this->assertTrue($tenantPackage->active); + + $tenant->refresh(); + $this->assertSame('active', $tenant->subscription_status); + $this->assertSame('cus_456', $tenant->paddle_customer_id); + } +}