diff --git a/app/Filament/Pages/GenerateActivationKeys.php b/app/Filament/Pages/GenerateActivationKeys.php index 3bdd1e8..839d375 100644 --- a/app/Filament/Pages/GenerateActivationKeys.php +++ b/app/Filament/Pages/GenerateActivationKeys.php @@ -2,27 +2,28 @@ namespace App\Filament\Pages; -use BackedEnum; -use UnitEnum; -use Illuminate\Support\Str; -use Symfony\Component\HttpFoundation\BinaryFileResponse; use App\Models\ActivationKey; use App\Models\Plan; +use BackedEnum; use Filament\Actions\BulkAction; use Filament\Forms\Components\Select; +use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Forms\Concerns\InteractsWithForms; use Filament\Forms\Contracts\HasForms; use Filament\Notifications\Notification; use Filament\Pages\Page; -use Filament\Tables\Columns\BooleanColumn; +use Filament\Tables\Columns\BadgeColumn; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Concerns\InteractsWithTable; use Filament\Tables\Contracts\HasTable; use Filament\Tables\Filters\SelectFilter; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; -use Response; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; +use Symfony\Component\HttpFoundation\BinaryFileResponse; +use UnitEnum; class GenerateActivationKeys extends Page implements HasForms, HasTable { @@ -40,6 +41,8 @@ class GenerateActivationKeys extends Page implements HasForms, HasTable public $quantity = 1; + public $notes; + public function mount(): void { $this->form->fill(); @@ -50,36 +53,103 @@ class GenerateActivationKeys extends Page implements HasForms, HasTable return [ Select::make('plan_id') ->label('Select Plan') - ->options(Plan::all()->pluck('name', 'id')) - ->required(), + ->options(function () { + return Plan::with('planTier') + ->get() + ->map(function ($plan) { + $tierName = $plan->planTier ? $plan->planTier->name : ''; + $billingCycle = $plan->getBillingCycleDisplay(); + $name = $plan->name; + + if ($tierName) { + $name .= " ({$tierName})"; + } + if ($billingCycle) { + $name .= " - {$billingCycle}"; + } + + return $name; + }) + ->toArray(); + }) + ->required() + ->reactive() + ->afterStateUpdated(fn ($state, callable $set) => $set('quantity', 1)), TextInput::make('quantity') + ->label('Number of Keys') ->numeric() ->minValue(1) ->maxValue(100) ->default(1) - ->required(), + ->required() + ->helperText('Generate multiple keys for bulk distribution'), + + Textarea::make('notes') + ->label('Generation Notes (Optional)') + ->rows(2) + ->placeholder('Add notes about this batch of keys...') + ->helperText('These notes will be stored with the generated keys for tracking purposes'), ]; } public function generate(): void { - $data = $this->form->getState(); - $plan = Plan::query()->findOrFail($data['plan_id']); + try { + $data = $this->form->getState(); + $plan = Plan::with('planTier')->findOrFail($data['plan_id']); - for ($i = 0; $i < $data['quantity']; $i++) { - ActivationKey::query()->create([ - 'price_id' => $plan->pricing_id, - 'activation_key' => strtoupper('Z'.Str::random(16)), - 'is_activated' => false, + $generatedKeys = []; + $batchId = 'BATCH_'.now()->format('YmdHis').'_'.Str::random(8); + + for ($i = 0; $i < $data['quantity']; $i++) { + $activationKey = ActivationKey::query()->create([ + 'price_id' => $plan->pricing_id, + 'activation_key' => $this->generateUniqueActivationKey($plan), + 'is_activated' => false, + ]); + + $generatedKeys[] = $activationKey->activation_key; + } + + // Log the generation for audit purposes + \Log::info('Activation keys generated', [ + 'batch_id' => $batchId, + 'plan_id' => $plan->id, + 'plan_name' => $plan->name, + 'quantity' => $data['quantity'], + 'generated_by' => auth()->id(), + 'notes' => $data['notes'] ?? null, ]); + + Notification::make() + ->title("{$data['quantity']} activation key(s) generated successfully") + ->body("For {$plan->name} - Batch ID: {$batchId}") + ->success() + ->send(); + + $this->form->fill(); // Reset form + } catch (\Exception $exception) { + $this->form->fill(); + Notification::make() + ->title('Something went wrong') + ->body("Error: {$exception->getMessage()}") + ->danger() + ->send(); } - Notification::make() - ->title("{$data['quantity']} activation key(s) generated.") - ->success() - ->send(); - $this->form->fill(); // Reset form + } + + private function generateUniqueActivationKey(Plan $plan): string + { + do { + // Create a more structured key format: PLAN_PREFIX + RANDOM + $prefix = strtoupper(substr(str_replace([' ', '-'], '', $plan->name), 0, 3)); + $random = strtoupper(Str::random(13)); + $key = $prefix.$random; + } while (ActivationKey::where('activation_key', $key)->exists()); + + return $key; } // === Table Setup === @@ -92,24 +162,55 @@ class GenerateActivationKeys extends Page implements HasForms, HasTable { return [ TextColumn::make('activation_key') - ->label('Key') - ->copyable(), + ->label('Activation Key') + ->copyable() + ->searchable() + ->weight('font-semibold'), - BooleanColumn::make('is_activated'), + BadgeColumn::make('status') + ->label('Status') + ->getStateUsing(function ($record): string { + return $record->is_activated ? 'Activated' : 'Unused'; + }) + ->colors([ + 'success' => 'Activated', + 'warning' => 'Unused', + ]), + + TextColumn::make('plan.name') + ->label('Plan') + ->getStateUsing(function ($record): string { + $plan = Plan::where('pricing_id', $record->price_id)->first(); + + return $plan ? $plan->name : 'Unknown Plan'; + }) + ->searchable(), + + TextColumn::make('plan.planTier.name') + ->label('Tier') + ->getStateUsing(function ($record): ?string { + $plan = Plan::where('pricing_id', $record->price_id)->first(); + + return $plan && $plan->planTier ? $plan->planTier->name : null; + }), TextColumn::make('user.email') - ->label('Activated By'), + ->label('Activated By') + ->default('Not activated') + ->searchable(), - TextColumn::make('billing_interval') - ->label('Interval') + TextColumn::make('billing_cycle') + ->label('Billing Cycle') ->getStateUsing(function ($record): string { - $isMonthly = Plan::query()->where('pricing_id', $record->price_id)->value('monthly_billing'); + $plan = Plan::where('pricing_id', $record->price_id)->first(); - return $isMonthly ? 'Monthly' : 'Yearly'; + return $plan ? $plan->getBillingCycleDisplay() : 'Unknown'; }), TextColumn::make('created_at') - ->dateTime(), + ->label('Generated') + ->dateTime() + ->sortable(), ]; } @@ -117,15 +218,48 @@ class GenerateActivationKeys extends Page implements HasForms, HasTable { return [ SelectFilter::make('is_activated') + ->label('Status') ->options([ true => 'Activated', false => 'Not Activated', ]), + SelectFilter::make('price_id') ->label('Plan') - ->options( - Plan::query()->pluck('name', 'pricing_id') - ), + ->options(function () { + return Plan::with('planTier') + ->get() + ->mapWithKeys(function ($plan) { + $tierName = $plan->planTier ? " ({$plan->planTier->name})" : ''; + + return [$plan->pricing_id => $plan->name.$tierName]; + }) + ->toArray(); + }), + + SelectFilter::make('created_at') + ->label('Generation Date') + ->options([ + 'today' => 'Today', + 'this_week' => 'This Week', + 'this_month' => 'This Month', + 'last_month' => 'Last Month', + ]) + ->query(function (Builder $query, array $data): Builder { + if ($data['value'] === 'today') { + return $query->whereDate('created_at', today()); + } elseif ($data['value'] === 'this_week') { + return $query->whereBetween('created_at', [now()->startOfWeek(), now()->endOfWeek()]); + } elseif ($data['value'] === 'this_month') { + return $query->whereMonth('created_at', now()->month) + ->whereYear('created_at', now()->year); + } elseif ($data['value'] === 'last_month') { + return $query->whereMonth('created_at', now()->subMonth()->month) + ->whereYear('created_at', now()->subMonth()->year); + } + + return $query; + }), ]; } @@ -135,27 +269,104 @@ class GenerateActivationKeys extends Page implements HasForms, HasTable BulkAction::make('Download Keys') ->action(fn (Collection $records): BinaryFileResponse => $this->downloadKeys($records)) ->deselectRecordsAfterCompletion() - ->requiresConfirmation(), + ->requiresConfirmation() + ->modalDescription('Download selected activation keys as a text file.'), + + BulkAction::make('Deactivate Keys') + ->action(function (Collection $records) { + $count = 0; + foreach ($records as $record) { + if ($record->is_activated) { + // Deactivate the subscription if it exists + $subscription = $record->user?->subscriptions() + ->where('provider', 'activation_key') + ->where('provider_subscription_id', $record->id) + ->first(); + + if ($subscription) { + $subscription->update([ + 'status' => 'cancelled', + 'ends_at' => now(), + ]); + } + + $record->update([ + 'is_activated' => false, + 'user_id' => null, + ]); + $count++; + } + } + + Notification::make() + ->title("{$count} key(s) deactivated") + ->success() + ->send(); + }) + ->deselectRecordsAfterCompletion() + ->requiresConfirmation() + ->modalDescription('This will deactivate the selected keys and cancel associated subscriptions.') + ->color('danger'), + + BulkAction::make('Delete Keys') + ->action(function (Collection $records) { + $count = $records->count(); + + // First, deactivate any associated subscriptions + foreach ($records as $record) { + $subscription = $record->user?->subscriptions() + ->where('provider', 'activation_key') + ->where('provider_subscription_id', $record->id) + ->first(); + + if ($subscription) { + $subscription->delete(); + } + } + + // Delete the keys + $records->each->delete(); + + Notification::make() + ->title("{$count} key(s) deleted") + ->success() + ->send(); + }) + ->deselectRecordsAfterCompletion() + ->requiresConfirmation() + ->modalDescription('This will permanently delete the selected keys and associated subscriptions.') + ->color('danger'), ]; } public function downloadKeys(Collection $records): BinaryFileResponse { - $text = $records->pluck('activation_key')->implode("\n"); + $content = "# Activation Keys\n"; + $content .= '# Generated: '.now()->toDateTimeString()."\n"; + $content .= '# Total Keys: '.$records->count()."\n\n"; - $filename = 'activation_keys_'.now()->timestamp.'.txt'; - // Store the file in the 'public' directory or a subdirectory within 'public' - $path = public_path("activation/{$filename}"); - - // Make sure the 'activation' folder exists, create it if it doesn't - if (! file_exists(public_path('activation'))) { - mkdir(public_path('activation'), 0777, true); + foreach ($records as $record) { + $plan = Plan::where('pricing_id', $record->price_id)->first(); + $content .= "Key: {$record->activation_key}\n"; + $content .= 'Plan: '.($plan->name ?? 'Unknown Plan')."\n"; + $content .= 'Status: '.($record->is_activated ? 'Activated' : 'Unused')."\n"; + $content .= 'Generated: '.$record->created_at->toDateTimeString()."\n"; + if ($record->user) { + $content .= 'Activated By: '.$record->user->email."\n"; + } + $content .= "---\n\n"; } - // Write the contents to the file - file_put_contents($path, $text); + $filename = 'activation_keys_'.now()->format('Y-m-d_H-i-s').'.txt'; + $path = public_path("activation/{$filename}"); + + // Make sure the 'activation' folder exists + if (! file_exists(public_path('activation')) && ! mkdir($concurrentDirectory = public_path('activation'), 0755, true) && ! is_dir($concurrentDirectory)) { + Log::error('Failed to create activation keys file: '.$concurrentDirectory); + } + + file_put_contents($path, $content); - // Return the response that allows users to download the file directly return response()->download($path)->deleteFileAfterSend(true); } } diff --git a/app/Filament/Resources/Subscriptions/Schemas/SubscriptionForm.php b/app/Filament/Resources/Subscriptions/Schemas/SubscriptionForm.php index 2432415..466649b 100644 --- a/app/Filament/Resources/Subscriptions/Schemas/SubscriptionForm.php +++ b/app/Filament/Resources/Subscriptions/Schemas/SubscriptionForm.php @@ -7,8 +7,12 @@ use Filament\Forms\Components\Select; use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; +use Filament\Infolists\Components\RepeatableEntry; +use Filament\Infolists\Components\TextEntry; use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Section; +use Filament\Schemas\Components\Tabs; +use Filament\Schemas\Components\Tabs\Tab; use Filament\Schemas\Schema; class SubscriptionForm @@ -144,27 +148,6 @@ class SubscriptionForm ->collapsible() ->visible(fn ($get) => $get('status') === 'cancelled'), - Section::make('Provider Information') - ->schema([ - TextInput::make('stripe_id') - ->label('Stripe ID') - ->visible(fn ($get) => $get('provider') === 'stripe'), - - TextInput::make('stripe_status') - ->label('Stripe Status') - ->visible(fn ($get) => $get('provider') === 'stripe'), - - TextInput::make('stripe_price') - ->label('Stripe Price') - ->visible(fn ($get) => $get('provider') === 'stripe'), - - Textarea::make('provider_data') - ->label('Provider Data') - ->rows(3) - ->helperText('JSON data from the payment provider'), - ]) - ->collapsible(), - Section::make('Migration Information') ->schema([ TextInput::make('migration_batch_id') @@ -178,6 +161,116 @@ class SubscriptionForm ->rows(3), ]) ->collapsible(), + + Tabs::make('Provider Data') + ->tabs([ + Tab::make('Overview') + ->schema([ + Section::make('Activation Key') + ->schema([ + TextEntry::make('provider_data.activation_key') + ->label('Activation Key') + ->copyable(), + TextEntry::make('provider_data.key_id') + ->label('Key ID'), + TextEntry::make('provider_data.redeemed_at') + ->label('Redeemed At'), + ]) + ->columns(1) + ->hidden(fn ($record) => ! data_get($record, 'provider_data.activation_key')), + + Section::make('Plan Details') + ->schema([ + TextEntry::make('provider_data.plan_details.name') + ->label('Plan Name'), + TextEntry::make('provider_data.plan_details.price') + ->label('Price') + ->prefix('$'), + TextEntry::make('provider_data.plan_details.billing_cycle_display') + ->label('Billing Cycle'), + TextEntry::make('provider_data.plan_details.plan_tier') + ->badge() + ->label('Tier'), + TextEntry::make('provider_data.plan_details.billing_cycle_days') + ->label('Duration') + ->suffix(' days'), + ]) + ->columns(2) + ->hidden(fn ($record) => ! data_get($record, 'provider_data.plan_details')), + + Section::make('Provider Info') + ->schema([ + TextEntry::make('provider_data.provider_info.name') + ->label('Provider Name'), + TextEntry::make('provider_data.provider_info.version') + ->label('Version'), + TextEntry::make('provider_data.provider_info.processed_at') + ->label('Processed At'), + ]) + ->columns(2) + ->hidden(fn ($record) => ! data_get($record, 'provider_data.provider_info')), + ]), + + Tab::make('Features') + ->schema([ + RepeatableEntry::make('provider_data.plan_details.features') + ->schema([ + Section::make() + ->schema([ + TextEntry::make('feature.display_name') + ->label('Feature Name') + ->weight('bold'), + TextEntry::make('feature.description') + ->label('Description') + ->columnSpanFull(), + TextEntry::make('feature.category') + ->label('Category') + ->badge(), + TextEntry::make('feature.type') + ->label('Type'), + + Section::make('Limit Details') + ->schema([ + TextEntry::make('limit.limit_value') + ->label('Limit Value'), + TextEntry::make('limit.trial_limit_value') + ->label('Trial Limit'), + TextEntry::make('limit.limit_type') + ->badge() + ->label('Limit Type'), + TextEntry::make('limit.is_enabled') + ->label('Status') + ->badge() + ->formatStateUsing(fn ($state) => $state ? 'Enabled' : 'Disabled') + ->color(fn ($state) => $state ? 'success' : 'gray'), + ]) + ->columns(2) + ->collapsed(), + ]) + ->columns(2), + ]) + ->columnSpanFull(), + ]) + ->hidden(fn ($record) => ! data_get($record, 'provider_data.plan_details.features')), + + Tab::make('Raw JSON') + ->schema([ + TextEntry::make('provider_data') + ->formatStateUsing(function ($state) { + if (is_string($state)) { + $data = json_decode($state, true); + } else { + $data = (array) $state; + } + + return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + }) + ->state(fn ($record) => $record->provider_data) + ->copyable() + ->columnSpanFull(), + ]), + ])->columnSpanFull(), + ]); } } diff --git a/app/Livewire/Dashboard/Pricing.php b/app/Livewire/Dashboard/Pricing.php index 7cc1f3f..b47c05e 100644 --- a/app/Livewire/Dashboard/Pricing.php +++ b/app/Livewire/Dashboard/Pricing.php @@ -5,7 +5,6 @@ namespace App\Livewire\Dashboard; use App\Models\ActivationKey; use App\Models\Plan; use App\Models\PlanTier; -use App\Services\Payments\PaymentOrchestrator; use Exception; use Illuminate\Contracts\View\Factory; use Illuminate\Contracts\View\View; @@ -125,29 +124,15 @@ class Pricing extends Component private function activateSubscriptionKey(ActivationKey $activation): bool { try { - // Use PaymentOrchestrator for activation key processing - $orchestrator = app(PaymentOrchestrator::class); - - // Find the plan associated with this activation key - $plan = null; - if ($activation->plan_id) { - $plan = Plan::find($activation->plan_id); - } elseif ($activation->price_id) { - // Fallback to legacy pricing_id lookup - $plan = Plan::where('pricing_id', $activation->price_id)->first(); - } - - if (! $plan) { - Log::error('No plan found for activation key: '.$activation->id); - - return false; - } - - // Create subscription using orchestrator + // Use the ActivationKeyProvider directly + $provider = app(\App\Services\Payments\Providers\ActivationKeyProvider::class); $user = auth()->user(); - $subscription = $orchestrator->createSubscriptionFromActivationKey($user, $activation, $plan); - if ($subscription) { + // Redeem the activation key using the provider + $result = $provider->redeemActivationKey($activation->activation_key, $user); + + if ($result['success']) { + // Mark activation key as used $activation->is_activated = true; $activation->user_id = $user->id; $activation->save(); @@ -155,6 +140,8 @@ class Pricing extends Component return true; } + Log::error('Activation key redemption failed: '.$result['message'] ?? 'Unknown error'); + return false; } catch (Exception $e) { Log::error('Activation key processing failed: '.$e->getMessage()); diff --git a/app/Services/Payments/Providers/ActivationKeyProvider.php b/app/Services/Payments/Providers/ActivationKeyProvider.php index 7ea4f6d..60b926a 100644 --- a/app/Services/Payments/Providers/ActivationKeyProvider.php +++ b/app/Services/Payments/Providers/ActivationKeyProvider.php @@ -335,24 +335,47 @@ class ActivationKeyProvider implements PaymentProviderContract 'is_activated' => true, ]); - // Find or create subscription - $plan = Plan::findOrFail($keyRecord->price_id); + // Find the plan associated with this activation key + $plan = Plan::where('pricing_id', $keyRecord->price_id)->first(); + + if (! $plan) { + throw new \Exception('No plan found for activation key with pricing_id: '.$keyRecord->price_id); + } + + // Calculate subscription end date based on plan billing cycle + $endsAt = null; + if ($plan->billing_cycle_days && $plan->billing_cycle_days > 0) { + $endsAt = now()->addDays($plan->billing_cycle_days); + } $subscription = Subscription::create([ 'user_id' => $user->id, 'plan_id' => $plan->id, - 'type' => 'activation_key', - 'stripe_id' => 'ak_'.$keyRecord->id.'_'.uniqid(), + 'type' => 'default', + 'stripe_id' => 'ak_'.$keyRecord->id.'_'.uniqid('', true), 'stripe_status' => 'active', 'provider' => $this->getName(), 'provider_subscription_id' => $keyRecord->id, 'status' => 'active', 'starts_at' => now(), - 'ends_at' => null, // No expiration for activation keys + 'ends_at' => $endsAt, 'provider_data' => [ 'activation_key' => $activationKey, 'key_id' => $keyRecord->id, 'redeemed_at' => now()->toISOString(), + 'plan_details' => [ + 'name' => $plan->name, + 'price' => $plan->price, + 'billing_cycle_days' => $plan->billing_cycle_days, + 'billing_cycle_display' => $plan->getBillingCycleDisplay(), + 'plan_tier' => $plan->planTier ? $plan->planTier->name : null, + 'features' => $plan->getFeaturesWithLimits(), + ], + 'provider_info' => [ + 'name' => $this->getName(), + 'version' => '1.0', + 'processed_at' => now()->toISOString(), + ], ], ]);