From 6ecb7573460420b5eaf92d54f8b7af67e7b2ad66 Mon Sep 17 00:00:00 2001 From: Steven Fox <62109327+steven-fox@users.noreply.github.com> Date: Wed, 21 May 2025 13:00:05 -0400 Subject: [PATCH] add license suspension --- app/Actions/Licenses/SuspendLicense.php | 27 +++ app/Filament/Resources/LicenseResource.php | 194 ++++++------------ .../LicenseResource/Pages/ViewLicense.php | 46 +++++ .../SubscriptionItemRelationManager.php | 33 +++ .../RelationManagers/UserRelationManager.php | 35 ++++ .../Resources/SubscriptionItemResource.php | 10 +- .../Resources/SubscriptionResource.php | 10 +- app/Filament/Resources/UserResource.php | 5 +- app/Jobs/UpsertLicenseFromAnystackLicense.php | 1 + app/Models/License.php | 7 + app/Services/Anystack/Anystack.php | 38 ++++ database/factories/LicenseFactory.php | 1 + ...321_add_is_suspended_to_licenses_table.php | 28 +++ .../Actions/Licenses/SuspendLicenseTest.php | 67 ++++++ .../Jobs/CreateAnystackLicenseJobTest.php | 2 + .../UpsertLicenseFromAnystackLicenseTest.php | 54 +++++ tests/Feature/Services/AnystackTest.php | 53 +++++ 17 files changed, 469 insertions(+), 142 deletions(-) create mode 100644 app/Actions/Licenses/SuspendLicense.php create mode 100644 app/Filament/Resources/LicenseResource/Pages/ViewLicense.php create mode 100644 app/Filament/Resources/LicenseResource/RelationManagers/SubscriptionItemRelationManager.php create mode 100644 app/Filament/Resources/LicenseResource/RelationManagers/UserRelationManager.php create mode 100644 app/Services/Anystack/Anystack.php create mode 100644 database/migrations/2025_05_20_163321_add_is_suspended_to_licenses_table.php create mode 100644 tests/Feature/Actions/Licenses/SuspendLicenseTest.php create mode 100644 tests/Feature/Jobs/UpsertLicenseFromAnystackLicenseTest.php create mode 100644 tests/Feature/Services/AnystackTest.php diff --git a/app/Actions/Licenses/SuspendLicense.php b/app/Actions/Licenses/SuspendLicense.php new file mode 100644 index 00000000..4241712a --- /dev/null +++ b/app/Actions/Licenses/SuspendLicense.php @@ -0,0 +1,27 @@ +anystack->suspendLicense($license->anystack_product_id, $license->anystack_id); + + $license->update([ + 'is_suspended' => true, + ]); + + return $license; + } +} diff --git a/app/Filament/Resources/LicenseResource.php b/app/Filament/Resources/LicenseResource.php index 0c87b85b..f76336db 100644 --- a/app/Filament/Resources/LicenseResource.php +++ b/app/Filament/Resources/LicenseResource.php @@ -3,10 +3,11 @@ namespace App\Filament\Resources; use App\Filament\Resources\LicenseResource\Pages; +use App\Filament\Resources\LicenseResource\RelationManagers; use App\Models\License; use Filament\Forms; use Filament\Forms\Form; -use Filament\Infolists\Components; +use Filament\Infolists; use Filament\Infolists\Infolist; use Filament\Resources\Resource; use Filament\Tables; @@ -24,24 +25,21 @@ public static function form(Form $form): Form ->schema([ Forms\Components\Section::make('License Information') ->schema([ - Forms\Components\TextInput::make('id') - ->disabled(), + Forms\Components\TextInput::make('id'), Forms\Components\TextInput::make('anystack_id') - ->maxLength(36) - ->disabled(), + ->maxLength(36), Forms\Components\Select::make('user_id') - ->relationship('user', 'email') - ->disabled(), + ->relationship('user', 'email'), Forms\Components\TextInput::make('policy_name') - ->label('Plan') - ->disabled(), - Forms\Components\TextInput::make('key') - ->disabled(), - Forms\Components\DateTimePicker::make('expires_at') - ->disabled(), - Forms\Components\DateTimePicker::make('created_at') - ->disabled(), - ])->columns(2), + ->label('Plan'), + Forms\Components\TextInput::make('key'), + Forms\Components\DateTimePicker::make('expires_at'), + Forms\Components\DateTimePicker::make('created_at'), + Forms\Components\Toggle::make('is_suspended') + ->label('Suspended'), + ]) + ->columns(2) + ->disabled(), ]); } @@ -54,10 +52,11 @@ public static function table(Table $table): Table ->searchable(), Tables\Columns\TextColumn::make('user.email') ->searchable() - ->sortable() - ->copyable() - ->url(fn (\App\Models\License $record): string => route('filament.admin.resources.users.edit', ['record' => $record->user_id])) - ->openUrlInNewTab(), + ->sortable(), + Tables\Columns\TextColumn::make('subscription_item_id') + ->label('Subscription Item') + ->searchable() + ->sortable(), Tables\Columns\TextColumn::make('key') ->searchable() ->copyable(), @@ -70,138 +69,77 @@ public static function table(Table $table): Table Tables\Columns\TextColumn::make('created_at') ->dateTime() ->sortable(), + Tables\Columns\IconColumn::make('is_suspended') + ->boolean() + ->label('Suspended') + ->sortable(), ]) ->filters([ // ]) ->actions([ - Tables\Actions\ViewAction::make() - ->slideOver() - ->modalHeading('License Details') - ->modalWidth('7xl') - ->extraModalFooterActions([ - Tables\Actions\Action::make('viewUser') - ->label('View User') - ->icon('heroicon-o-user') - ->color('primary') - ->url(fn (License $record) => route('filament.admin.resources.users.edit', ['record' => $record->user_id])) - ->openUrlInNewTab() - ->visible(fn (License $record) => $record->user_id !== null), - ]), + Tables\Actions\ActionGroup::make([ + Tables\Actions\EditAction::make(), + Tables\Actions\Action::make('viewUser') + ->label('View User') + ->icon('heroicon-o-user') + ->color('primary') + ->url(fn (License $record) => route('filament.admin.resources.users.edit', ['record' => $record->user_id])) + ->openUrlInNewTab() + ->visible(fn (License $record) => $record->user_id !== null), + ]) + ->label('Actions') + ->icon('heroicon-m-ellipsis-vertical'), ]) ->defaultPaginationPageOption(25) ->bulkActions([]); } - public static function getRelations(): array - { - return [ - // - ]; - } - public static function infolist(Infolist $infolist): Infolist { return $infolist ->schema([ - Components\Section::make('License Information') + Infolists\Components\Section::make('License Information') ->schema([ - Components\TextEntry::make('id'), - Components\TextEntry::make('key') + Infolists\Components\TextEntry::make('id') ->copyable(), - Components\TextEntry::make('policy_name') - ->label('Plan'), - Components\TextEntry::make('expires_at') - ->dateTime(), - Components\TextEntry::make('created_at') - ->dateTime(), - Components\TextEntry::make('anystack_id') + Infolists\Components\TextEntry::make('anystack_id') ->copyable(), - ])->columns(2), - - Components\Section::make('User Information') - ->schema([ - Components\TextEntry::make('user.id') - ->label('User ID') - ->url(fn ($record) => route('filament.admin.resources.users.edit', ['record' => $record->user_id])) - ->openUrlInNewTab(), - Components\TextEntry::make('user.email') - ->label('Email') - ->copyable() - ->url(fn ($record) => route('filament.admin.resources.users.edit', ['record' => $record->user_id])) - ->openUrlInNewTab(), - Components\TextEntry::make('user.name') - ->label('Name'), - Components\TextEntry::make('user.first_name') - ->label('First Name'), - Components\TextEntry::make('user.last_name') - ->label('Last Name'), - Components\TextEntry::make('user.stripe_id') - ->label('Stripe ID') - ->copyable() - ->visible(fn ($record) => filled($record->user->stripe_id)) - ->url(fn ($record) => filled($record->user->stripe_id) - ? "https://dashboard.stripe.com/customers/{$record->user->stripe_id}" - : null) - ->openUrlInNewTab(), - Components\TextEntry::make('user.anystack_contact_id') - ->label('Anystack Contact ID') - ->copyable() - ->visible(fn ($record) => filled($record->user->anystack_contact_id)) - ->url(fn ($record) => filled($record->user->anystack_contact_id) - ? "https://app.anystack.sh/contacts/{$record->user->anystack_contact_id}" - : null) - ->openUrlInNewTab(), - ])->columns(2), - - Components\Section::make('Subscription Information') - ->schema([ - Components\TextEntry::make('subscriptionItem.id') - ->label('Subscription Item ID') - ->visible(fn ($record) => $record->subscription_item_id !== null), - Components\TextEntry::make('subscriptionItem.stripe_id') - ->label('Stripe Subscription Item ID') - ->copyable() - ->visible(fn ($record) => $record->subscription_item_id !== null), - Components\TextEntry::make('subscriptionItem.subscription.stripe_id') - ->label('Stripe Subscription ID') - ->copyable() - ->visible(fn ($record) => $record->subscription_item_id !== null) - ->url(fn ($record) => $record->subscription_item_id !== null && filled($record->subscriptionItem?->subscription?->stripe_id) - ? "https://dashboard.stripe.com/subscriptions/{$record->subscriptionItem->subscription->stripe_id}" - : null) - ->openUrlInNewTab(), - Components\TextEntry::make('subscriptionItem.stripe_price') - ->label('Stripe Price ID') - ->copyable() - ->visible(fn ($record) => $record->subscription_item_id !== null), - Components\TextEntry::make('subscriptionItem.stripe_product') - ->label('Stripe Product ID') - ->copyable() - ->visible(fn ($record) => $record->subscription_item_id !== null), - Components\TextEntry::make('subscriptionItem.subscription.stripe_status') - ->label('Subscription Status') - ->badge() - ->color(fn ($state): string => match ($state) { - 'active' => 'success', - 'canceled' => 'danger', - 'incomplete' => 'warning', - 'incomplete_expired' => 'danger', - 'past_due' => 'warning', - 'trialing' => 'info', - 'unpaid' => 'danger', - default => 'gray', - }) - ->visible(fn ($record) => $record->subscription_item_id !== null), - ])->columns(2) - ->visible(fn ($record) => $record->subscription_item_id !== null), + Infolists\Components\TextEntry::make('user.email') + ->label('User') + ->copyable(), + Infolists\Components\TextEntry::make('policy_name') + ->label('Plan') + ->copyable(), + Infolists\Components\TextEntry::make('key') + ->copyable(), + Infolists\Components\TextEntry::make('expires_at') + ->dateTime() + ->copyable(), + Infolists\Components\TextEntry::make('created_at') + ->dateTime() + ->copyable(), + Infolists\Components\IconEntry::make('is_suspended') + ->label('Suspended') + ->boolean(), + ]) + ->columns(2), ]); } + public static function getRelations(): array + { + return [ + RelationManagers\UserRelationManager::class, + RelationManagers\SubscriptionItemRelationManager::class, + ]; + } + public static function getPages(): array { return [ 'index' => Pages\ListLicenses::route('/'), + 'view' => Pages\ViewLicense::route('/{record}'), ]; } } diff --git a/app/Filament/Resources/LicenseResource/Pages/ViewLicense.php b/app/Filament/Resources/LicenseResource/Pages/ViewLicense.php new file mode 100644 index 00000000..ccb71015 --- /dev/null +++ b/app/Filament/Resources/LicenseResource/Pages/ViewLicense.php @@ -0,0 +1,46 @@ +label('Suspend License') + ->icon('heroicon-o-archive-box-x-mark') + ->color('danger') + ->requiresConfirmation() + ->modalHeading('Suspend License') + ->modalDescription('Are you sure you want to suspend this license? This will prevent the user from using the software.') + ->modalSubmitActionLabel('Yes, suspend license') + ->visible(fn () => ! $this->record->is_suspended) + ->action(function () { + app(SuspendLicense::class)->handle($this->record); + + Notification::make() + ->title('License suspended successfully') + ->success() + ->send(); + }), + Actions\DeleteAction::make(), + ]) + ->label('Actions') + ->icon('heroicon-m-ellipsis-vertical'), + ]; + } +} diff --git a/app/Filament/Resources/LicenseResource/RelationManagers/SubscriptionItemRelationManager.php b/app/Filament/Resources/LicenseResource/RelationManagers/SubscriptionItemRelationManager.php new file mode 100644 index 00000000..175aa11b --- /dev/null +++ b/app/Filament/Resources/LicenseResource/RelationManagers/SubscriptionItemRelationManager.php @@ -0,0 +1,33 @@ +sortable() ->toggleable(isToggledHiddenByDefault: true), ]) - ->filters([ - // - ]) ->actions([ - Tables\Actions\ViewAction::make(), Tables\Actions\Action::make('view_on_stripe') ->label('View on Stripe') ->color('gray') @@ -92,9 +88,9 @@ public static function table(Table $table): Table ->url(fn (SubscriptionItem $record) => 'https://dashboard.stripe.com/subscriptions/'.$record->subscription->stripe_id) ->openUrlInNewTab(), ]) - ->bulkActions([ - // - ]); + ->recordUrl( + fn ($record) => static::getUrl('view', ['record' => $record]) + ); } public static function getRelations(): array diff --git a/app/Filament/Resources/SubscriptionResource.php b/app/Filament/Resources/SubscriptionResource.php index e40b8e30..5b662ecd 100644 --- a/app/Filament/Resources/SubscriptionResource.php +++ b/app/Filament/Resources/SubscriptionResource.php @@ -60,8 +60,6 @@ public static function table(Table $table): Table Tables\Columns\TextColumn::make('user.email') ->searchable() ->sortable(), - // Tables\Columns\TextColumn::make('type') - // ->searchable(), Tables\Columns\TextColumn::make('stripe_id') ->searchable() ->copyable(), @@ -125,7 +123,6 @@ public static function table(Table $table): Table ->query(fn (Builder $query): Builder => $query->where('stripe_status', 'canceled')), ]) ->actions([ - Tables\Actions\ViewAction::make(), Tables\Actions\Action::make('view_on_stripe') ->label('View on Stripe') ->color('gray') @@ -133,9 +130,10 @@ public static function table(Table $table): Table ->url(fn (Subscription $record) => 'https://dashboard.stripe.com/subscriptions/'.$record->stripe_id) ->openUrlInNewTab(), ]) - ->bulkActions([ - // - ]); + ->recordUrl( + fn ($record) => static::getUrl('view', ['record' => $record]) + ); + } public static function getRelations(): array diff --git a/app/Filament/Resources/UserResource.php b/app/Filament/Resources/UserResource.php index 97ad4b3a..9a91205b 100644 --- a/app/Filament/Resources/UserResource.php +++ b/app/Filament/Resources/UserResource.php @@ -108,7 +108,10 @@ public static function table(Table $table): Table Tables\Actions\BulkActionGroup::make([ Tables\Actions\DeleteBulkAction::make(), ]), - ]); + ]) + ->recordUrl( + fn ($record) => static::getUrl('edit', ['record' => $record]) + ); } public static function getRelations(): array diff --git a/app/Jobs/UpsertLicenseFromAnystackLicense.php b/app/Jobs/UpsertLicenseFromAnystackLicense.php index 7a86e830..113a125c 100644 --- a/app/Jobs/UpsertLicenseFromAnystackLicense.php +++ b/app/Jobs/UpsertLicenseFromAnystackLicense.php @@ -32,6 +32,7 @@ protected function values(): array 'anystack_id' => $this->licenseData['id'], // subscription_item_id is not set here because we don't want to replace any existing values. 'policy_name' => Subscription::fromAnystackPolicy($this->licenseData['policy_id'])->value, + 'is_suspended' => $this->licenseData['suspended'], 'expires_at' => $this->licenseData['expires_at'], 'created_at' => $this->licenseData['created_at'], 'updated_at' => $this->licenseData['updated_at'], diff --git a/app/Models/License.php b/app/Models/License.php index ecc14351..597d5d8e 100644 --- a/app/Models/License.php +++ b/app/Models/License.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Enums\Subscription; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -16,6 +17,7 @@ class License extends Model protected $casts = [ 'expires_at' => 'datetime', + 'is_suspended' => 'boolean', ]; /** @@ -41,4 +43,9 @@ public function scopeWhereActive(Builder $builder): Builder ->orWhere('expires_at', '>', now()) ); } + + public function getAnystackProductIdAttribute(): string + { + return Subscription::from($this->policy_name)->anystackProductId(); + } } diff --git a/app/Services/Anystack/Anystack.php b/app/Services/Anystack/Anystack.php new file mode 100644 index 00000000..ab3c52ff --- /dev/null +++ b/app/Services/Anystack/Anystack.php @@ -0,0 +1,38 @@ +acceptJson() + ->asJson(); + } + + /** + * Suspend a license on AnyStack. + * + * @param string $productId The AnyStack product ID + * @param string $licenseId The AnyStack license ID + * @return Response The API response + * + * @throws \Illuminate\Http\Client\RequestException If the request fails + */ + public function suspendLicense(string $productId, string $licenseId): Response + { + return $this->client() + ->patch("https://api.anystack.sh/v1/products/{$productId}/licenses/{$licenseId}", [ + 'suspended' => true, + ]) + ->throw(); + } +} diff --git a/database/factories/LicenseFactory.php b/database/factories/LicenseFactory.php index 9a1bd63f..60d00e8f 100644 --- a/database/factories/LicenseFactory.php +++ b/database/factories/LicenseFactory.php @@ -24,6 +24,7 @@ public function definition(): array 'subscription_item_id' => SubscriptionItem::factory(), 'policy_name' => fake()->randomElement(Subscription::cases())->value, 'key' => fake()->uuid(), + 'is_suspended' => false, 'created_at' => fake()->dateTimeBetween('-1 year', 'now'), 'updated_at' => fn (array $attrs) => $attrs['created_at'], 'expires_at' => fn (array $attrs) => Date::parse($attrs['created_at'])->addYear(), diff --git a/database/migrations/2025_05_20_163321_add_is_suspended_to_licenses_table.php b/database/migrations/2025_05_20_163321_add_is_suspended_to_licenses_table.php new file mode 100644 index 00000000..6a9dcbc1 --- /dev/null +++ b/database/migrations/2025_05_20_163321_add_is_suspended_to_licenses_table.php @@ -0,0 +1,28 @@ +boolean('is_suspended')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('licenses', function (Blueprint $table) { + $table->dropColumn('is_suspended'); + }); + } +}; diff --git a/tests/Feature/Actions/Licenses/SuspendLicenseTest.php b/tests/Feature/Actions/Licenses/SuspendLicenseTest.php new file mode 100644 index 00000000..4f04c869 --- /dev/null +++ b/tests/Feature/Actions/Licenses/SuspendLicenseTest.php @@ -0,0 +1,67 @@ + Http::response([ + 'data' => [ + 'id' => 'license-123', + 'suspended' => true, + ], + ], 200), + ]); + + $license = License::factory()->create([ + 'anystack_id' => 'license-123', + 'policy_name' => 'max', + 'is_suspended' => false, + ]); + + $action = app(SuspendLicense::class); + $result = $action->handle($license); + + $this->assertTrue($license->fresh()->is_suspended); + + Http::assertSent(function ($request) use ($license) { + return str_contains($request->url(), '/products/') && + str_contains($request->url(), "/licenses/{$license->anystack_id}") && + $request->method() === 'PATCH' && + $request->data() === ['suspended' => true]; + }); + } + + #[Test] + public function it_fails_when_api_call_fails() + { + Http::fake([ + 'https://api.anystack.sh/v1/products/*/licenses/*' => Http::response([], 500), + ]); + + $license = License::factory()->create([ + 'anystack_id' => 'license-123', + 'policy_name' => 'max', + 'is_suspended' => false, + ]); + + $this->expectException(\Illuminate\Http\Client\RequestException::class); + + $action = app(SuspendLicense::class); + $result = $action->handle($license); + + $this->assertFalse($license->fresh()->is_suspended); + } +} diff --git a/tests/Feature/Jobs/CreateAnystackLicenseJobTest.php b/tests/Feature/Jobs/CreateAnystackLicenseJobTest.php index 6fad58d9..8a269cba 100644 --- a/tests/Feature/Jobs/CreateAnystackLicenseJobTest.php +++ b/tests/Feature/Jobs/CreateAnystackLicenseJobTest.php @@ -145,6 +145,7 @@ public function it_stores_the_license_key_in_database() 'subscription_item_id' => null, 'policy_name' => 'max', 'key' => 'test-license-key-12345', + 'is_suspended' => false, 'expires_at' => $this->now->addYear(), 'created_at' => $this->now, 'updated_at' => $this->now, @@ -174,6 +175,7 @@ public function the_subscription_item_id_is_filled_when_provided() 'subscription_item_id' => 123, 'policy_name' => 'max', 'key' => 'test-license-key-12345', + 'is_suspended' => false, 'expires_at' => $this->now->addYear(), 'created_at' => $this->now, 'updated_at' => $this->now, diff --git a/tests/Feature/Jobs/UpsertLicenseFromAnystackLicenseTest.php b/tests/Feature/Jobs/UpsertLicenseFromAnystackLicenseTest.php new file mode 100644 index 00000000..e916c408 --- /dev/null +++ b/tests/Feature/Jobs/UpsertLicenseFromAnystackLicenseTest.php @@ -0,0 +1,54 @@ +create([ + 'anystack_contact_id' => 'contact-123', + ]); + + $now = Date::now()->toImmutable(); + + $licenseData = [ + 'id' => 'license-123', + 'key' => 'test-license-key-12345', + 'contact_id' => 'contact-123', + 'policy_id' => Subscription::Mini->anystackPolicyId(), + 'name' => null, + 'activations' => 0, + 'max_activations' => 10, + 'suspended' => true, + 'expires_at' => $now->addYear()->toIso8601String(), + 'created_at' => $now->toIso8601String(), + 'updated_at' => $now->toIso8601String(), + ]; + + $job = new UpsertLicenseFromAnystackLicense($licenseData); + $job->handle(); + + $this->assertDatabaseHas('licenses', [ + 'anystack_id' => 'license-123', + 'user_id' => $user->id, + 'key' => 'test-license-key-12345', + 'policy_name' => Subscription::Mini->value, + 'is_suspended' => true, + 'expires_at' => $now->addYear(), + 'created_at' => $now, + 'updated_at' => $now, + ]); + } +} diff --git a/tests/Feature/Services/AnystackTest.php b/tests/Feature/Services/AnystackTest.php new file mode 100644 index 00000000..c90de971 --- /dev/null +++ b/tests/Feature/Services/AnystackTest.php @@ -0,0 +1,53 @@ + Http::response([ + 'data' => [ + 'id' => 'license-123', + 'suspended' => true, + ], + ], 200), + ]); + + $anystack = new Anystack; + $response = $anystack->suspendLicense('product-123', 'license-123'); + + $this->assertEquals(200, $response->status()); + $this->assertEquals('license-123', $response->json('data.id')); + $this->assertTrue($response->json('data.suspended')); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/products/product-123/licenses/license-123') && + $request->method() === 'PATCH' && + $request->data() === ['suspended' => true]; + }); + } + + #[Test] + public function it_throws_exception_when_api_call_fails() + { + Http::fake([ + 'https://api.anystack.sh/v1/products/*/licenses/*' => Http::response([], 500), + ]); + + $this->expectException(\Illuminate\Http\Client\RequestException::class); + + $anystack = new Anystack; + $anystack->suspendLicense('product-123', 'license-123'); + } +}