diff --git a/app/Actions/Licenses/DeleteLicense.php b/app/Actions/Licenses/DeleteLicense.php new file mode 100644 index 00000000..51e5cf0f --- /dev/null +++ b/app/Actions/Licenses/DeleteLicense.php @@ -0,0 +1,25 @@ +anystack->deleteLicense($license->anystack_product_id, $license->anystack_id); + } + + return $license->delete(); + } +} diff --git a/app/Filament/Resources/LicenseResource.php b/app/Filament/Resources/LicenseResource.php index f76336db..696777cb 100644 --- a/app/Filament/Resources/LicenseResource.php +++ b/app/Filament/Resources/LicenseResource.php @@ -7,8 +7,6 @@ use App\Models\License; use Filament\Forms; use Filament\Forms\Form; -use Filament\Infolists; -use Filament\Infolists\Infolist; use Filament\Resources\Resource; use Filament\Tables; use Filament\Tables\Table; @@ -25,27 +23,39 @@ public static function form(Form $form): Form ->schema([ Forms\Components\Section::make('License Information') ->schema([ - Forms\Components\TextInput::make('id'), + Forms\Components\TextInput::make('id') + ->disabled(), Forms\Components\TextInput::make('anystack_id') - ->maxLength(36), + ->maxLength(36) + ->disabled(), Forms\Components\Select::make('user_id') - ->relationship('user', 'email'), + ->relationship('user', 'email') + ->searchable(['id', 'email']), + Forms\Components\Select::make('subscription_item_id') + ->relationship('subscriptionItem', 'id') + ->nullable() + ->searchable(), Forms\Components\TextInput::make('policy_name') - ->label('Plan'), - Forms\Components\TextInput::make('key'), - Forms\Components\DateTimePicker::make('expires_at'), - Forms\Components\DateTimePicker::make('created_at'), + ->label('Plan') + ->disabled(), + Forms\Components\TextInput::make('key') + ->disabled(), + Forms\Components\DateTimePicker::make('expires_at') + ->disabled(), + Forms\Components\DateTimePicker::make('created_at') + ->disabled(), Forms\Components\Toggle::make('is_suspended') - ->label('Suspended'), + ->label('Suspended') + ->disabled(), ]) - ->columns(2) - ->disabled(), + ->columns(2), ]); } public static function table(Table $table): Table { return $table + ->defaultSort('id', 'desc') ->columns([ Tables\Columns\TextColumn::make('id') ->sortable() @@ -75,7 +85,8 @@ public static function table(Table $table): Table ->sortable(), ]) ->filters([ - // + Tables\Filters\TernaryFilter::make('is_suspended') + ->label('Suspended'), ]) ->actions([ Tables\Actions\ActionGroup::make([ @@ -92,39 +103,10 @@ public static function table(Table $table): Table ->icon('heroicon-m-ellipsis-vertical'), ]) ->defaultPaginationPageOption(25) - ->bulkActions([]); - } - - public static function infolist(Infolist $infolist): Infolist - { - return $infolist - ->schema([ - Infolists\Components\Section::make('License Information') - ->schema([ - Infolists\Components\TextEntry::make('id') - ->copyable(), - Infolists\Components\TextEntry::make('anystack_id') - ->copyable(), - 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), - ]); + ->bulkActions([]) + ->recordUrl( + fn ($record) => static::getUrl('edit', ['record' => $record]) + ); } public static function getRelations(): array @@ -139,7 +121,7 @@ public static function getPages(): array { return [ 'index' => Pages\ListLicenses::route('/'), - 'view' => Pages\ViewLicense::route('/{record}'), + 'edit' => Pages\EditLicense::route('/{record}/edit'), ]; } } diff --git a/app/Filament/Resources/LicenseResource/Pages/EditLicense.php b/app/Filament/Resources/LicenseResource/Pages/EditLicense.php new file mode 100644 index 00000000..53f875df --- /dev/null +++ b/app/Filament/Resources/LicenseResource/Pages/EditLicense.php @@ -0,0 +1,107 @@ +label('Sync from Anystack') + ->icon('heroicon-o-arrow-path') + ->color('gray') + ->requiresConfirmation() + ->modalHeading('Sync License from Anystack') + ->modalDescription('This will retrieve the latest license data from Anystack and update the local record.') + ->modalSubmitActionLabel('Sync License') + ->visible(fn () => filled($this->record->anystack_id)) + ->action(function () { + try { + $response = app(Anystack::class)->getLicense($this->record->anystack_product_id, $this->record->anystack_id); + dispatch_sync(new UpsertLicenseFromAnystackLicense($response->json('data'))); + + Notification::make() + ->title('License synced') + ->body('The license data has been synced.') + ->success() + ->send(); + } catch (\Exception $e) { + Notification::make() + ->title('Error syncing license') + ->body('Failed to sync license from Anystack: '.$e->getMessage()) + ->danger() + ->send(); + } + }), + Actions\Action::make('suspend') + ->label('Suspend') + ->icon('heroicon-o-archive-box-x-mark') + ->color('danger') + ->requiresConfirmation() + ->modalHeading('Suspend') + ->modalDescription('Are you sure you want to suspend this license?') + ->modalSubmitActionLabel('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\Action::make('delete') + ->label('Delete') + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->modalHeading('Delete License') + ->modalDescription('Are you sure you want to delete this license?') + ->modalSubmitActionLabel('Delete') + ->form([ + Checkbox::make('delete_from_anystack') + ->label('Also delete from Anystack') + ->default(true), + ]) + ->action(function (array $data) { + try { + app(DeleteLicense::class)->handle($this->record, $data['delete_from_anystack']); + + Notification::make() + ->title('License deleted successfully') + ->success() + ->send(); + + $this->redirect(LicenseResource::getUrl('index')); + } catch (\Exception $e) { + Notification::make() + ->title('Error deleting license') + ->body('Failed to delete license: '.$e->getMessage()) + ->danger() + ->send(); + } + }), + ]) + ->label('Actions') + ->icon('heroicon-m-ellipsis-vertical'), + ]; + } +} diff --git a/app/Filament/Resources/LicenseResource/Pages/ListLicenses.php b/app/Filament/Resources/LicenseResource/Pages/ListLicenses.php index 915d1c39..f7777c5b 100644 --- a/app/Filament/Resources/LicenseResource/Pages/ListLicenses.php +++ b/app/Filament/Resources/LicenseResource/Pages/ListLicenses.php @@ -2,10 +2,69 @@ namespace App\Filament\Resources\LicenseResource\Pages; +use App\Enums\Subscription; use App\Filament\Resources\LicenseResource; +use App\Jobs\UpsertLicenseFromAnystackLicense; +use App\Models\License; +use App\Services\Anystack\Anystack; +use Filament\Actions; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\TextInput; +use Filament\Notifications\Notification; use Filament\Resources\Pages\ListRecords; class ListLicenses extends ListRecords { protected static string $resource = LicenseResource::class; + + protected function getHeaderActions(): array + { + return [ + Actions\Action::make('import_from_anystack') + ->label('Import from Anystack') + ->icon('heroicon-o-arrow-down-tray') + ->color('primary') + ->form([ + TextInput::make('anystack_id') + ->label('Anystack License UUID') + ->placeholder('Enter the Anystack license UUID') + ->required() + ->uuid() + ->helperText('Paste the license UUID from Anystack to import it into the system.'), + Select::make('subscription_item_id') + ->label('Subscription Item ID') + ->placeholder('Enter the subscription item ID') + ->nullable() + ->helperText('Provide the subscription item ID if this license relates to a subscription.') + ->relationship('subscriptionItem', 'id') + ->searchable(), + ]) + ->action(function (array $data) { + try { + $productId = Subscription::Mini->anystackProductId(); + $response = app(Anystack::class)->getLicense($productId, $data['anystack_id']); + $licenseData = $response->json('data'); + + dispatch_sync(new UpsertLicenseFromAnystackLicense($licenseData)); + + if (filled($data['subscription_item_id'] ?? null)) { + $license = License::where('anystack_id', $data['anystack_id'])->firstOrFail(); + $license->update(['subscription_item_id' => $data['subscription_item_id']]); + } + + Notification::make() + ->title('License Imported') + ->body('The license was imported.') + ->success() + ->send(); + } catch (\Exception $e) { + Notification::make() + ->title('Error importing license') + ->body('Failed to import license from Anystack: '.$e->getMessage()) + ->danger() + ->send(); + } + }), + ]; + } } diff --git a/app/Filament/Resources/LicenseResource/Pages/ViewLicense.php b/app/Filament/Resources/LicenseResource/Pages/ViewLicense.php deleted file mode 100644 index ccb71015..00000000 --- a/app/Filament/Resources/LicenseResource/Pages/ViewLicense.php +++ /dev/null @@ -1,46 +0,0 @@ -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/SubscriptionItemResource.php b/app/Filament/Resources/SubscriptionItemResource.php index 79c06988..9e5f39e8 100644 --- a/app/Filament/Resources/SubscriptionItemResource.php +++ b/app/Filament/Resources/SubscriptionItemResource.php @@ -45,12 +45,31 @@ public static function form(Form $form): Form public static function table(Table $table): Table { return $table + ->defaultSort('id', 'desc') ->columns([ Tables\Columns\TextColumn::make('id') - ->sortable(), + ->sortable() + ->searchable(), + Tables\Columns\TextColumn::make('subscription.user.email') + ->sortable() + ->searchable(), Tables\Columns\TextColumn::make('subscription.id') ->numeric() - ->sortable(), + ->sortable() + ->url(fn ($record) => SubscriptionResource::getUrl('view', ['record' => $record->subscription])), + Tables\Columns\TextColumn::make('subscription.stripe_status') + ->label('Subscription Status') + ->badge() + ->color(fn (string $state): string => match ($state) { + 'active' => 'success', + 'canceled' => 'danger', + 'incomplete' => 'warning', + 'incomplete_expired' => 'danger', + 'past_due' => 'warning', + 'trialing' => 'info', + 'unpaid' => 'danger', + default => 'gray', + }), Tables\Columns\TextColumn::make('stripe_id') ->searchable() ->copyable(), diff --git a/app/Filament/Resources/SubscriptionItemResource/Pages/ViewSubscriptionItem.php b/app/Filament/Resources/SubscriptionItemResource/Pages/ViewSubscriptionItem.php index c2f9ca11..6cceb0de 100644 --- a/app/Filament/Resources/SubscriptionItemResource/Pages/ViewSubscriptionItem.php +++ b/app/Filament/Resources/SubscriptionItemResource/Pages/ViewSubscriptionItem.php @@ -2,13 +2,25 @@ namespace App\Filament\Resources\SubscriptionItemResource\Pages; +use App\Enums\Subscription; use App\Enums\Subscription as SubscriptionEnum; use App\Filament\Resources\SubscriptionItemResource; +use App\Filament\Resources\SubscriptionResource; +use App\Filament\Resources\UserResource; +use App\Jobs\UpsertLicenseFromAnystackLicense; +use App\Models\License; +use App\Services\Anystack\Anystack; use Filament\Actions; +use Filament\Forms\Components\TextInput; use Filament\Infolists\Components; use Filament\Infolists\Infolist; +use Filament\Notifications\Notification; use Filament\Resources\Pages\ViewRecord; +use Laravel\Cashier\SubscriptionItem; +/** + * @property ?SubscriptionItem $record + */ class ViewSubscriptionItem extends ViewRecord { protected static string $resource = SubscriptionItemResource::class; @@ -16,6 +28,42 @@ class ViewSubscriptionItem extends ViewRecord protected function getHeaderActions(): array { return [ + Actions\Action::make('import_from_anystack') + ->label('Import Related License') + ->icon('heroicon-o-arrow-down-tray') + ->color('primary') + ->form([ + TextInput::make('anystack_id') + ->label('Anystack License UUID') + ->placeholder('Enter the Anystack license UUID') + ->required() + ->uuid() + ->helperText('Paste the license UUID from Anystack to import it into the system.'), + ]) + ->action(function (array $data) { + try { + $productId = Subscription::Mini->anystackProductId(); + $response = app(Anystack::class)->getLicense($productId, $data['anystack_id']); + $licenseData = $response->json('data'); + + dispatch_sync(new UpsertLicenseFromAnystackLicense($licenseData)); + + $license = License::where('anystack_id', $data['anystack_id'])->firstOrFail(); + $license->update(['subscription_item_id' => $this->record->id]); + + Notification::make() + ->title('License Imported') + ->body('The license was imported.') + ->success() + ->send(); + } catch (\Exception $e) { + Notification::make() + ->title('Error importing license') + ->body('Failed to import license from Anystack: '.$e->getMessage()) + ->danger() + ->send(); + } + }), Actions\Action::make('viewOnStripe') ->label('View on Stripe') ->color('gray') @@ -32,7 +80,11 @@ public function infolist(Infolist $infolist): Infolist Components\Section::make('Subscription Item Details') ->schema([ Components\TextEntry::make('subscription.id') - ->label('Subscription ID'), + ->label('Subscription ID') + ->url(fn ($record) => SubscriptionResource::getUrl('view', ['record' => $record->subscription_id])), + Components\TextEntry::make('subscription.user.email') + ->label('User') + ->url(fn ($record) => UserResource::getUrl('edit', ['record' => $record->subscription_id])), Components\TextEntry::make('stripe_id') ->label('Stripe ID') ->copyable(), diff --git a/app/Filament/Resources/SubscriptionItemResource/RelationManagers/LicensesRelationManager.php b/app/Filament/Resources/SubscriptionItemResource/RelationManagers/LicensesRelationManager.php index 0d06fc2f..48888287 100644 --- a/app/Filament/Resources/SubscriptionItemResource/RelationManagers/LicensesRelationManager.php +++ b/app/Filament/Resources/SubscriptionItemResource/RelationManagers/LicensesRelationManager.php @@ -2,12 +2,10 @@ namespace App\Filament\Resources\SubscriptionItemResource\RelationManagers; -use Filament\Forms; +use App\Filament\Resources\LicenseResource; use Filament\Forms\Form; use Filament\Resources\RelationManagers\RelationManager; -use Filament\Tables; use Filament\Tables\Table; -use Illuminate\Database\Eloquent\Builder; class LicensesRelationManager extends RelationManager { @@ -15,55 +13,11 @@ class LicensesRelationManager extends RelationManager public function form(Form $form): Form { - return $form - ->schema([ - Forms\Components\TextInput::make('key') - ->disabled(), - Forms\Components\TextInput::make('policy_name') - ->disabled(), - Forms\Components\Select::make('user_id') - ->relationship('user', 'email') - ->searchable() - ->disabled(), - Forms\Components\DateTimePicker::make('expires_at') - ->disabled(), - ]); + return LicenseResource::form($form); } public function table(Table $table): Table { - return $table - ->recordTitleAttribute('key') - ->columns([ - Tables\Columns\TextColumn::make('key') - ->searchable() - ->copyable(), - Tables\Columns\TextColumn::make('policy_name') - ->searchable(), - Tables\Columns\TextColumn::make('user.email') - ->searchable(), - Tables\Columns\TextColumn::make('expires_at') - ->dateTime() - ->sortable(), - Tables\Columns\TextColumn::make('created_at') - ->dateTime() - ->sortable() - ->toggleable(isToggledHiddenByDefault: true), - Tables\Columns\TextColumn::make('updated_at') - ->dateTime() - ->sortable() - ->toggleable(isToggledHiddenByDefault: true), - ]) - ->filters([ - Tables\Filters\Filter::make('active') - ->query(fn (Builder $query): Builder => $query->whereNull('expires_at')->orWhere('expires_at', '>', now())), - Tables\Filters\Filter::make('expired') - ->query(fn (Builder $query): Builder => $query->whereNotNull('expires_at')->where('expires_at', '<=', now())), - ]) - ->headerActions([]) - ->actions([ - Tables\Actions\ViewAction::make(), - ]) - ->bulkActions([]); + return LicenseResource::table($table); } } diff --git a/app/Filament/Resources/SubscriptionResource.php b/app/Filament/Resources/SubscriptionResource.php index 5b662ecd..d5898583 100644 --- a/app/Filament/Resources/SubscriptionResource.php +++ b/app/Filament/Resources/SubscriptionResource.php @@ -54,9 +54,11 @@ public static function form(Form $form): Form public static function table(Table $table): Table { return $table + ->defaultSort('id', 'desc') ->columns([ Tables\Columns\TextColumn::make('id') - ->sortable(), + ->sortable() + ->searchable(), Tables\Columns\TextColumn::make('user.email') ->searchable() ->sortable(), diff --git a/app/Filament/Resources/SubscriptionResource/Pages/ViewSubscription.php b/app/Filament/Resources/SubscriptionResource/Pages/ViewSubscription.php index d57321d3..95c8e835 100644 --- a/app/Filament/Resources/SubscriptionResource/Pages/ViewSubscription.php +++ b/app/Filament/Resources/SubscriptionResource/Pages/ViewSubscription.php @@ -4,6 +4,7 @@ use App\Enums\Subscription as SubscriptionEnum; use App\Filament\Resources\SubscriptionResource; +use App\Filament\Resources\UserResource; use Filament\Actions; use Filament\Infolists\Components; use Filament\Infolists\Infolist; @@ -32,7 +33,8 @@ public function infolist(Infolist $infolist): Infolist Components\Section::make('Subscription Details') ->schema([ Components\TextEntry::make('user.email') - ->label('User'), + ->label('User') + ->url(fn ($record) => UserResource::getUrl('edit', ['record' => $record->user_id])), Components\TextEntry::make('type'), Components\TextEntry::make('stripe_id') ->label('Stripe ID') diff --git a/app/Filament/Resources/SubscriptionResource/RelationManagers/SubscriptionItemsRelationManager.php b/app/Filament/Resources/SubscriptionResource/RelationManagers/SubscriptionItemsRelationManager.php index 86186ecf..aceb0934 100644 --- a/app/Filament/Resources/SubscriptionResource/RelationManagers/SubscriptionItemsRelationManager.php +++ b/app/Filament/Resources/SubscriptionResource/RelationManagers/SubscriptionItemsRelationManager.php @@ -2,11 +2,9 @@ namespace App\Filament\Resources\SubscriptionResource\RelationManagers; -use App\Enums\Subscription as SubscriptionEnum; -use Filament\Forms; +use App\Filament\Resources\SubscriptionItemResource; use Filament\Forms\Form; use Filament\Resources\RelationManagers\RelationManager; -use Filament\Tables; use Filament\Tables\Table; class SubscriptionItemsRelationManager extends RelationManager @@ -15,62 +13,11 @@ class SubscriptionItemsRelationManager extends RelationManager public function form(Form $form): Form { - return $form - ->schema([ - Forms\Components\TextInput::make('stripe_id') - ->disabled(), - Forms\Components\TextInput::make('stripe_product') - ->disabled(), - Forms\Components\TextInput::make('stripe_price') - ->disabled(), - Forms\Components\TextInput::make('quantity') - ->disabled(), - ]); + return SubscriptionItemResource::form($form); } public function table(Table $table): Table { - return $table - ->recordTitleAttribute('stripe_id') - ->columns([ - Tables\Columns\TextColumn::make('id') - ->sortable(), - Tables\Columns\TextColumn::make('stripe_id') - ->searchable() - ->copyable(), - Tables\Columns\TextColumn::make('stripe_product') - ->searchable() - ->copyable(), - Tables\Columns\TextColumn::make('stripe_price') - ->label('Plan') - ->formatStateUsing(function ($state) { - try { - return SubscriptionEnum::fromStripePriceId($state)->name(); - } catch (\Exception $e) { - return $state; - } - }) - ->searchable() - ->sortable(), - Tables\Columns\TextColumn::make('quantity') - ->numeric() - ->sortable(), - Tables\Columns\TextColumn::make('created_at') - ->dateTime() - ->sortable() - ->toggleable(isToggledHiddenByDefault: true), - Tables\Columns\TextColumn::make('updated_at') - ->dateTime() - ->sortable() - ->toggleable(isToggledHiddenByDefault: true), - ]) - ->filters([ - // - ]) - ->headerActions([]) - ->actions([ - Tables\Actions\ViewAction::make(), - ]) - ->bulkActions([]); + return SubscriptionItemResource::table($table); } } diff --git a/app/Filament/Resources/UserResource.php b/app/Filament/Resources/UserResource.php index 9a91205b..c6e1253a 100644 --- a/app/Filament/Resources/UserResource.php +++ b/app/Filament/Resources/UserResource.php @@ -59,6 +59,7 @@ public static function form(Form $form): Form public static function table(Table $table): Table { return $table + ->defaultSort('id', 'desc') ->columns([ Tables\Columns\TextColumn::make('id') ->sortable() diff --git a/app/Services/Anystack/Anystack.php b/app/Services/Anystack/Anystack.php index ab3c52ff..a6e4010f 100644 --- a/app/Services/Anystack/Anystack.php +++ b/app/Services/Anystack/Anystack.php @@ -35,4 +35,36 @@ public function suspendLicense(string $productId, string $licenseId): Response ]) ->throw(); } + + /** + * Delete 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 deleteLicense(string $productId, string $licenseId): Response + { + return $this->client() + ->delete("https://api.anystack.sh/v1/products/{$productId}/licenses/{$licenseId}") + ->throw(); + } + + /** + * Retrieve a license from 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 getLicense(string $productId, string $licenseId): Response + { + return $this->client() + ->get("https://api.anystack.sh/v1/products/{$productId}/licenses/{$licenseId}") + ->throw(); + } }