diff --git a/app/Console/Commands/RetryFailedPayouts.php b/app/Console/Commands/RetryFailedPayouts.php new file mode 100644 index 00000000..78ed1517 --- /dev/null +++ b/app/Console/Commands/RetryFailedPayouts.php @@ -0,0 +1,89 @@ +option('payout-id'); + + if ($payoutId) { + $payout = PluginPayout::find($payoutId); + + if (! $payout) { + $this->error("Payout #{$payoutId} not found."); + + return self::FAILURE; + } + + if (! $payout->isFailed()) { + $this->error("Payout #{$payoutId} is not in failed status."); + + return self::FAILURE; + } + + return $this->retryPayout($payout, $stripeConnectService); + } + + $failedPayouts = PluginPayout::failed() + ->with(['pluginLicense', 'developerAccount']) + ->get(); + + if ($failedPayouts->isEmpty()) { + $this->info('No failed payouts to retry.'); + + return self::SUCCESS; + } + + $this->info("Found {$failedPayouts->count()} failed payout(s) to retry."); + + $succeeded = 0; + $failed = 0; + + foreach ($failedPayouts as $payout) { + // Reset status to pending before retrying + $payout->update(['status' => PayoutStatus::Pending]); + + if ($stripeConnectService->processTransfer($payout)) { + $this->info("Payout #{$payout->id} succeeded."); + $succeeded++; + } else { + $this->error("Payout #{$payout->id} failed again."); + $failed++; + } + } + + $this->newLine(); + $this->info("Results: {$succeeded} succeeded, {$failed} failed."); + + return $failed > 0 ? self::FAILURE : self::SUCCESS; + } + + protected function retryPayout(PluginPayout $payout, StripeConnectService $stripeConnectService): int + { + $this->info("Retrying payout #{$payout->id}..."); + + // Reset status to pending before retrying + $payout->update(['status' => PayoutStatus::Pending]); + + if ($stripeConnectService->processTransfer($payout)) { + $this->info('Payout succeeded!'); + + return self::SUCCESS; + } + + $this->error('Payout failed again.'); + + return self::FAILURE; + } +} diff --git a/app/Console/Commands/SatisBuild.php b/app/Console/Commands/SatisBuild.php new file mode 100644 index 00000000..1a87608e --- /dev/null +++ b/app/Console/Commands/SatisBuild.php @@ -0,0 +1,76 @@ +option('plugin'); + + if ($pluginName) { + $plugin = \App\Models\Plugin::where('name', $pluginName)->first(); + + if (! $plugin) { + $this->error("Plugin '{$pluginName}' not found."); + + return self::FAILURE; + } + + if (! $plugin->isApproved()) { + $this->error("Plugin '{$pluginName}' is not approved."); + + return self::FAILURE; + } + + $this->info("Triggering Satis build for: {$pluginName}"); + $result = $satisService->build([$plugin]); + } else { + $this->info('Triggering Satis build for all approved plugins...'); + $result = $satisService->buildAll(); + } + + if ($result['success']) { + $this->info('Build triggered successfully!'); + $this->line("Job ID: {$result['job_id']}"); + + if (isset($result['plugins_count'])) { + $this->line("Plugins: {$result['plugins_count']}"); + } + + return self::SUCCESS; + } + + $this->error('Build trigger failed: '.$result['error']); + + if (isset($result['status'])) { + $this->line("HTTP Status: {$result['status']}"); + } + + $this->line('API URL: '.config('services.satis.url')); + $this->line('API Key configured: '.(config('services.satis.api_key') ? 'Yes' : 'No')); + + return self::FAILURE; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 50fc8e89..b021c160 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -12,9 +12,15 @@ class Kernel extends ConsoleKernel */ protected function schedule(Schedule $schedule): void { - // Send license expiry warnings daily at 9 AM UTC - $schedule->command('licenses:send-expiry-warnings') - ->dailyAt('09:00') + // Remove GitHub access for users with expired Max licenses + $schedule->command('github:remove-expired-access') + ->dailyAt('10:00') + ->onOneServer() + ->runInBackground(); + + // Remove Discord Max role for users with expired Max licenses + $schedule->command('discord:remove-expired-roles') + ->dailyAt('10:30') ->onOneServer() ->runInBackground(); diff --git a/app/Enums/GrandfatheringTier.php b/app/Enums/GrandfatheringTier.php new file mode 100644 index 00000000..b499dc15 --- /dev/null +++ b/app/Enums/GrandfatheringTier.php @@ -0,0 +1,48 @@ + 'No Discount', + self::Discounted => 'Legacy Discount', + self::FreeOfficialPlugins => 'Free Official Plugins', + }; + } + + public function color(): string + { + return match ($this) { + self::None => 'gray', + self::Discounted => 'blue', + self::FreeOfficialPlugins => 'green', + }; + } + + public function getDiscountPercent(): int + { + return match ($this) { + self::None => 0, + self::Discounted => 20, + self::FreeOfficialPlugins => 100, + }; + } + + public function appliesToPlugin(Plugin $plugin): bool + { + return match ($this) { + self::None => false, + self::Discounted => true, + self::FreeOfficialPlugins => $plugin->is_official, + }; + } +} diff --git a/app/Enums/PayoutStatus.php b/app/Enums/PayoutStatus.php new file mode 100644 index 00000000..b548934e --- /dev/null +++ b/app/Enums/PayoutStatus.php @@ -0,0 +1,28 @@ + 'Pending', + self::Transferred => 'Transferred', + self::Failed => 'Failed', + }; + } + + public function color(): string + { + return match ($this) { + self::Pending => 'yellow', + self::Transferred => 'green', + self::Failed => 'red', + }; + } +} diff --git a/app/Enums/PluginActivityType.php b/app/Enums/PluginActivityType.php new file mode 100644 index 00000000..56a26126 --- /dev/null +++ b/app/Enums/PluginActivityType.php @@ -0,0 +1,45 @@ + 'Submitted', + self::Resubmitted => 'Resubmitted', + self::Approved => 'Approved', + self::Rejected => 'Rejected', + self::DescriptionUpdated => 'Description Updated', + }; + } + + public function color(): string + { + return match ($this) { + self::Submitted => 'info', + self::Resubmitted => 'info', + self::Approved => 'success', + self::Rejected => 'danger', + self::DescriptionUpdated => 'gray', + }; + } + + public function icon(): string + { + return match ($this) { + self::Submitted => 'heroicon-o-paper-airplane', + self::Resubmitted => 'heroicon-o-arrow-path', + self::Approved => 'heroicon-o-check-circle', + self::Rejected => 'heroicon-o-x-circle', + self::DescriptionUpdated => 'heroicon-o-pencil-square', + }; + } +} diff --git a/app/Enums/PluginStatus.php b/app/Enums/PluginStatus.php new file mode 100644 index 00000000..4fd44020 --- /dev/null +++ b/app/Enums/PluginStatus.php @@ -0,0 +1,28 @@ + 'Pending Review', + self::Approved => 'Approved', + self::Rejected => 'Rejected', + }; + } + + public function color(): string + { + return match ($this) { + self::Pending => 'yellow', + self::Approved => 'green', + self::Rejected => 'red', + }; + } +} diff --git a/app/Enums/PluginType.php b/app/Enums/PluginType.php new file mode 100644 index 00000000..27df3858 --- /dev/null +++ b/app/Enums/PluginType.php @@ -0,0 +1,17 @@ + 'Free', + self::Paid => 'Paid', + }; + } +} diff --git a/app/Enums/StripeConnectStatus.php b/app/Enums/StripeConnectStatus.php new file mode 100644 index 00000000..8af1617d --- /dev/null +++ b/app/Enums/StripeConnectStatus.php @@ -0,0 +1,33 @@ + 'Pending Onboarding', + self::Active => 'Active', + self::Disabled => 'Disabled', + }; + } + + public function color(): string + { + return match ($this) { + self::Pending => 'yellow', + self::Active => 'green', + self::Disabled => 'red', + }; + } + + public function canReceivePayouts(): bool + { + return $this === self::Active; + } +} diff --git a/app/Features/AllowPaidPlugins.php b/app/Features/AllowPaidPlugins.php new file mode 100644 index 00000000..1adc4e6f --- /dev/null +++ b/app/Features/AllowPaidPlugins.php @@ -0,0 +1,20 @@ +active(static::class); + } + + return false; + } +} diff --git a/app/Features/ShowPlugins.php b/app/Features/ShowPlugins.php new file mode 100644 index 00000000..56327249 --- /dev/null +++ b/app/Features/ShowPlugins.php @@ -0,0 +1,20 @@ +active(static::class); + } + + return false; + } +} diff --git a/app/Filament/Pages/Dashboard.php b/app/Filament/Pages/Dashboard.php index c88539c9..8961642d 100644 --- a/app/Filament/Pages/Dashboard.php +++ b/app/Filament/Pages/Dashboard.php @@ -4,6 +4,7 @@ use App\Filament\Widgets\LicenseDistributionChart; use App\Filament\Widgets\LicensesChart; +use App\Filament\Widgets\PluginRevenueChart; use App\Filament\Widgets\StatsOverview; use App\Filament\Widgets\UsersChart; use Filament\Pages\Dashboard as BaseDashboard; @@ -25,6 +26,7 @@ public function getWidgets(): array UsersChart::class, LicensesChart::class, LicenseDistributionChart::class, + PluginRevenueChart::class, ]; } } diff --git a/app/Filament/Resources/PluginBundleResource.php b/app/Filament/Resources/PluginBundleResource.php new file mode 100644 index 00000000..c67fb6eb --- /dev/null +++ b/app/Filament/Resources/PluginBundleResource.php @@ -0,0 +1,214 @@ +schema([ + Forms\Components\Section::make('Bundle Details') + ->schema([ + Forms\Components\TextInput::make('name') + ->required() + ->maxLength(255) + ->live(onBlur: true) + ->afterStateUpdated(function (string $state, Forms\Set $set) { + $set('slug', Str::slug($state)); + }), + + Forms\Components\TextInput::make('slug') + ->required() + ->maxLength(255) + ->unique(ignoreRecord: true) + ->alphaDash(), + + Forms\Components\Textarea::make('description') + ->rows(3) + ->maxLength(1000) + ->columnSpanFull(), + + Forms\Components\FileUpload::make('logo_path') + ->label('Bundle Logo') + ->image() + ->disk('public') + ->directory('bundle-logos') + ->imageResizeMode('cover') + ->imageCropAspectRatio('1:1') + ->imageResizeTargetWidth('256') + ->imageResizeTargetHeight('256'), + ]) + ->columns(2), + + Forms\Components\Section::make('Pricing') + ->schema([ + Forms\Components\TextInput::make('price') + ->label('Bundle Price (in cents)') + ->required() + ->numeric() + ->minValue(100) + ->helperText('Enter price in cents. E.g., 4999 = $49.99'), + + Forms\Components\Select::make('currency') + ->options([ + 'USD' => 'USD', + ]) + ->default('USD') + ->required(), + ]) + ->columns(2), + + Forms\Components\Section::make('Included Plugins') + ->schema([ + Forms\Components\Select::make('plugins') + ->relationship( + 'plugins', + 'name', + fn ($query) => $query->approved()->where('type', PluginType::Paid)->whereHas('activePrice') + ) + ->multiple() + ->preload() + ->searchable() + ->required() + ->getOptionLabelFromRecordUsing(function (Plugin $record) { + $price = $record->activePrice ? '$'.number_format($record->activePrice->amount / 100, 2) : 'No price'; + + return "{$record->name} ({$price})"; + }) + ->helperText('Select paid plugins to include in this bundle.') + ->optionsLimit(50), + ]), + + Forms\Components\Section::make('Publishing') + ->schema([ + Forms\Components\Toggle::make('is_active') + ->label('Active') + ->helperText('Bundle will only be visible when active and published.'), + + Forms\Components\Toggle::make('is_featured') + ->label('Featured') + ->helperText('Show this bundle prominently in the bundles section.'), + + Forms\Components\DateTimePicker::make('published_at') + ->label('Publish Date') + ->helperText('Leave empty to keep as draft. Set future date to schedule.'), + ]) + ->columns(3), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\ImageColumn::make('logo_path') + ->label('') + ->disk('public') + ->circular() + ->size(40), + + Tables\Columns\TextColumn::make('name') + ->searchable() + ->sortable(), + + Tables\Columns\TextColumn::make('plugins_count') + ->label('Plugins') + ->counts('plugins') + ->sortable(), + + Tables\Columns\TextColumn::make('price') + ->label('Bundle Price') + ->formatStateUsing(fn (int $state): string => '$'.number_format($state / 100, 2)) + ->sortable(), + + Tables\Columns\TextColumn::make('retail_value') + ->label('Retail Value') + ->getStateUsing(fn (PluginBundle $record): string => $record->formatted_retail_value), + + Tables\Columns\TextColumn::make('discount_percent') + ->label('Discount') + ->getStateUsing(fn (PluginBundle $record): string => $record->discount_percent.'%') + ->badge() + ->color('success'), + + Tables\Columns\IconColumn::make('is_active') + ->label('Active') + ->boolean() + ->sortable(), + + Tables\Columns\ToggleColumn::make('is_featured') + ->sortable(), + + Tables\Columns\TextColumn::make('published_at') + ->label('Published') + ->dateTime() + ->sortable(), + ]) + ->filters([ + Tables\Filters\TernaryFilter::make('is_active'), + Tables\Filters\TernaryFilter::make('is_featured'), + ]) + ->actions([ + Tables\Actions\ViewAction::make(), + Tables\Actions\EditAction::make(), + Tables\Actions\ActionGroup::make([ + Tables\Actions\Action::make('viewListing') + ->label('View Listing Page') + ->icon('heroicon-o-eye') + ->color('gray') + ->url(fn (PluginBundle $record) => route('bundles.show', $record)) + ->openUrlInNewTab() + ->visible(fn (PluginBundle $record) => $record->is_active && $record->published_at?->isPast()), + ]) + ->label('More') + ->icon('heroicon-m-ellipsis-vertical'), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]) + ->defaultSort('created_at', 'desc'); + } + + public static function getRelations(): array + { + return [ + RelationManagers\PluginsRelationManager::class, + RelationManagers\LicensesRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListPluginBundles::route('/'), + 'create' => Pages\CreatePluginBundle::route('/create'), + 'view' => Pages\ViewPluginBundle::route('/{record}'), + 'edit' => Pages\EditPluginBundle::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Resources/PluginBundleResource/Pages/CreatePluginBundle.php b/app/Filament/Resources/PluginBundleResource/Pages/CreatePluginBundle.php new file mode 100644 index 00000000..4450f1a0 --- /dev/null +++ b/app/Filament/Resources/PluginBundleResource/Pages/CreatePluginBundle.php @@ -0,0 +1,11 @@ +label('View Listing Page') + ->icon('heroicon-o-eye') + ->color('gray') + ->url(fn () => route('bundles.show', $this->record)) + ->openUrlInNewTab() + ->visible(fn () => $this->record->is_active && $this->record->published_at?->isPast()), + Actions\EditAction::make(), + ]; + } +} diff --git a/app/Filament/Resources/PluginBundleResource/RelationManagers/LicensesRelationManager.php b/app/Filament/Resources/PluginBundleResource/RelationManagers/LicensesRelationManager.php new file mode 100644 index 00000000..6d75b6ab --- /dev/null +++ b/app/Filament/Resources/PluginBundleResource/RelationManagers/LicensesRelationManager.php @@ -0,0 +1,37 @@ +columns([ + Tables\Columns\TextColumn::make('user.email') + ->label('User') + ->searchable(), + + Tables\Columns\TextColumn::make('plugin.name') + ->label('Plugin') + ->fontFamily('mono'), + + Tables\Columns\TextColumn::make('price_paid') + ->label('Allocated Amount') + ->formatStateUsing(fn (int $state): string => '$'.number_format($state / 100, 2)), + + Tables\Columns\TextColumn::make('purchased_at') + ->dateTime() + ->sortable(), + ]) + ->defaultSort('purchased_at', 'desc'); + } +} diff --git a/app/Filament/Resources/PluginBundleResource/RelationManagers/PluginsRelationManager.php b/app/Filament/Resources/PluginBundleResource/RelationManagers/PluginsRelationManager.php new file mode 100644 index 00000000..2e9795fe --- /dev/null +++ b/app/Filament/Resources/PluginBundleResource/RelationManagers/PluginsRelationManager.php @@ -0,0 +1,60 @@ +columns([ + Tables\Columns\ImageColumn::make('logo_path') + ->label('') + ->disk('public') + ->circular() + ->size(40), + + Tables\Columns\TextColumn::make('name') + ->label('Package Name') + ->searchable() + ->fontFamily('mono'), + + Tables\Columns\TextColumn::make('user.email') + ->label('Developer'), + + Tables\Columns\TextColumn::make('activePrice.amount') + ->label('Retail Price') + ->formatStateUsing(fn (?int $state): string => $state ? '$'.number_format($state / 100, 2) : 'N/A'), + + Tables\Columns\TextColumn::make('sort_order') + ->label('Order') + ->sortable() + ->getStateUsing(fn ($record) => $record->pivot->sort_order ?? 0), + ]) + ->reorderable('sort_order') + ->defaultSort('sort_order') + ->headerActions([ + Tables\Actions\AttachAction::make() + ->preloadRecordSelect() + ->recordSelectSearchColumns(['name']), + ]) + ->actions([ + Tables\Actions\DetachAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DetachBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Filament/Resources/PluginResource.php b/app/Filament/Resources/PluginResource.php new file mode 100644 index 00000000..a1d9bcc9 --- /dev/null +++ b/app/Filament/Resources/PluginResource.php @@ -0,0 +1,269 @@ +schema([ + Forms\Components\Section::make('Plugin Details') + ->schema([ + Forms\Components\Placeholder::make('logo_preview') + ->label('Logo') + ->content(fn (?Plugin $record) => $record?->hasLogo() + ? new \Illuminate\Support\HtmlString('Logo') + : 'No logo') + ->visible(fn (?Plugin $record) => $record !== null), + + Forms\Components\TextInput::make('name') + ->label('Composer Package Name') + ->disabled(), + + Forms\Components\Select::make('type') + ->options(PluginType::class) + ->disabled(), + + Forms\Components\TextInput::make('repository_url') + ->label('Repository URL') + ->disabled() + ->url() + ->suffixIcon('heroicon-o-arrow-top-right-on-square') + ->suffixIconColor('gray'), + + Forms\Components\Select::make('status') + ->options(PluginStatus::class) + ->disabled(), + + Forms\Components\Textarea::make('description') + ->label('Description') + ->disabled() + ->columnSpanFull(), + + Forms\Components\Textarea::make('rejection_reason') + ->label('Rejection Reason') + ->disabled() + ->visible(fn (?Plugin $record) => $record?->isRejected()), + ]) + ->columns(2), + + Forms\Components\Section::make('Submission Info') + ->schema([ + Forms\Components\Select::make('user_id') + ->relationship('user', 'email') + ->disabled(), + + Forms\Components\DateTimePicker::make('created_at') + ->label('Submitted At') + ->disabled(), + + Forms\Components\Select::make('approved_by') + ->relationship('approvedBy', 'email') + ->disabled() + ->visible(fn (?Plugin $record) => $record?->approved_by !== null), + + Forms\Components\DateTimePicker::make('approved_at') + ->disabled() + ->visible(fn (?Plugin $record) => $record?->approved_at !== null), + ]) + ->columns(2), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\ImageColumn::make('logo_path') + ->label('') + ->disk('public') + ->circular() + ->defaultImageUrl(fn () => 'https://ui-avatars.com/api/?name=P&color=7C3AED&background=EDE9FE') + ->size(40), + + Tables\Columns\TextColumn::make('name') + ->label('Package Name') + ->searchable() + ->sortable() + ->copyable() + ->fontFamily('mono'), + + Tables\Columns\TextColumn::make('type') + ->badge() + ->color(fn (PluginType $state): string => match ($state) { + PluginType::Free => 'gray', + PluginType::Paid => 'success', + }) + ->sortable(), + + Tables\Columns\TextColumn::make('user.email') + ->label('Submitted By') + ->searchable() + ->sortable(), + + Tables\Columns\TextColumn::make('status') + ->badge() + ->color(fn (PluginStatus $state): string => match ($state) { + PluginStatus::Pending => 'warning', + PluginStatus::Approved => 'success', + PluginStatus::Rejected => 'danger', + }) + ->sortable(), + + Tables\Columns\ToggleColumn::make('featured') + ->sortable(), + + Tables\Columns\ToggleColumn::make('is_active') + ->label('Active') + ->sortable(), + + Tables\Columns\TextColumn::make('created_at') + ->label('Submitted') + ->dateTime() + ->sortable(), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('status') + ->options(PluginStatus::class), + Tables\Filters\SelectFilter::make('type') + ->options(PluginType::class), + Tables\Filters\TernaryFilter::make('featured'), + Tables\Filters\TernaryFilter::make('is_active') + ->label('Active'), + ]) + ->actions([ + // Approve Action + Tables\Actions\Action::make('approve') + ->icon('heroicon-o-check') + ->color('success') + ->visible(fn (Plugin $record) => $record->isPending()) + ->action(fn (Plugin $record) => $record->approve(auth()->id())) + ->requiresConfirmation() + ->modalHeading('Approve Plugin') + ->modalDescription(fn (Plugin $record) => "Are you sure you want to approve '{$record->name}'?"), + + // Reject Action + Tables\Actions\Action::make('reject') + ->icon('heroicon-o-x-mark') + ->color('danger') + ->visible(fn (Plugin $record) => $record->isPending() || $record->isApproved()) + ->form([ + Forms\Components\Textarea::make('rejection_reason') + ->label('Reason for Rejection') + ->required() + ->rows(3) + ->placeholder('Please explain why this plugin is being rejected...'), + ]) + ->action(fn (Plugin $record, array $data) => $record->reject($data['rejection_reason'], auth()->id())) + ->modalHeading('Reject Plugin') + ->modalDescription(fn (Plugin $record) => "Are you sure you want to reject '{$record->name}'?"), + + // External Links Group + Tables\Actions\ActionGroup::make([ + // View Listing Page (Approved plugins only) + Tables\Actions\Action::make('viewListing') + ->label('View Listing Page') + ->icon('heroicon-o-eye') + ->color('gray') + ->url(fn (Plugin $record) => route('plugins.show', $record)) + ->openUrlInNewTab() + ->visible(fn (Plugin $record) => $record->isApproved()), + + // Packagist Link (Free plugins only) + Tables\Actions\Action::make('viewPackagist') + ->label('View on Packagist') + ->icon('heroicon-o-arrow-top-right-on-square') + ->color('gray') + ->url(fn (Plugin $record) => $record->getPackagistUrl()) + ->openUrlInNewTab() + ->visible(fn (Plugin $record) => $record->isFree()), + + // GitHub Link (Free plugins only) + Tables\Actions\Action::make('viewGithub') + ->label('View on GitHub') + ->icon('heroicon-o-arrow-top-right-on-square') + ->color('gray') + ->url(fn (Plugin $record) => $record->getGithubUrl()) + ->openUrlInNewTab() + ->visible(fn (Plugin $record) => $record->isFree()), + + // Edit Description Action + Tables\Actions\Action::make('editDescription') + ->label('Edit Description') + ->icon('heroicon-o-pencil-square') + ->color('gray') + ->form([ + Forms\Components\Textarea::make('description') + ->label('Description') + ->required() + ->rows(5) + ->maxLength(1000) + ->default(fn (Plugin $record) => $record->description) + ->placeholder('Describe what this plugin does...'), + ]) + ->action(fn (Plugin $record, array $data) => $record->updateDescription($data['description'], auth()->id())) + ->modalHeading('Edit Plugin Description') + ->modalDescription(fn (Plugin $record) => "Update the description for '{$record->name}'"), + + Tables\Actions\ViewAction::make(), + ]) + ->label('More') + ->icon('heroicon-m-ellipsis-vertical'), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\BulkAction::make('approve') + ->icon('heroicon-o-check') + ->color('success') + ->action(function ($records) { + $records->each(fn (Plugin $record) => $record->approve(auth()->id())); + }) + ->requiresConfirmation() + ->modalHeading('Approve Selected Plugins') + ->modalDescription('Are you sure you want to approve all selected plugins?'), + + Tables\Actions\DeleteBulkAction::make(), + ]), + ]) + ->defaultSort('created_at', 'desc'); + } + + public static function getRelations(): array + { + return [ + RelationManagers\ActivitiesRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListPlugins::route('/'), + 'view' => Pages\ViewPlugin::route('/{record}'), + ]; + } +} diff --git a/app/Filament/Resources/PluginResource/Pages/ListPlugins.php b/app/Filament/Resources/PluginResource/Pages/ListPlugins.php new file mode 100644 index 00000000..ba97676c --- /dev/null +++ b/app/Filament/Resources/PluginResource/Pages/ListPlugins.php @@ -0,0 +1,19 @@ +label('View Listing Page') + ->icon('heroicon-o-eye') + ->color('gray') + ->url(fn () => route('plugins.show', $this->record)) + ->openUrlInNewTab() + ->visible(fn () => $this->record->isApproved()), + + Actions\Action::make('approve') + ->icon('heroicon-o-check') + ->color('success') + ->visible(fn () => $this->record->isPending()) + ->action(fn () => $this->record->approve(auth()->id())) + ->requiresConfirmation() + ->modalHeading('Approve Plugin') + ->modalDescription(fn () => "Are you sure you want to approve '{$this->record->name}'?"), + + Actions\Action::make('reject') + ->icon('heroicon-o-x-mark') + ->color('danger') + ->visible(fn () => $this->record->isPending() || $this->record->isApproved()) + ->form([ + Forms\Components\Textarea::make('rejection_reason') + ->label('Reason for Rejection') + ->required() + ->rows(3) + ->placeholder('Please explain why this plugin is being rejected...'), + ]) + ->action(fn (array $data) => $this->record->reject($data['rejection_reason'], auth()->id())) + ->modalHeading('Reject Plugin') + ->modalDescription(fn () => "Are you sure you want to reject '{$this->record->name}'?"), + ]; + } +} diff --git a/app/Filament/Resources/PluginResource/RelationManagers/ActivitiesRelationManager.php b/app/Filament/Resources/PluginResource/RelationManagers/ActivitiesRelationManager.php new file mode 100644 index 00000000..5119dd88 --- /dev/null +++ b/app/Filament/Resources/PluginResource/RelationManagers/ActivitiesRelationManager.php @@ -0,0 +1,55 @@ +columns([ + Tables\Columns\TextColumn::make('type') + ->badge() + ->color(fn (PluginActivityType $state): string => $state->color()) + ->icon(fn (PluginActivityType $state): string => $state->icon()) + ->sortable(), + + Tables\Columns\TextColumn::make('from_status') + ->label('From') + ->badge() + ->color('gray') + ->placeholder('-'), + + Tables\Columns\TextColumn::make('to_status') + ->label('To') + ->badge() + ->color('gray'), + + Tables\Columns\TextColumn::make('note') + ->label('Note/Reason') + ->limit(50) + ->tooltip(fn ($record) => $record->note) + ->placeholder('-'), + + Tables\Columns\TextColumn::make('causer.email') + ->label('By') + ->placeholder('System'), + + Tables\Columns\TextColumn::make('created_at') + ->label('Date') + ->dateTime() + ->sortable(), + ]) + ->defaultSort('created_at', 'desc') + ->paginated([10, 25, 50]); + } +} diff --git a/app/Filament/Widgets/LicenseDistributionChart.php b/app/Filament/Widgets/LicenseDistributionChart.php index b1aa1307..10eaa35e 100644 --- a/app/Filament/Widgets/LicenseDistributionChart.php +++ b/app/Filament/Widgets/LicenseDistributionChart.php @@ -70,24 +70,20 @@ protected function getLicenseDistribution(): array protected function getOptions(): array { return [ + 'scales' => [ + 'x' => [ + 'display' => false, + ], + 'y' => [ + 'display' => false, + ], + ], 'plugins' => [ 'legend' => [ 'position' => 'bottom', ], 'tooltip' => [ - 'callbacks' => [ - 'label' => '/** - * @param {Object} context - * @returns {string} - */ - function(context) { - const label = context.label || ""; - const value = context.raw || 0; - const total = context.chart.data.datasets[0].data.reduce((a, b) => a + b, 0); - const percentage = Math.round((value / total) * 100); - return `${label}: ${value} (${percentage}%)`; - }', - ], + 'enabled' => true, ], ], ]; diff --git a/app/Filament/Widgets/PluginRevenueChart.php b/app/Filament/Widgets/PluginRevenueChart.php new file mode 100644 index 00000000..3342c78e --- /dev/null +++ b/app/Filament/Widgets/PluginRevenueChart.php @@ -0,0 +1,115 @@ +getRevenueByPlugin(); + + return [ + 'datasets' => [ + [ + 'label' => 'Revenue', + 'data' => $data['amounts'], + 'backgroundColor' => [ + 'rgba(59, 130, 246, 0.7)', // blue + 'rgba(16, 185, 129, 0.7)', // green + 'rgba(249, 115, 22, 0.7)', // orange + 'rgba(139, 92, 246, 0.7)', // purple + 'rgba(236, 72, 153, 0.7)', // pink + 'rgba(245, 158, 11, 0.7)', // amber + 'rgba(20, 184, 166, 0.7)', // teal + 'rgba(239, 68, 68, 0.7)', // red + 'rgba(99, 102, 241, 0.7)', // indigo + 'rgba(168, 162, 158, 0.7)', // stone + ], + 'borderColor' => [ + 'rgb(59, 130, 246)', + 'rgb(16, 185, 129)', + 'rgb(249, 115, 22)', + 'rgb(139, 92, 246)', + 'rgb(236, 72, 153)', + 'rgb(245, 158, 11)', + 'rgb(20, 184, 166)', + 'rgb(239, 68, 68)', + 'rgb(99, 102, 241)', + 'rgb(168, 162, 158)', + ], + 'borderWidth' => 1, + ], + ], + 'labels' => $data['labels'], + ]; + } + + protected function getType(): string + { + return 'pie'; + } + + public function getDescription(): ?string + { + $total = PluginLicense::sum('price_paid'); + + return 'Top 10 plugins by revenue. Total: $'.number_format($total / 100, 2); + } + + protected function getRevenueByPlugin(): array + { + $revenues = PluginLicense::select('plugin_id', DB::raw('SUM(price_paid) as total_revenue')) + ->whereNotNull('plugin_id') + ->groupBy('plugin_id') + ->orderByDesc('total_revenue') + ->limit(10) + ->with('plugin:id,name') + ->get(); + + $labels = []; + $amounts = []; + + foreach ($revenues as $revenue) { + $pluginName = $revenue->plugin?->name ?? 'Unknown'; + $labels[] = $pluginName; + $amounts[] = $revenue->total_revenue / 100; // Convert cents to dollars for display + } + + return [ + 'labels' => $labels, + 'amounts' => $amounts, + ]; + } + + protected function getOptions(): array + { + return [ + 'scales' => [ + 'x' => [ + 'display' => false, + ], + 'y' => [ + 'display' => false, + ], + ], + 'plugins' => [ + 'legend' => [ + 'position' => 'bottom', + ], + 'tooltip' => [ + 'enabled' => true, + ], + ], + ]; + } +} diff --git a/app/Http/Controllers/Api/PluginAccessController.php b/app/Http/Controllers/Api/PluginAccessController.php new file mode 100644 index 00000000..3ec6b4d8 --- /dev/null +++ b/app/Http/Controllers/Api/PluginAccessController.php @@ -0,0 +1,139 @@ +getUser(); + $licenseKey = $request->getPassword(); + + if (! $email || ! $licenseKey) { + return response()->json([ + 'error' => 'Authentication required', + 'message' => 'Please provide email and license key via HTTP Basic Auth', + ], 401); + } + + $user = User::where('email', $email) + ->where('plugin_license_key', $licenseKey) + ->first(); + + if (! $user) { + return response()->json([ + 'error' => 'Invalid credentials', + 'message' => 'The provided email or license key is incorrect', + ], 401); + } + + $accessiblePlugins = $this->getAccessiblePlugins($user); + + return response()->json([ + 'success' => true, + 'user' => [ + 'email' => $user->email, + ], + 'plugins' => $accessiblePlugins, + ]); + } + + /** + * Check if user has access to a specific plugin. + */ + public function checkAccess(Request $request, string $vendor, string $package): JsonResponse + { + $email = $request->getUser(); + $licenseKey = $request->getPassword(); + + if (! $email || ! $licenseKey) { + return response()->json([ + 'error' => 'Authentication required', + ], 401); + } + + $user = User::where('email', $email) + ->where('plugin_license_key', $licenseKey) + ->first(); + + if (! $user) { + return response()->json([ + 'error' => 'Invalid credentials', + ], 401); + } + + $packageName = "{$vendor}/{$package}"; + $plugin = Plugin::where('name', $packageName)->first(); + + if (! $plugin) { + return response()->json([ + 'error' => 'Plugin not found', + ], 404); + } + + $hasAccess = $user->hasPluginAccess($plugin); + + return response()->json([ + 'success' => true, + 'package' => $packageName, + 'has_access' => $hasAccess, + ]); + } + + /** + * Get all paid plugins the user has access to (submitted or purchased). + * + * @return array + */ + protected function getAccessiblePlugins(User $user): array + { + $plugins = []; + + // Paid plugins the user has submitted + $submittedPlugins = Plugin::query() + ->where('user_id', $user->id) + ->where('type', \App\Enums\PluginType::Paid) + ->get(['name']); + + foreach ($submittedPlugins as $plugin) { + $plugins[] = [ + 'name' => $plugin->name, + 'access' => 'author', + ]; + } + + // Paid plugins the user has purchased (has licenses for) + $licensedPlugins = $user->pluginLicenses() + ->active() + ->with('plugin:id,name') + ->get() + ->pluck('plugin') + ->filter() + ->unique('id'); + + foreach ($licensedPlugins as $plugin) { + // Avoid duplicates if user is also the author + if (! collect($plugins)->contains('name', $plugin->name)) { + $plugins[] = [ + 'name' => $plugin->name, + 'access' => 'purchased', + ]; + } + } + + return $plugins; + } +} diff --git a/app/Http/Controllers/Auth/CustomerAuthController.php b/app/Http/Controllers/Auth/CustomerAuthController.php index 7cb45557..cf8a9a66 100644 --- a/app/Http/Controllers/Auth/CustomerAuthController.php +++ b/app/Http/Controllers/Auth/CustomerAuthController.php @@ -4,7 +4,9 @@ use App\Http\Controllers\Controller; use App\Http\Requests\Auth\LoginRequest; +use App\Models\Plugin; use App\Models\User; +use App\Services\CartService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -13,6 +15,8 @@ class CustomerAuthController extends Controller { + public function __construct(protected CartService $cartService) {} + public function showLogin(): View { return view('auth.login'); @@ -39,7 +43,27 @@ public function register(Request $request): RedirectResponse Auth::login($user); - return redirect()->route('customer.licenses'); + // Transfer guest cart to user + $this->cartService->transferGuestCartToUser($user); + + // Check for pending add-to-cart action + $pendingPluginId = session()->pull('pending_add_to_cart'); + if ($pendingPluginId) { + $plugin = Plugin::find($pendingPluginId); + if ($plugin && $plugin->isPaid() && $plugin->activePrice) { + $cart = $this->cartService->getCart($user); + try { + $this->cartService->addPlugin($cart, $plugin); + + return redirect()->route('cart.show') + ->with('success', "{$plugin->name} has been added to your cart!"); + } catch (\Exception $e) { + // Plugin couldn't be added, continue to normal flow + } + } + } + + return redirect()->intended(route('dashboard')); } public function login(LoginRequest $request): RedirectResponse @@ -48,7 +72,29 @@ public function login(LoginRequest $request): RedirectResponse $request->session()->regenerate(); - return redirect()->intended(route('customer.licenses')); + $user = Auth::user(); + + // Transfer guest cart to user + $this->cartService->transferGuestCartToUser($user); + + // Check for pending add-to-cart action + $pendingPluginId = session()->pull('pending_add_to_cart'); + if ($pendingPluginId) { + $plugin = Plugin::find($pendingPluginId); + if ($plugin && $plugin->isPaid() && $plugin->activePrice) { + $cart = $this->cartService->getCart($user); + try { + $this->cartService->addPlugin($cart, $plugin); + + return redirect()->route('cart.show') + ->with('success', "{$plugin->name} has been added to your cart!"); + } catch (\Exception $e) { + // Plugin couldn't be added, continue to normal flow + } + } + } + + return redirect()->intended(route('dashboard')); } public function logout(Request $request): RedirectResponse diff --git a/app/Http/Controllers/BundleController.php b/app/Http/Controllers/BundleController.php new file mode 100644 index 00000000..874a0314 --- /dev/null +++ b/app/Http/Controllers/BundleController.php @@ -0,0 +1,20 @@ +isActive(), 404); + + $bundle->load('plugins.activePrice', 'plugins.user'); + + return view('bundle-show', [ + 'bundle' => $bundle, + ]); + } +} diff --git a/app/Http/Controllers/CartController.php b/app/Http/Controllers/CartController.php new file mode 100644 index 00000000..3a63b7ba --- /dev/null +++ b/app/Http/Controllers/CartController.php @@ -0,0 +1,396 @@ +cartService->getCart($user); + + $cart->load('items.plugin.activePrice', 'items.plugin.user', 'items.pluginBundle.plugins'); + + // Refresh prices and notify of changes + $priceChanges = $this->cartService->refreshPrices($cart); + + $cart = $cart->fresh(['items.plugin.activePrice', 'items.plugin.user', 'items.pluginBundle.plugins']); + + // Check for available bundle upgrades + $bundleUpgrades = $cart->getAvailableBundleUpgrades(); + + return view('cart.show', [ + 'cart' => $cart, + 'priceChanges' => $priceChanges, + 'bundleUpgrades' => $bundleUpgrades, + ]); + } + + public function add(Request $request, Plugin $plugin): RedirectResponse|JsonResponse + { + $user = Auth::user(); + $cart = $this->cartService->getCart($user); + + try { + $this->cartService->addPlugin($cart, $plugin); + + if ($request->wantsJson()) { + return response()->json([ + 'success' => true, + 'message' => 'Plugin added to cart', + 'cart_count' => $cart->itemCount(), + ]); + } + + // Store the added plugin ID to highlight it in the cart + session()->flash('just_added_plugin_id', $plugin->id); + + return redirect()->route('cart.show') + ->with('success', ''.e($plugin->name).' has been added to your cart!'); + } catch (\InvalidArgumentException $e) { + if ($request->wantsJson()) { + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 400); + } + + return redirect()->back()->with('error', $e->getMessage()); + } + } + + public function remove(Request $request, Plugin $plugin): RedirectResponse|JsonResponse + { + $user = Auth::user(); + $cart = $this->cartService->getCart($user); + + $this->cartService->removePlugin($cart, $plugin); + + if ($request->wantsJson()) { + return response()->json([ + 'success' => true, + 'message' => 'Plugin removed from cart', + 'cart_count' => $cart->itemCount(), + ]); + } + + return redirect()->route('cart.show')->with('success', "{$plugin->name} removed from cart."); + } + + public function addBundle(Request $request, PluginBundle $bundle): RedirectResponse|JsonResponse + { + $user = Auth::user(); + $cart = $this->cartService->getCart($user); + + try { + $this->cartService->addBundle($cart, $bundle); + + if ($request->wantsJson()) { + return response()->json([ + 'success' => true, + 'message' => 'Bundle added to cart', + 'cart_count' => $cart->itemCount(), + ]); + } + + session()->flash('just_added_bundle_id', $bundle->id); + + return redirect()->route('cart.show') + ->with('success', ''.e($bundle->name).' has been added to your cart!'); + } catch (\InvalidArgumentException $e) { + if ($request->wantsJson()) { + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 400); + } + + return redirect()->back()->with('error', $e->getMessage()); + } + } + + public function removeBundle(Request $request, PluginBundle $bundle): RedirectResponse|JsonResponse + { + $user = Auth::user(); + $cart = $this->cartService->getCart($user); + + $this->cartService->removeBundle($cart, $bundle); + + if ($request->wantsJson()) { + return response()->json([ + 'success' => true, + 'message' => 'Bundle removed from cart', + 'cart_count' => $cart->itemCount(), + ]); + } + + return redirect()->route('cart.show')->with('success', "{$bundle->name} removed from cart."); + } + + public function exchangeForBundle(Request $request, PluginBundle $bundle): RedirectResponse + { + $user = Auth::user(); + $cart = $this->cartService->getCart($user); + + try { + $this->cartService->exchangeForBundle($cart, $bundle); + + return redirect()->route('cart.show') + ->with('success', 'Swapped individual plugins for '.e($bundle->name).' bundle and saved '.$bundle->formatted_savings.'!'); + } catch (\InvalidArgumentException $e) { + return redirect()->route('cart.show')->with('error', $e->getMessage()); + } + } + + public function clear(Request $request): RedirectResponse + { + $user = Auth::user(); + $cart = $this->cartService->getCart($user); + + $cart->clear(); + + return redirect()->route('cart.show')->with('success', 'Cart cleared.'); + } + + public function checkout(Request $request): RedirectResponse + { + $user = Auth::user(); + + if (! $user) { + // Store intended URL and redirect to login + session(['url.intended' => route('cart.checkout')]); + + return redirect()->route('customer.login') + ->with('message', 'Please log in or create an account to complete your purchase.'); + } + + $cart = $this->cartService->getCart($user); + + if ($cart->isEmpty()) { + return redirect()->route('cart.show') + ->with('error', 'Your cart is empty.'); + } + + // Refresh prices + $this->cartService->refreshPrices($cart); + + try { + $session = $this->createMultiItemCheckoutSession($cart, $user); + + return redirect($session->url); + } catch (\Exception $e) { + Log::error('Cart checkout failed', [ + 'cart_id' => $cart->id, + 'user_id' => $user->id, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + return redirect()->route('cart.show') + ->with('error', 'Unable to start checkout. Please try again.'); + } + } + + public function success(Request $request): View|RedirectResponse + { + $sessionId = $request->query('session_id'); + + // Validate session ID exists and looks like a real Stripe session ID + if (! $sessionId || ! str_starts_with($sessionId, 'cs_')) { + return redirect()->route('cart.show') + ->with('error', 'Invalid checkout session. Please try again.'); + } + + $user = Auth::user(); + + // Clear the cart - the webhook will create the licenses + $cart = $this->cartService->getCart($user); + $cart->clear(); + + return view('cart.success', [ + 'sessionId' => $sessionId, + ]); + } + + public function status(Request $request, string $sessionId): JsonResponse + { + $user = Auth::user(); + + // Check if licenses exist for this checkout session + $licenses = $user->pluginLicenses() + ->where('stripe_checkout_session_id', $sessionId) + ->with('plugin') + ->get(); + + if ($licenses->isEmpty()) { + return response()->json([ + 'status' => 'pending', + 'message' => 'Processing your purchase...', + ]); + } + + return response()->json([ + 'status' => 'complete', + 'message' => 'Purchase complete!', + 'licenses' => $licenses->map(fn ($license) => [ + 'id' => $license->id, + 'plugin_name' => $license->plugin->name, + 'plugin_slug' => $license->plugin->slug, + ]), + ]); + } + + public function cancel(): RedirectResponse + { + return redirect()->route('cart.show') + ->with('message', 'Checkout cancelled. Your cart items are still saved.'); + } + + public function count(Request $request): JsonResponse + { + $user = Auth::user(); + $count = $this->cartService->getCartItemCount($user); + + return response()->json(['count' => $count]); + } + + protected function createMultiItemCheckoutSession($cart, $user): \Stripe\Checkout\Session + { + $stripe = new \Stripe\StripeClient(config('cashier.secret')); + + // Eager load items with plugins and bundles to avoid any stale data issues + $cart->load('items.plugin', 'items.pluginBundle.plugins'); + + $lineItems = []; + $metadata = [ + 'cart_id' => $cart->id, + 'user_id' => $user->id, + 'plugin_ids' => [], + 'price_ids' => [], + 'bundle_ids' => [], + 'bundle_plugin_ids' => [], + ]; + + Log::info('Creating multi-item checkout session', [ + 'cart_id' => $cart->id, + 'user_id' => $user->id, + 'item_count' => $cart->items->count(), + ]); + + foreach ($cart->items as $item) { + if ($item->isBundle()) { + $bundle = $item->pluginBundle; + + Log::info('Adding bundle to checkout session', [ + 'cart_id' => $cart->id, + 'cart_item_id' => $item->id, + 'bundle_id' => $bundle->id, + 'bundle_name' => $bundle->name, + 'plugin_count' => $bundle->plugins->count(), + ]); + + $pluginNames = $bundle->plugins->pluck('name')->take(3)->implode(', '); + if ($bundle->plugins->count() > 3) { + $pluginNames .= ' and '.($bundle->plugins->count() - 3).' more'; + } + + $lineItems[] = [ + 'price_data' => [ + 'currency' => strtolower($item->currency), + 'unit_amount' => $item->bundle_price_at_addition, + 'product_data' => [ + 'name' => $bundle->name.' (Bundle)', + 'description' => 'Includes: '.$pluginNames, + ], + ], + 'quantity' => 1, + ]; + + $metadata['bundle_ids'][] = $bundle->id; + $metadata['bundle_plugin_ids'][$bundle->id] = $bundle->plugins->pluck('id')->implode(':'); + } else { + $plugin = $item->plugin; + + Log::info('Adding plugin to checkout session', [ + 'cart_id' => $cart->id, + 'cart_item_id' => $item->id, + 'plugin_id' => $plugin->id, + 'plugin_name' => $plugin->name, + 'price_id' => $item->plugin_price_id, + ]); + + $lineItems[] = [ + 'price_data' => [ + 'currency' => strtolower($item->currency), + 'unit_amount' => $item->price_at_addition, + 'product_data' => [ + 'name' => $plugin->name, + 'description' => $plugin->description ?? 'NativePHP Plugin', + ], + ], + 'quantity' => 1, + ]; + + $metadata['plugin_ids'][] = $plugin->id; + $metadata['price_ids'][] = $item->plugin_price_id; + } + } + + // Encode arrays for Stripe metadata (must be strings) + $metadata['cart_id'] = (string) $metadata['cart_id']; + $metadata['user_id'] = (string) $metadata['user_id']; + $metadata['plugin_ids'] = implode(',', $metadata['plugin_ids']); + $metadata['price_ids'] = implode(',', $metadata['price_ids']); + $metadata['bundle_ids'] = implode(',', $metadata['bundle_ids']); + $metadata['bundle_plugin_ids'] = json_encode($metadata['bundle_plugin_ids']); + + Log::info('Checkout session metadata prepared', [ + 'cart_id' => $cart->id, + 'metadata' => $metadata, + 'line_items_count' => count($lineItems), + ]); + + // Ensure the user has a Stripe customer ID (required for Stripe Accounts V2) + if (! $user->stripe_id) { + $user->createAsStripeCustomer(); + } + + return $stripe->checkout->sessions->create([ + 'mode' => 'payment', + 'line_items' => $lineItems, + 'success_url' => route('cart.success').'?session_id={CHECKOUT_SESSION_ID}', + 'cancel_url' => route('cart.cancel'), + 'customer' => $user->stripe_id, + 'customer_update' => [ + 'name' => 'auto', + 'address' => 'auto', + ], + 'metadata' => $metadata, + 'billing_address_collection' => 'required', + 'tax_id_collection' => ['enabled' => true], + 'invoice_creation' => [ + 'enabled' => true, + 'invoice_data' => [ + 'description' => 'NativePHP Plugin Purchase', + 'footer' => 'Thank you for your purchase!', + ], + ], + ]); + } +} diff --git a/app/Http/Controllers/CustomerLicenseController.php b/app/Http/Controllers/CustomerLicenseController.php index a7571ac4..115d5dc0 100644 --- a/app/Http/Controllers/CustomerLicenseController.php +++ b/app/Http/Controllers/CustomerLicenseController.php @@ -30,7 +30,13 @@ public function index(): View ->orderBy('created_at', 'desc') ->get(); - return view('customer.licenses.index', compact('licenses', 'assignedSubLicenses')); + // Fetch plugin licenses (purchased plugins) + $pluginLicenses = $user->pluginLicenses() + ->with('plugin') + ->orderBy('purchased_at', 'desc') + ->get(); + + return view('customer.licenses.index', compact('licenses', 'assignedSubLicenses', 'pluginLicenses')); } public function show(string $licenseKey): View diff --git a/app/Http/Controllers/CustomerPluginController.php b/app/Http/Controllers/CustomerPluginController.php new file mode 100644 index 00000000..1068a3ed --- /dev/null +++ b/app/Http/Controllers/CustomerPluginController.php @@ -0,0 +1,224 @@ +middleware('auth'); + } + + public function index(): View + { + $user = Auth::user(); + $plugins = $user->plugins()->orderBy('created_at', 'desc')->get(); + $developerAccount = $user->developerAccount; + + return view('customer.plugins.index', compact('plugins', 'developerAccount')); + } + + public function create(): View + { + return view('customer.plugins.create'); + } + + public function store(SubmitPluginRequest $request, PluginSyncService $syncService): RedirectResponse + { + $user = Auth::user(); + + // Reject paid plugin submissions if the feature is disabled + if ($request->type === 'paid' && ! Feature::active(AllowPaidPlugins::class)) { + return redirect()->route('customer.plugins.create') + ->with('error', 'Paid plugin submissions are not currently available.'); + } + + // For paid plugins, link to the user's developer account if they have one + $developerAccountId = null; + if ($request->type === 'paid' && $user->developerAccount) { + $developerAccountId = $user->developerAccount->id; + } + + $plugin = $user->plugins()->create([ + 'repository_url' => $request->repository_url, + 'type' => $request->type, + 'status' => PluginStatus::Pending, + 'developer_account_id' => $developerAccountId, + ]); + + $plugin->generateWebhookSecret(); + + if ($request->type === 'paid' && $request->price) { + $plugin->prices()->create([ + 'amount' => $request->price * 100, + 'currency' => 'usd', + 'is_active' => true, + ]); + } + + $syncService->sync($plugin); + + if (! $plugin->name) { + $plugin->delete(); + + return redirect()->route('customer.plugins.create') + ->with('error', 'Could not find a valid composer.json in the repository. Please ensure your repository contains a composer.json with a valid package name.'); + } + + // Check if the vendor namespace is available for this user + $namespace = $plugin->getVendorNamespace(); + if ($namespace && ! Plugin::isNamespaceAvailableForUser($namespace, $user->id)) { + $plugin->delete(); + + $errorMessage = Plugin::isReservedNamespace($namespace) + ? "The namespace '{$namespace}' is reserved and cannot be used for plugin submissions." + : "The namespace '{$namespace}' is already claimed by another user. You cannot submit plugins under this namespace."; + + return redirect()->route('customer.plugins.create') + ->with('error', $errorMessage); + } + + return redirect()->route('customer.plugins.show', $plugin) + ->with('success', 'Your plugin has been submitted for review!'); + } + + public function show(Plugin $plugin): View + { + $user = Auth::user(); + + if ($plugin->user_id !== $user->id) { + abort(403); + } + + return view('customer.plugins.show', compact('plugin')); + } + + public function update(UpdatePluginDescriptionRequest $request, Plugin $plugin): RedirectResponse + { + $user = Auth::user(); + + if ($plugin->user_id !== $user->id) { + abort(403); + } + + $plugin->updateDescription($request->description, $user->id); + + return redirect()->route('customer.plugins.show', $plugin) + ->with('success', 'Plugin description updated successfully!'); + } + + public function resubmit(Plugin $plugin): RedirectResponse + { + $user = Auth::user(); + + // Ensure the plugin belongs to the current user + if ($plugin->user_id !== $user->id) { + abort(403); + } + + // Only rejected plugins can be resubmitted + if (! $plugin->isRejected()) { + return redirect()->route('customer.plugins.index') + ->with('error', 'Only rejected plugins can be resubmitted.'); + } + + $plugin->resubmit(); + + return redirect()->route('customer.plugins.index') + ->with('success', 'Your plugin has been resubmitted for review!'); + } + + public function updateLogo(UpdatePluginLogoRequest $request, Plugin $plugin): RedirectResponse + { + $user = Auth::user(); + + if ($plugin->user_id !== $user->id) { + abort(403); + } + + if ($plugin->logo_path) { + Storage::disk('public')->delete($plugin->logo_path); + } + + $path = $request->file('logo')->store('plugin-logos', 'public'); + + $plugin->update(['logo_path' => $path]); + + return redirect()->route('customer.plugins.show', $plugin) + ->with('success', 'Plugin logo updated successfully!'); + } + + public function deleteLogo(Plugin $plugin): RedirectResponse + { + $user = Auth::user(); + + if ($plugin->user_id !== $user->id) { + abort(403); + } + + if ($plugin->logo_path) { + Storage::disk('public')->delete($plugin->logo_path); + $plugin->update(['logo_path' => null]); + } + + return redirect()->route('customer.plugins.show', $plugin) + ->with('success', 'Plugin logo removed successfully!'); + } + + public function updateDisplayName(): RedirectResponse + { + $user = Auth::user(); + + $validated = request()->validate([ + 'display_name' => ['nullable', 'string', 'max:255'], + ]); + + $user->update([ + 'display_name' => $validated['display_name'] ?: null, + ]); + + return redirect()->route('customer.plugins.index') + ->with('success', 'Display name updated successfully!'); + } + + public function updatePrice(UpdatePluginPriceRequest $request, Plugin $plugin): RedirectResponse + { + $user = Auth::user(); + + if ($plugin->user_id !== $user->id) { + abort(403); + } + + if (! $plugin->isPaid()) { + return redirect()->route('customer.plugins.show', $plugin) + ->with('error', 'Only paid plugins can have pricing updated.'); + } + + // Deactivate existing prices + $plugin->prices()->update(['is_active' => false]); + + // Create new active price + $plugin->prices()->create([ + 'amount' => $request->price * 100, + 'currency' => 'usd', + 'is_active' => true, + ]); + + return redirect()->route('customer.plugins.show', $plugin) + ->with('success', 'Plugin price updated successfully!'); + } +} diff --git a/app/Http/Controllers/DeveloperOnboardingController.php b/app/Http/Controllers/DeveloperOnboardingController.php new file mode 100644 index 00000000..ebfcbfdb --- /dev/null +++ b/app/Http/Controllers/DeveloperOnboardingController.php @@ -0,0 +1,125 @@ +user(); + $developerAccount = $user->developerAccount; + + if ($developerAccount && $developerAccount->hasCompletedOnboarding()) { + return redirect()->route('customer.developer.dashboard') + ->with('message', 'Your developer account is already set up.'); + } + + return view('customer.developer.onboarding', [ + 'developerAccount' => $developerAccount, + 'hasExistingAccount' => $developerAccount !== null, + ]); + } + + public function start(Request $request): RedirectResponse + { + $user = $request->user(); + $developerAccount = $user->developerAccount; + + if (! $developerAccount) { + $developerAccount = $this->stripeConnectService->createConnectAccount($user); + } + + try { + $onboardingUrl = $this->stripeConnectService->createOnboardingLink($developerAccount); + + return redirect($onboardingUrl); + } catch (\Exception $e) { + return redirect()->route('customer.developer.onboarding') + ->with('error', 'Unable to start onboarding. Please try again.'); + } + } + + public function return(Request $request): RedirectResponse + { + $user = $request->user(); + $developerAccount = $user->developerAccount; + + if (! $developerAccount) { + return redirect()->route('customer.developer.onboarding') + ->with('error', 'Developer account not found.'); + } + + $this->stripeConnectService->refreshAccountStatus($developerAccount); + + if ($developerAccount->hasCompletedOnboarding()) { + // Link any existing paid plugins that don't have a developer account + $user->plugins() + ->where('type', 'paid') + ->whereNull('developer_account_id') + ->update(['developer_account_id' => $developerAccount->id]); + + return redirect()->route('customer.developer.dashboard') + ->with('success', 'Your developer account is now active!'); + } + + return redirect()->route('customer.developer.onboarding') + ->with('message', 'Onboarding is not complete. Please finish the remaining steps.'); + } + + public function refresh(Request $request): RedirectResponse + { + $user = $request->user(); + $developerAccount = $user->developerAccount; + + if (! $developerAccount) { + return redirect()->route('customer.developer.onboarding'); + } + + try { + $onboardingUrl = $this->stripeConnectService->createOnboardingLink($developerAccount); + + return redirect($onboardingUrl); + } catch (\Exception $e) { + return redirect()->route('customer.developer.onboarding') + ->with('error', 'Unable to refresh onboarding. Please try again.'); + } + } + + public function dashboard(Request $request): View|RedirectResponse + { + $user = $request->user(); + $developerAccount = $user->developerAccount; + + if (! $developerAccount || ! $developerAccount->hasCompletedOnboarding()) { + return redirect()->route('customer.developer.onboarding'); + } + + $this->stripeConnectService->refreshAccountStatus($developerAccount); + + $plugins = $user->plugins()->withCount('licenses')->get(); + $payouts = $developerAccount->payouts()->with('pluginLicense.plugin')->latest()->limit(10)->get(); + + $totalEarnings = $developerAccount->payouts() + ->where('status', 'transferred') + ->sum('developer_amount'); + + $pendingEarnings = $developerAccount->payouts() + ->where('status', 'pending') + ->sum('developer_amount'); + + return view('customer.developer.dashboard', [ + 'developerAccount' => $developerAccount, + 'plugins' => $plugins, + 'payouts' => $payouts, + 'totalEarnings' => $totalEarnings, + 'pendingEarnings' => $pendingEarnings, + ]); + } +} diff --git a/app/Http/Controllers/GitHubAuthController.php b/app/Http/Controllers/GitHubAuthController.php new file mode 100644 index 00000000..731eb149 --- /dev/null +++ b/app/Http/Controllers/GitHubAuthController.php @@ -0,0 +1,18 @@ + 'login']); + + return Socialite::driver('github') + ->scopes(['read:user', 'user:email']) + ->redirect(); + } +} diff --git a/app/Http/Controllers/GitHubIntegrationController.php b/app/Http/Controllers/GitHubIntegrationController.php index da929e9b..b8fe097c 100644 --- a/app/Http/Controllers/GitHubIntegrationController.php +++ b/app/Http/Controllers/GitHubIntegrationController.php @@ -2,23 +2,34 @@ namespace App\Http\Controllers; +use App\Models\User; +use App\Services\GitHubUserService; use App\Support\GitHubOAuth; +use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; use Laravel\Socialite\Facades\Socialite; class GitHubIntegrationController extends Controller { public function __construct() { - $this->middleware('auth'); + $this->middleware('auth')->except('handleCallback'); } public function redirectToGitHub(): RedirectResponse { + session(['github_auth_intent' => 'link']); + + // Store the return URL if provided + if (request()->has('return')) { + session(['github_return_url' => request()->get('return')]); + } + return Socialite::driver('github') - ->scopes(['read:user']) + ->scopes(['read:user', 'repo']) ->redirect(); } @@ -27,25 +38,97 @@ public function handleCallback(): RedirectResponse try { $githubUser = Socialite::driver('github')->user(); - $user = Auth::user(); - $user->update([ - 'github_id' => $githubUser->id, - 'github_username' => $githubUser->nickname, - ]); + $intent = session()->pull('github_auth_intent', 'link'); - return redirect()->route('customer.licenses') - ->with('success', 'GitHub account connected successfully!'); + if (Auth::check()) { + return $this->handleLinkAccount($githubUser); + } + + if ($intent === 'login') { + return $this->handleLogin($githubUser); + } + + return redirect()->route('customer.login') + ->with('error', 'Please log in first to connect your GitHub account.'); } catch (\Exception $e) { Log::error('GitHub OAuth callback failed', [ 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); - return redirect()->route('customer.licenses') - ->with('error', 'Failed to connect GitHub account. Please try again.'); + $route = Auth::check() ? 'customer.licenses' : 'customer.login'; + + return redirect()->route($route) + ->with('error', 'GitHub authentication failed. Please try again.'); } } + protected function handleLinkAccount($githubUser): RedirectResponse + { + $user = Auth::user(); + $user->update([ + 'github_id' => $githubUser->id, + 'github_username' => $githubUser->nickname, + 'github_token' => encrypt($githubUser->token), + ]); + + $returnUrl = session()->pull('github_return_url'); + + if ($returnUrl) { + return redirect($returnUrl) + ->with('success', 'GitHub account connected successfully!'); + } + + return redirect()->route('dashboard') + ->with('success', 'GitHub account connected successfully!'); + } + + protected function handleLogin($githubUser): RedirectResponse + { + $user = User::where('github_id', $githubUser->id)->first(); + + if ($user) { + $user->update([ + 'github_token' => encrypt($githubUser->token), + ]); + + Auth::login($user, remember: true); + + return redirect()->intended(route('dashboard')) + ->with('success', 'Welcome back!'); + } + + $user = User::where('email', $githubUser->email)->first(); + + if ($user) { + $user->update([ + 'github_id' => $githubUser->id, + 'github_username' => $githubUser->nickname, + 'github_token' => encrypt($githubUser->token), + ]); + + Auth::login($user, remember: true); + + return redirect()->intended(route('dashboard')) + ->with('success', 'GitHub account connected and logged in!'); + } + + $user = User::create([ + 'name' => $githubUser->name ?? $githubUser->nickname, + 'email' => $githubUser->email, + 'github_id' => $githubUser->id, + 'github_username' => $githubUser->nickname, + 'github_token' => encrypt($githubUser->token), + 'password' => bcrypt(Str::random(24)), + 'email_verified_at' => now(), + ]); + + Auth::login($user, remember: true); + + return redirect()->route('dashboard') + ->with('success', 'Account created successfully!'); + } + public function requestRepoAccess(): RedirectResponse { $user = Auth::user(); @@ -84,9 +167,29 @@ public function disconnect(): RedirectResponse $user->update([ 'github_id' => null, 'github_username' => null, + 'github_token' => null, 'mobile_repo_access_granted_at' => null, ]); return back()->with('success', 'GitHub account disconnected successfully.'); } + + public function repositories(): JsonResponse + { + $user = Auth::user(); + + if (! $user->hasGitHubToken()) { + return response()->json([ + 'error' => 'GitHub account not connected or token expired', + 'repositories' => [], + ], 401); + } + + $service = GitHubUserService::for($user); + $repositories = $service->getRepositories(includePrivate: true); + + return response()->json([ + 'repositories' => $repositories, + ]); + } } diff --git a/app/Http/Controllers/PluginDirectoryController.php b/app/Http/Controllers/PluginDirectoryController.php new file mode 100644 index 00000000..3d5cb3f1 --- /dev/null +++ b/app/Http/Controllers/PluginDirectoryController.php @@ -0,0 +1,51 @@ +approved() + ->featured() + ->latest() + ->take(3) + ->get(); + + $latestPlugins = Plugin::query() + ->approved() + ->where('featured', false) + ->latest() + ->take(3) + ->get(); + + $bundles = PluginBundle::query() + ->active() + ->with('plugins') + ->latest() + ->get(); + + return view('plugins', [ + 'featuredPlugins' => $featuredPlugins, + 'latestPlugins' => $latestPlugins, + 'bundles' => $bundles, + ]); + } + + public function show(Plugin $plugin): View + { + abort_unless($plugin->isApproved(), 404); + + $bundles = $plugin->bundles()->active()->get(); + + return view('plugin-show', [ + 'plugin' => $plugin, + 'bundles' => $bundles, + ]); + } +} diff --git a/app/Http/Controllers/PluginPurchaseController.php b/app/Http/Controllers/PluginPurchaseController.php new file mode 100644 index 00000000..447450fc --- /dev/null +++ b/app/Http/Controllers/PluginPurchaseController.php @@ -0,0 +1,125 @@ +isFree()) { + return redirect()->route('plugins.show', $plugin); + } + + $user = $request->user(); + $activePrice = $plugin->activePrice; + + if (! $activePrice) { + return redirect()->route('plugins.show', $plugin) + ->with('error', 'This plugin is not available for purchase.'); + } + + $discountPercent = $this->grandfatheringService->getApplicableDiscount($user, $plugin->is_official); + $discountedAmount = $activePrice->getDiscountedAmount($discountPercent); + + return view('plugins.purchase', [ + 'plugin' => $plugin, + 'price' => $activePrice, + 'discountPercent' => $discountPercent, + 'discountedAmount' => $discountedAmount, + 'originalAmount' => $activePrice->amount, + ]); + } + + public function checkout(Request $request, Plugin $plugin): RedirectResponse + { + $user = $request->user(); + + if ($plugin->isFree()) { + return redirect()->route('plugins.show', $plugin); + } + + $activePrice = $plugin->activePrice; + + if (! $activePrice) { + return redirect()->route('plugins.show', $plugin) + ->with('error', 'This plugin is not available for purchase.'); + } + + try { + $session = $this->stripeConnectService->createCheckoutSession($activePrice, $user); + + return redirect($session->url); + } catch (\Exception $e) { + Log::error('Plugin checkout failed', [ + 'plugin_id' => $plugin->id, + 'user_id' => $user->id, + 'price_id' => $activePrice->id, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + return redirect()->route('plugins.purchase.show', $plugin) + ->with('error', 'Unable to start checkout. Please try again.'); + } + } + + public function success(Request $request, Plugin $plugin): View|RedirectResponse + { + $sessionId = $request->query('session_id'); + + // Validate session ID exists and looks like a real Stripe session ID + if (! $sessionId || ! str_starts_with($sessionId, 'cs_')) { + return redirect()->route('plugins.show', $plugin) + ->with('error', 'Invalid checkout session. Please try again.'); + } + + return view('plugins.purchase-success', [ + 'plugin' => $plugin, + 'sessionId' => $sessionId, + ]); + } + + public function status(Request $request, Plugin $plugin, string $sessionId): JsonResponse + { + $user = $request->user(); + + // Check if license exists for this checkout session and plugin + $license = $user->pluginLicenses() + ->where('stripe_checkout_session_id', $sessionId) + ->where('plugin_id', $plugin->id) + ->first(); + + if (! $license) { + return response()->json([ + 'status' => 'pending', + 'message' => 'Processing your purchase...', + ]); + } + + return response()->json([ + 'status' => 'complete', + 'message' => 'Purchase complete!', + 'plugin_name' => $plugin->name, + ]); + } + + public function cancel(Request $request, Plugin $plugin): RedirectResponse + { + return redirect()->route('plugins.show', $plugin) + ->with('message', 'Purchase cancelled.'); + } +} diff --git a/app/Http/Controllers/PluginWebhookController.php b/app/Http/Controllers/PluginWebhookController.php new file mode 100644 index 00000000..31ebbbbf --- /dev/null +++ b/app/Http/Controllers/PluginWebhookController.php @@ -0,0 +1,68 @@ +first(); + + if (! $plugin) { + return response()->json(['error' => 'Invalid webhook secret'], 404); + } + + if (! $plugin->isApproved()) { + return response()->json(['error' => 'Plugin is not approved'], 403); + } + + $event = $request->header('X-GitHub-Event'); + + if ($event === 'release') { + // Sync plugin metadata to update latest_version + $syncService->sync($plugin); + + // Queue release sync for version records + SyncPluginReleases::dispatch($plugin); + + return response()->json([ + 'success' => true, + 'message' => 'Release sync queued', + 'synced_at' => $plugin->fresh()->last_synced_at->toIso8601String(), + ]); + } + + if ($event === 'push') { + $synced = $syncService->sync($plugin); + + if (! $synced) { + return response()->json(['error' => 'Failed to sync plugin'], 500); + } + + return response()->json([ + 'success' => true, + 'synced_at' => $plugin->fresh()->last_synced_at->toIso8601String(), + ]); + } + + $synced = $syncService->sync($plugin); + + if (! $synced) { + return response()->json(['error' => 'Failed to sync plugin'], 500); + } + + SyncPluginReleases::dispatch($plugin); + + return response()->json([ + 'success' => true, + 'synced_at' => $plugin->fresh()->last_synced_at->toIso8601String(), + 'releases_sync' => 'queued', + ]); + } +} diff --git a/app/Http/Controllers/WallOfLoveSubmissionController.php b/app/Http/Controllers/WallOfLoveSubmissionController.php index 42103aa6..cbb98b98 100644 --- a/app/Http/Controllers/WallOfLoveSubmissionController.php +++ b/app/Http/Controllers/WallOfLoveSubmissionController.php @@ -20,7 +20,7 @@ public function create() $hasExistingSubmission = auth()->user()->wallOfLoveSubmissions()->exists(); if ($hasExistingSubmission) { - return redirect()->route('customer.licenses')->with('info', 'You have already submitted your story to the Wall of Love.'); + return redirect()->route('dashboard')->with('info', 'You have already submitted your story to the Wall of Love.'); } return view('customer.wall-of-love.create'); diff --git a/app/Http/Middleware/AuthenticateApiKey.php b/app/Http/Middleware/AuthenticateApiKey.php index 95189696..c491e5dd 100644 --- a/app/Http/Middleware/AuthenticateApiKey.php +++ b/app/Http/Middleware/AuthenticateApiKey.php @@ -21,13 +21,19 @@ public function handle(Request $request, Closure $next): Response return response()->json(['message' => 'API key not configured'], 500); } - $authHeader = $request->header('Authorization'); + // Prefer X-API-Key header (allows Basic Auth to coexist) + // Fall back to Bearer token in Authorization header + $providedKey = $request->header('X-API-Key'); - if (! $authHeader || ! str_starts_with($authHeader, 'Bearer ')) { - return response()->json(['message' => 'Unauthorized'], 401); - } + if (! $providedKey) { + $authHeader = $request->header('Authorization'); - $providedKey = substr($authHeader, 7); // Remove 'Bearer ' prefix + if (! $authHeader || ! str_starts_with($authHeader, 'Bearer ')) { + return response()->json(['message' => 'Unauthorized'], 401); + } + + $providedKey = substr($authHeader, 7); + } if (! hash_equals($apiKey, $providedKey)) { return response()->json(['message' => 'Unauthorized'], 401); diff --git a/app/Http/Middleware/VerifyCsrfToken.php b/app/Http/Middleware/VerifyCsrfToken.php index 0c163a38..6d7c9382 100644 --- a/app/Http/Middleware/VerifyCsrfToken.php +++ b/app/Http/Middleware/VerifyCsrfToken.php @@ -14,5 +14,6 @@ class VerifyCsrfToken extends Middleware protected $except = [ 'stripe/webhook', 'opencollective/contribution', + 'webhooks/plugins/*', ]; } diff --git a/app/Http/Requests/SubmitPluginRequest.php b/app/Http/Requests/SubmitPluginRequest.php new file mode 100644 index 00000000..e72ddb2c --- /dev/null +++ b/app/Http/Requests/SubmitPluginRequest.php @@ -0,0 +1,61 @@ + [ + 'required', + 'url', + 'max:255', + 'regex:/^https:\/\/github\.com\/[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/', + 'unique:plugins,repository_url', + ], + 'type' => ['required', 'string', Rule::enum(PluginType::class)], + 'price' => [ + 'nullable', + 'required_if:type,paid', + 'integer', + 'min:10', + 'max:99999', + ], + ]; + } + + public function messages(): array + { + return [ + 'repository_url.required' => 'Please select or enter your plugin\'s GitHub repository.', + 'repository_url.url' => 'Please enter a valid URL.', + 'repository_url.regex' => 'Please enter a valid GitHub repository URL (e.g., https://github.com/vendor/repo).', + 'repository_url.unique' => 'This repository has already been submitted.', + 'type.required' => 'Please select whether your plugin is free or paid.', + 'type.enum' => 'Please select a valid plugin type.', + 'price.required_if' => 'Please enter a price for your paid plugin.', + 'price.integer' => 'The price must be a whole dollar amount (no cents).', + 'price.min' => 'The price must be at least $10.', + 'price.max' => 'The price cannot exceed $99,999.', + ]; + } + + public function withValidator($validator): void + { + $validator->after(function ($validator) { + if ($this->type === 'paid' && ! $this->user()->github_id) { + $validator->errors()->add('type', 'You must connect your GitHub account to submit a paid plugin.'); + } + }); + } +} diff --git a/app/Http/Requests/UpdatePluginDescriptionRequest.php b/app/Http/Requests/UpdatePluginDescriptionRequest.php new file mode 100644 index 00000000..ccefdc9e --- /dev/null +++ b/app/Http/Requests/UpdatePluginDescriptionRequest.php @@ -0,0 +1,34 @@ +> + */ + public function rules(): array + { + return [ + 'description' => ['required', 'string', 'max:1000'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'description.required' => 'Please provide a description for your plugin.', + 'description.max' => 'The description must not exceed 1000 characters.', + ]; + } +} diff --git a/app/Http/Requests/UpdatePluginLogoRequest.php b/app/Http/Requests/UpdatePluginLogoRequest.php new file mode 100644 index 00000000..466b70d8 --- /dev/null +++ b/app/Http/Requests/UpdatePluginLogoRequest.php @@ -0,0 +1,37 @@ + [ + 'required', + 'image', + 'mimes:png,jpg,jpeg,svg,webp', + 'max:1024', + 'dimensions:min_width=100,min_height=100,max_width=1024,max_height=1024', + ], + ]; + } + + public function messages(): array + { + return [ + 'logo.required' => 'Please select a logo image to upload.', + 'logo.image' => 'The file must be an image.', + 'logo.mimes' => 'The logo must be a PNG, JPG, JPEG, SVG, or WebP file.', + 'logo.max' => 'The logo must be less than 1MB.', + 'logo.dimensions' => 'The logo must be between 100x100 and 1024x1024 pixels.', + ]; + } +} diff --git a/app/Http/Requests/UpdatePluginPriceRequest.php b/app/Http/Requests/UpdatePluginPriceRequest.php new file mode 100644 index 00000000..e13870d5 --- /dev/null +++ b/app/Http/Requests/UpdatePluginPriceRequest.php @@ -0,0 +1,41 @@ +|string> + */ + public function rules(): array + { + return [ + 'price' => ['required', 'integer', 'min:10', 'max:99999'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'price.required' => 'Please enter a price for your plugin.', + 'price.integer' => 'The price must be a whole dollar amount (no cents).', + 'price.min' => 'The minimum price is $10.', + 'price.max' => 'The maximum price is $99,999.', + ]; + } +} diff --git a/app/Jobs/ProcessPluginCheckoutJob.php b/app/Jobs/ProcessPluginCheckoutJob.php new file mode 100644 index 00000000..e96ff117 --- /dev/null +++ b/app/Jobs/ProcessPluginCheckoutJob.php @@ -0,0 +1,336 @@ +metadata['user_id'] ?? null; + $cartId = $this->metadata['cart_id'] ?? null; + + if (! $userId) { + Log::error('No user_id in checkout session metadata', ['session_id' => $this->checkoutSessionId]); + + return; + } + + $user = User::find($userId); + + if (! $user) { + Log::error('User not found for checkout session', ['session_id' => $this->checkoutSessionId, 'user_id' => $userId]); + + return; + } + + // Handle bundle checkout + if (isset($this->metadata['bundle_ids']) && ! empty($this->metadata['bundle_ids'])) { + $this->processBundleCheckout($user); + } + + // Handle cart checkout (individual plugins) + if ($cartId && isset($this->metadata['plugin_ids']) && ! empty($this->metadata['plugin_ids'])) { + $this->processCartCheckout($user); + + return; + } + + // Handle single plugin checkout + if (isset($this->metadata['plugin_id'])) { + // Check if single plugin already processed + if (PluginLicense::where('stripe_checkout_session_id', $this->checkoutSessionId)->exists()) { + Log::info('Single plugin checkout already processed', ['session_id' => $this->checkoutSessionId]); + + return; + } + + $this->processSinglePluginCheckout($user); + + return; + } + + Log::error('Unknown checkout session format', ['session_id' => $this->checkoutSessionId, 'metadata' => $this->metadata]); + } + + protected function processCartCheckout(User $user): void + { + Log::info('Starting cart checkout processing', [ + 'session_id' => $this->checkoutSessionId, + 'metadata' => $this->metadata, + ]); + + $pluginIds = explode(',', $this->metadata['plugin_ids']); + $priceIds = explode(',', $this->metadata['price_ids'] ?? ''); + + Log::info('Parsed plugin IDs from metadata', [ + 'session_id' => $this->checkoutSessionId, + 'raw_plugin_ids' => $this->metadata['plugin_ids'], + 'parsed_plugin_ids' => $pluginIds, + 'plugin_count' => count($pluginIds), + ]); + + // Get already processed plugin IDs for this session + $alreadyProcessedPluginIds = PluginLicense::where('stripe_checkout_session_id', $this->checkoutSessionId) + ->pluck('plugin_id') + ->toArray(); + + $processedCount = 0; + $skippedCount = count($alreadyProcessedPluginIds); + + foreach ($pluginIds as $index => $pluginId) { + Log::info('Processing plugin in cart', [ + 'session_id' => $this->checkoutSessionId, + 'index' => $index, + 'plugin_id' => $pluginId, + 'already_processed' => in_array((int) $pluginId, $alreadyProcessedPluginIds), + ]); + + // Skip if this plugin was already processed for this session + if (in_array((int) $pluginId, $alreadyProcessedPluginIds)) { + continue; + } + + $plugin = Plugin::find($pluginId); + + if (! $plugin) { + Log::warning('Plugin not found during checkout processing', ['plugin_id' => $pluginId]); + + continue; + } + + $priceId = $priceIds[$index] ?? null; + $price = $priceId ? PluginPrice::find($priceId) : $plugin->activePrice; + $amount = $price ? $price->amount : 0; + + try { + $this->createLicense($user, $plugin, $amount); + $processedCount++; + Log::info('Successfully created license for plugin', [ + 'session_id' => $this->checkoutSessionId, + 'plugin_id' => $pluginId, + 'plugin_name' => $plugin->name, + ]); + } catch (\Exception $e) { + Log::error('Failed to create license for plugin', [ + 'session_id' => $this->checkoutSessionId, + 'plugin_id' => $pluginId, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + throw $e; + } + } + + // Ensure user has a plugin license key + $user->getPluginLicenseKey(); + + Log::info('Processed cart checkout', [ + 'session_id' => $this->checkoutSessionId, + 'user_id' => $user->id, + 'total_plugins' => count($pluginIds), + 'processed_now' => $processedCount, + 'already_processed' => $skippedCount, + ]); + } + + protected function processBundleCheckout(User $user): void + { + $bundleIds = array_filter(explode(',', $this->metadata['bundle_ids'])); + $bundlePluginIds = json_decode($this->metadata['bundle_plugin_ids'] ?? '{}', true); + + Log::info('Processing bundle checkout', [ + 'session_id' => $this->checkoutSessionId, + 'bundle_ids' => $bundleIds, + ]); + + foreach ($bundleIds as $bundleId) { + $bundle = PluginBundle::with(['plugins.developerAccount', 'plugins.activePrice']) + ->find($bundleId); + + if (! $bundle) { + Log::warning('Bundle not found during checkout processing', ['bundle_id' => $bundleId]); + + continue; + } + + // Check how many plugins are already processed for this bundle in this session + $existingLicenseCount = PluginLicense::where('stripe_checkout_session_id', $this->checkoutSessionId) + ->where('plugin_bundle_id', $bundleId) + ->count(); + + if ($existingLicenseCount === $bundle->plugins->count()) { + Log::info('Bundle already fully processed', [ + 'session_id' => $this->checkoutSessionId, + 'bundle_id' => $bundleId, + ]); + + continue; + } + + // Calculate proportional allocation for developer payouts + $allocations = $bundle->calculateProportionalAllocation(); + + foreach ($bundle->plugins as $plugin) { + // Skip if license already exists for this plugin in this session + if (PluginLicense::where('stripe_checkout_session_id', $this->checkoutSessionId) + ->where('plugin_id', $plugin->id) + ->where('plugin_bundle_id', $bundleId) + ->exists()) { + continue; + } + + $allocatedAmount = $allocations[$plugin->id] ?? 0; + + $this->createBundleLicense($user, $plugin, $bundle, $allocatedAmount); + } + + Log::info('Processed bundle checkout', [ + 'session_id' => $this->checkoutSessionId, + 'bundle_id' => $bundleId, + 'bundle_name' => $bundle->name, + 'plugin_count' => $bundle->plugins->count(), + ]); + } + + $user->getPluginLicenseKey(); + } + + protected function createBundleLicense(User $user, Plugin $plugin, PluginBundle $bundle, int $allocatedAmount): PluginLicense + { + $license = PluginLicense::create([ + 'user_id' => $user->id, + 'plugin_id' => $plugin->id, + 'plugin_bundle_id' => $bundle->id, + 'stripe_checkout_session_id' => $this->checkoutSessionId, + 'stripe_payment_intent_id' => $this->paymentIntentId, + 'price_paid' => $allocatedAmount, + 'currency' => strtoupper($this->currency), + 'is_grandfathered' => false, + 'purchased_at' => now(), + ]); + + // Create proportional payout for developer + if ($plugin->developerAccount && $plugin->developerAccount->canReceivePayouts() && $allocatedAmount > 0) { + $split = PluginPayout::calculateSplit($allocatedAmount); + + $payout = PluginPayout::create([ + 'plugin_license_id' => $license->id, + 'developer_account_id' => $plugin->developerAccount->id, + 'gross_amount' => $allocatedAmount, + 'platform_fee' => $split['platform_fee'], + 'developer_amount' => $split['developer_amount'], + 'status' => PayoutStatus::Pending, + ]); + + $stripeConnectService = app(StripeConnectService::class); + $stripeConnectService->processTransfer($payout); + } + + Log::info('Created bundle license', [ + 'session_id' => $this->checkoutSessionId, + 'bundle_id' => $bundle->id, + 'plugin_id' => $plugin->id, + 'allocated_amount' => $allocatedAmount, + ]); + + return $license; + } + + protected function processSinglePluginCheckout(User $user): void + { + $pluginId = $this->metadata['plugin_id']; + $priceId = $this->metadata['price_id'] ?? null; + + $plugin = Plugin::find($pluginId); + + if (! $plugin) { + Log::error('Plugin not found for single checkout', ['plugin_id' => $pluginId]); + + return; + } + + $price = $priceId ? PluginPrice::find($priceId) : $plugin->activePrice; + $amount = $price ? $price->amount : $this->amountTotal; + + $this->createLicense($user, $plugin, $amount); + + $user->getPluginLicenseKey(); + + Log::info('Processed single plugin checkout', [ + 'session_id' => $this->checkoutSessionId, + 'user_id' => $user->id, + 'plugin_id' => $pluginId, + ]); + } + + protected function createLicense(User $user, Plugin $plugin, int $amount): PluginLicense + { + $license = PluginLicense::create([ + 'user_id' => $user->id, + 'plugin_id' => $plugin->id, + 'stripe_checkout_session_id' => $this->checkoutSessionId, + 'stripe_payment_intent_id' => $this->paymentIntentId, + 'price_paid' => $amount, + 'currency' => strtoupper($this->currency), + 'is_grandfathered' => false, + 'purchased_at' => now(), + ]); + + // Create payout record for developer if applicable + if ($plugin->developerAccount && $plugin->developerAccount->canReceivePayouts()) { + $split = PluginPayout::calculateSplit($amount); + + $payout = PluginPayout::create([ + 'plugin_license_id' => $license->id, + 'developer_account_id' => $plugin->developerAccount->id, + 'gross_amount' => $amount, + 'platform_fee' => $split['platform_fee'], + 'developer_amount' => $split['developer_amount'], + 'status' => PayoutStatus::Pending, + ]); + + // For cart checkouts, we need to manually transfer since transfer_data wasn't used + // For single plugin checkouts, transfer_data already handled the transfer at checkout time + $isCartCheckout = isset($this->metadata['cart_id']); + + if ($isCartCheckout) { + $stripeConnectService = app(StripeConnectService::class); + $stripeConnectService->processTransfer($payout); + } else { + // Single plugin purchase - transfer already happened via transfer_data + // Just mark the payout as transferred for tracking + $payout->update([ + 'status' => PayoutStatus::Transferred, + 'transferred_at' => now(), + ]); + } + } + + return $license; + } +} diff --git a/app/Jobs/SyncPluginReleases.php b/app/Jobs/SyncPluginReleases.php new file mode 100644 index 00000000..edbb176a --- /dev/null +++ b/app/Jobs/SyncPluginReleases.php @@ -0,0 +1,128 @@ +plugin->isApproved()) { + Log::info("Plugin {$this->plugin->id} is not approved, skipping release sync"); + + return; + } + + $repo = $this->plugin->getRepositoryOwnerAndName(); + + if (! $repo) { + Log::warning("Plugin {$this->plugin->id} has no valid repository URL"); + + return; + } + + $token = $this->getGitHubToken(); + + $releases = $this->fetchReleases($repo['owner'], $repo['repo'], $token); + + foreach ($releases as $release) { + $this->processRelease($release); + } + + $this->plugin->update(['last_synced_at' => now()]); + + // Trigger satis build if we have new releases + if ($this->triggerSatisBuild && $this->hasNewReleases) { + $satisService->build([$this->plugin]); + } + } + + protected function fetchReleases(string $owner, string $repo, ?string $token): array + { + $request = Http::timeout(30); + + if ($token) { + $request = $request->withToken($token); + } + + $response = $request->get("https://api.github.com/repos/{$owner}/{$repo}/releases", [ + 'per_page' => 30, + ]); + + if ($response->failed()) { + Log::warning("Failed to fetch releases for {$owner}/{$repo}", [ + 'status' => $response->status(), + 'response' => $response->json(), + ]); + + return []; + } + + return $response->json(); + } + + protected function processRelease(array $release): void + { + $tagName = $release['tag_name']; + $version = ltrim($tagName, 'v'); + + $existingVersion = $this->plugin->versions() + ->where('tag_name', $tagName) + ->first(); + + if ($existingVersion) { + return; + } + + PluginVersion::create([ + 'plugin_id' => $this->plugin->id, + 'version' => $version, + 'tag_name' => $tagName, + 'release_notes' => $release['body'] ?? null, + 'github_release_id' => (string) $release['id'], + 'commit_sha' => $release['target_commitish'] ?? null, + 'published_at' => $release['published_at'] ? now()->parse($release['published_at']) : null, + ]); + + Log::info('Created plugin version', [ + 'plugin_id' => $this->plugin->id, + 'version' => $version, + ]); + + $this->hasNewReleases = true; + } + + protected function getGitHubToken(): ?string + { + $user = $this->plugin->user; + + if ($user && $user->hasGitHubToken()) { + return $user->getGitHubToken(); + } + + return config('services.github.token'); + } +} diff --git a/app/Listeners/StripeWebhookReceivedListener.php b/app/Listeners/StripeWebhookReceivedListener.php index efc0708e..64e64014 100644 --- a/app/Listeners/StripeWebhookReceivedListener.php +++ b/app/Listeners/StripeWebhookReceivedListener.php @@ -4,6 +4,7 @@ use App\Jobs\CreateUserFromStripeCustomer; use App\Jobs\HandleInvoicePaidJob; +use App\Jobs\ProcessPluginCheckoutJob; use App\Jobs\RemoveDiscordMaxRoleJob; use App\Models\User; use Exception; @@ -23,6 +24,7 @@ public function handle(WebhookReceived $event): void 'customer.subscription.created' => $this->createUserIfNotExists($event->payload['data']['object']['customer']), 'customer.subscription.deleted' => $this->handleSubscriptionDeleted($event), 'customer.subscription.updated' => $this->handleSubscriptionUpdated($event), + 'checkout.session.completed' => $this->handleCheckoutSessionCompleted($event), default => null, }; } @@ -95,4 +97,37 @@ private function removeDiscordRoleIfNoMaxLicense(User $user): void dispatch(new RemoveDiscordMaxRoleJob($user)); } + + private function handleCheckoutSessionCompleted(WebhookReceived $event): void + { + $session = $event->payload['data']['object']; + + // Only process completed payment sessions for plugins + if ($session['payment_status'] !== 'paid') { + return; + } + + $metadata = $session['metadata'] ?? []; + + // Only process if this is a plugin purchase (has plugin_id or plugin_ids in metadata) + if (! isset($metadata['plugin_id']) && ! isset($metadata['plugin_ids'])) { + return; + } + + Log::info('Dispatching ProcessPluginCheckoutJob from webhook', [ + 'session_id' => $session['id'], + 'metadata' => $metadata, + 'has_cart_id' => isset($metadata['cart_id']), + 'has_plugin_ids' => isset($metadata['plugin_ids']), + 'plugin_ids_value' => $metadata['plugin_ids'] ?? null, + ]); + + dispatch(new ProcessPluginCheckoutJob( + checkoutSessionId: $session['id'], + metadata: $metadata, + amountTotal: $session['amount_total'], + currency: $session['currency'], + paymentIntentId: $session['payment_intent'] ?? null, + )); + } } diff --git a/app/Livewire/PluginDirectory.php b/app/Livewire/PluginDirectory.php new file mode 100644 index 00000000..02f0adf3 --- /dev/null +++ b/app/Livewire/PluginDirectory.php @@ -0,0 +1,72 @@ +resetPage(); + } + + public function updatedAuthor(): void + { + $this->resetPage(); + } + + public function clearSearch(): void + { + $this->search = ''; + $this->resetPage(); + } + + public function clearAuthor(): void + { + $this->author = null; + $this->resetPage(); + } + + public function render(): View + { + $authorUser = $this->author ? User::find($this->author) : null; + + $plugins = Plugin::query() + ->approved() + ->when($this->search, function ($query) { + $query->where(function ($q) { + $q->where('name', 'like', "%{$this->search}%") + ->orWhere('description', 'like', "%{$this->search}%"); + }); + }) + ->when($this->author, function ($query) { + $query->where('user_id', $this->author); + }) + ->orderByDesc('featured') + ->latest() + ->paginate(15); + + return view('livewire.plugin-directory', [ + 'plugins' => $plugins, + 'authorUser' => $authorUser, + ]); + } +} diff --git a/app/Livewire/WallOfLoveSubmissionForm.php b/app/Livewire/WallOfLoveSubmissionForm.php index 56d502c3..39c35836 100644 --- a/app/Livewire/WallOfLoveSubmissionForm.php +++ b/app/Livewire/WallOfLoveSubmissionForm.php @@ -52,7 +52,7 @@ public function submit() 'testimonial' => $this->testimonial ?: null, ]); - return redirect()->route('customer.licenses')->with('success', 'Thank you! Your submission has been received and is awaiting review.'); + return redirect()->route('dashboard')->with('success', 'Thank you! Your submission has been received and is awaiting review.'); } public function render() diff --git a/app/Models/Cart.php b/app/Models/Cart.php new file mode 100644 index 00000000..2053aa49 --- /dev/null +++ b/app/Models/Cart.php @@ -0,0 +1,120 @@ + 'datetime', + ]; + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * @return HasMany + */ + public function items(): HasMany + { + return $this->hasMany(CartItem::class); + } + + public function isEmpty(): bool + { + return $this->items()->count() === 0; + } + + public function itemCount(): int + { + return $this->items()->count(); + } + + public function hasPlugin(Plugin $plugin): bool + { + return $this->items()->where('plugin_id', $plugin->id)->exists(); + } + + public function hasBundle(PluginBundle $bundle): bool + { + return $this->items()->where('plugin_bundle_id', $bundle->id)->exists(); + } + + public function getSubtotal(): int + { + return $this->items->sum(fn (CartItem $item) => $item->getItemPrice()); + } + + public function getFormattedSubtotal(): string + { + return '$'.number_format($this->getSubtotal() / 100); + } + + public function isExpired(): bool + { + return $this->expires_at && $this->expires_at->isPast(); + } + + public function assignToUser(User $user): void + { + $this->update([ + 'user_id' => $user->id, + 'session_id' => null, + ]); + } + + public function clear(): void + { + $this->items()->delete(); + } + + /** + * Find bundles where all plugins are already in the cart as individual items. + * + * @return \Illuminate\Support\Collection + */ + public function getAvailableBundleUpgrades(): \Illuminate\Support\Collection + { + // Get plugin IDs that are in the cart as individual items (not part of a bundle) + $cartPluginIds = $this->items() + ->whereNotNull('plugin_id') + ->pluck('plugin_id') + ->toArray(); + + if (empty($cartPluginIds)) { + return collect(); + } + + // Get bundle IDs already in the cart + $cartBundleIds = $this->items() + ->whereNotNull('plugin_bundle_id') + ->pluck('plugin_bundle_id') + ->toArray(); + + // Find active bundles where ALL plugins are in the cart + return PluginBundle::query() + ->active() + ->whereNotIn('id', $cartBundleIds) + ->with('plugins') + ->get() + ->filter(function (PluginBundle $bundle) use ($cartPluginIds) { + $bundlePluginIds = $bundle->plugins->pluck('id')->toArray(); + + // All bundle plugins must be in the cart + return ! empty($bundlePluginIds) && empty(array_diff($bundlePluginIds, $cartPluginIds)); + }); + } +} diff --git a/app/Models/CartItem.php b/app/Models/CartItem.php new file mode 100644 index 00000000..1c099e48 --- /dev/null +++ b/app/Models/CartItem.php @@ -0,0 +1,96 @@ + 'integer', + 'bundle_price_at_addition' => 'integer', + ]; + + /** + * @return BelongsTo + */ + public function cart(): BelongsTo + { + return $this->belongsTo(Cart::class); + } + + /** + * @return BelongsTo + */ + public function plugin(): BelongsTo + { + return $this->belongsTo(Plugin::class); + } + + /** + * @return BelongsTo + */ + public function pluginPrice(): BelongsTo + { + return $this->belongsTo(PluginPrice::class); + } + + /** + * @return BelongsTo + */ + public function pluginBundle(): BelongsTo + { + return $this->belongsTo(PluginBundle::class); + } + + public function isBundle(): bool + { + return $this->plugin_bundle_id !== null; + } + + public function getItemPrice(): int + { + return $this->isBundle() ? $this->bundle_price_at_addition : $this->price_at_addition; + } + + public function getFormattedPrice(): string + { + return '$'.number_format($this->getItemPrice() / 100); + } + + public function hasPriceChanged(): bool + { + if ($this->isBundle()) { + $bundle = $this->pluginBundle; + + if (! $bundle || ! $bundle->isActive()) { + return true; + } + + return $bundle->price !== $this->bundle_price_at_addition; + } + + $currentPrice = $this->plugin->activePrice; + + if (! $currentPrice) { + return true; + } + + return $currentPrice->amount !== $this->price_at_addition; + } + + public function getItemName(): string + { + if ($this->isBundle()) { + return $this->pluginBundle->name.' (Bundle)'; + } + + return $this->plugin->name; + } +} diff --git a/app/Models/DeveloperAccount.php b/app/Models/DeveloperAccount.php new file mode 100644 index 00000000..a4ebd3a1 --- /dev/null +++ b/app/Models/DeveloperAccount.php @@ -0,0 +1,62 @@ + StripeConnectStatus::class, + 'payouts_enabled' => 'boolean', + 'charges_enabled' => 'boolean', + 'onboarding_completed_at' => 'datetime', + ]; + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * @return HasMany + */ + public function plugins(): HasMany + { + return $this->hasMany(Plugin::class); + } + + /** + * @return HasMany + */ + public function payouts(): HasMany + { + return $this->hasMany(PluginPayout::class); + } + + public function isActive(): bool + { + return $this->stripe_connect_status === StripeConnectStatus::Active; + } + + public function canReceivePayouts(): bool + { + return $this->isActive() && $this->payouts_enabled; + } + + public function hasCompletedOnboarding(): bool + { + return $this->onboarding_completed_at !== null; + } +} diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php new file mode 100644 index 00000000..ba851e79 --- /dev/null +++ b/app/Models/Plugin.php @@ -0,0 +1,448 @@ + PluginStatus::class, + 'type' => PluginType::class, + 'approved_at' => 'datetime', + 'featured' => 'boolean', + 'is_active' => 'boolean', + 'is_official' => 'boolean', + 'composer_data' => 'array', + 'nativephp_data' => 'array', + 'last_synced_at' => 'datetime', + ]; + + protected static function booted(): void + { + static::created(function (Plugin $plugin) { + $plugin->recordActivity( + PluginActivityType::Submitted, + null, + PluginStatus::Pending, + null, + $plugin->user_id + ); + }); + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * @return BelongsTo + */ + public function approvedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'approved_by'); + } + + /** + * @return HasMany + */ + public function activities(): HasMany + { + return $this->hasMany(PluginActivity::class)->orderBy('created_at', 'desc'); + } + + /** + * @return BelongsTo + */ + public function developerAccount(): BelongsTo + { + return $this->belongsTo(DeveloperAccount::class); + } + + /** + * @return HasMany + */ + public function prices(): HasMany + { + return $this->hasMany(PluginPrice::class); + } + + /** + * @return HasOne + */ + public function activePrice(): HasOne + { + return $this->hasOne(PluginPrice::class)->where('is_active', true)->latest(); + } + + /** + * @return HasMany + */ + public function licenses(): HasMany + { + return $this->hasMany(PluginLicense::class); + } + + /** + * @return BelongsToMany + */ + public function bundles(): BelongsToMany + { + return $this->belongsToMany(PluginBundle::class, 'bundle_plugin') + ->withPivot('sort_order') + ->withTimestamps(); + } + + /** + * Alias for bundles() - required by Filament's AttachAction. + * + * @return BelongsToMany + */ + public function pluginBundles(): BelongsToMany + { + return $this->bundles(); + } + + /** + * @return HasMany + */ + public function versions(): HasMany + { + return $this->hasMany(PluginVersion::class)->orderBy('created_at', 'desc'); + } + + /** + * @return HasOne + */ + public function latestVersion(): HasOne + { + return $this->hasOne(PluginVersion::class)->where('is_packaged', true)->latest('published_at'); + } + + public function isPending(): bool + { + return $this->status === PluginStatus::Pending; + } + + public function isApproved(): bool + { + return $this->status === PluginStatus::Approved; + } + + public function isRejected(): bool + { + return $this->status === PluginStatus::Rejected; + } + + public function isFree(): bool + { + return $this->type === PluginType::Free; + } + + public function isPaid(): bool + { + return $this->type === PluginType::Paid; + } + + public function isFeatured(): bool + { + return $this->featured; + } + + public function isOfficial(): bool + { + return $this->is_official ?? false; + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeApproved(Builder $query): Builder + { + return $query->where('status', PluginStatus::Approved) + ->where('is_active', true); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeFeatured(Builder $query): Builder + { + return $query->where('featured', true); + } + + public function getPackagistUrl(): string + { + return "https://packagist.org/packages/{$this->name}"; + } + + public function getGithubUrl(): string + { + return "https://github.com/{$this->name}"; + } + + public function getWebhookUrl(): ?string + { + if (! $this->webhook_secret) { + return null; + } + + return route('webhooks.plugins', $this->webhook_secret); + } + + public function getLogoUrl(): ?string + { + if (! $this->logo_path) { + return null; + } + + return asset('storage/'.$this->logo_path); + } + + public function hasLogo(): bool + { + return $this->logo_path !== null; + } + + /** + * Reserved namespaces that can only be used by admin users. + */ + public const RESERVED_NAMESPACES = [ + 'native', + 'nativephp', + 'bifrost', + ]; + + /** + * Get the vendor namespace from the package name. + * e.g., "acme/my-plugin" returns "acme" + */ + public function getVendorNamespace(): ?string + { + if (! $this->name) { + return null; + } + + $parts = explode('/', $this->name); + + return $parts[0] ?? null; + } + + /** + * Check if a namespace is reserved (admin-only). + */ + public static function isReservedNamespace(string $namespace): bool + { + return in_array(strtolower($namespace), self::RESERVED_NAMESPACES, true); + } + + /** + * Check if a vendor namespace is available for a given user. + * Returns true if the namespace is not claimed by another user + * and is not a reserved namespace (unless user is admin). + */ + public static function isNamespaceAvailableForUser(string $namespace, int $userId): bool + { + $user = User::find($userId); + + // Reserved namespaces are only available to admins + if (self::isReservedNamespace($namespace)) { + return $user && $user->isAdmin(); + } + + // Check if namespace is already claimed by another user + return ! static::where('name', 'like', $namespace.'/%') + ->where('user_id', '!=', $userId) + ->exists(); + } + + /** + * Get the user who owns a particular namespace. + */ + public static function getNamespaceOwner(string $namespace): ?User + { + $plugin = static::where('name', 'like', $namespace.'/%') + ->first(); + + return $plugin?->user; + } + + public function getLicense(): ?string + { + return $this->composer_data['license'] ?? null; + } + + public function getLicenseUrl(): ?string + { + $repoInfo = $this->getRepositoryOwnerAndName(); + + if (! $repoInfo) { + return null; + } + + return "https://github.com/{$repoInfo['owner']}/{$repoInfo['repo']}/blob/main/LICENSE"; + } + + public function generateWebhookSecret(): string + { + $secret = bin2hex(random_bytes(32)); + + $this->update(['webhook_secret' => $secret]); + + return $secret; + } + + public function getRepositoryOwnerAndName(): ?array + { + if (! $this->repository_url) { + return null; + } + + $path = parse_url($this->repository_url, PHP_URL_PATH); + $parts = array_values(array_filter(explode('/', trim($path, '/')))); + + if (count($parts) < 2) { + return null; + } + + return [ + 'owner' => $parts[0], + 'repo' => str_replace('.git', '', $parts[1]), + ]; + } + + public function approve(int $approvedById): void + { + $previousStatus = $this->status; + + $this->update([ + 'status' => PluginStatus::Approved, + 'approved_at' => now(), + 'approved_by' => $approvedById, + 'rejection_reason' => null, + ]); + + $this->recordActivity( + PluginActivityType::Approved, + $previousStatus, + PluginStatus::Approved, + null, + $approvedById + ); + + $this->user->notify(new PluginApproved($this)); + + app(PluginSyncService::class)->sync($this); + + if ($this->isPaid()) { + SyncPluginReleases::dispatch($this); + } + } + + public function reject(string $reason, int $rejectedById): void + { + $previousStatus = $this->status; + + $this->update([ + 'status' => PluginStatus::Rejected, + 'rejection_reason' => $reason, + 'approved_at' => null, + 'approved_by' => $rejectedById, + ]); + + $this->recordActivity( + PluginActivityType::Rejected, + $previousStatus, + PluginStatus::Rejected, + $reason, + $rejectedById + ); + + $this->user->notify(new PluginRejected($this)); + } + + public function resubmit(): void + { + $previousStatus = $this->status; + + $this->update([ + 'status' => PluginStatus::Pending, + 'rejection_reason' => null, + 'approved_at' => null, + 'approved_by' => null, + ]); + + $this->recordActivity( + PluginActivityType::Resubmitted, + $previousStatus, + PluginStatus::Pending, + null, + $this->user_id + ); + } + + public function updateDescription(string $description, int $updatedById): void + { + $oldDescription = $this->description; + + $this->update([ + 'description' => $description, + ]); + + $this->activities()->create([ + 'type' => PluginActivityType::DescriptionUpdated, + 'from_status' => $this->status->value, + 'to_status' => $this->status->value, + 'note' => $oldDescription ? "Changed from: {$oldDescription}" : 'Initial description set', + 'causer_id' => $updatedById, + ]); + } + + protected function recordActivity( + PluginActivityType $type, + ?PluginStatus $fromStatus, + PluginStatus $toStatus, + ?string $note, + ?int $causerId + ): void { + $this->activities()->create([ + 'type' => $type, + 'from_status' => $fromStatus?->value, + 'to_status' => $toStatus->value, + 'note' => $note, + 'causer_id' => $causerId, + ]); + } +} diff --git a/app/Models/PluginActivity.php b/app/Models/PluginActivity.php new file mode 100644 index 00000000..b41094ea --- /dev/null +++ b/app/Models/PluginActivity.php @@ -0,0 +1,35 @@ + PluginActivityType::class, + 'from_status' => PluginStatus::class, + 'to_status' => PluginStatus::class, + ]; + + /** + * @return BelongsTo + */ + public function plugin(): BelongsTo + { + return $this->belongsTo(Plugin::class); + } + + /** + * @return BelongsTo + */ + public function causer(): BelongsTo + { + return $this->belongsTo(User::class, 'causer_id'); + } +} diff --git a/app/Models/PluginBundle.php b/app/Models/PluginBundle.php new file mode 100644 index 00000000..7ae861ba --- /dev/null +++ b/app/Models/PluginBundle.php @@ -0,0 +1,214 @@ + 'integer', + 'is_active' => 'boolean', + 'is_featured' => 'boolean', + 'published_at' => 'datetime', + ]; + + /** + * @return BelongsToMany + */ + public function plugins(): BelongsToMany + { + return $this->belongsToMany(Plugin::class, 'bundle_plugin') + ->withPivot('sort_order') + ->orderByPivot('sort_order') + ->withTimestamps(); + } + + /** + * @return HasMany + */ + public function licenses(): HasMany + { + return $this->hasMany(PluginLicense::class); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true) + ->whereNotNull('published_at') + ->where('published_at', '<=', now()); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeFeatured(Builder $query): Builder + { + return $query->where('is_featured', true); + } + + public function isActive(): bool + { + return $this->is_active + && $this->published_at + && $this->published_at->lte(now()); + } + + public function getLogoUrl(): ?string + { + if (! $this->logo_path) { + return null; + } + + return asset('storage/'.$this->logo_path); + } + + public function hasLogo(): bool + { + return $this->logo_path !== null; + } + + /** + * Calculate the total retail value of all plugins in the bundle. + */ + public function getRetailValueAttribute(): int + { + return $this->plugins + ->filter(fn (Plugin $plugin) => $plugin->activePrice) + ->sum(fn (Plugin $plugin) => $plugin->activePrice->amount); + } + + /** + * Get formatted retail value. + */ + public function getFormattedRetailValueAttribute(): string + { + return '$'.number_format($this->retail_value / 100, 2); + } + + /** + * Get formatted bundle price. + */ + public function getFormattedPriceAttribute(): string + { + return '$'.number_format($this->price / 100, 2); + } + + /** + * Calculate the discount percentage. + */ + public function getDiscountPercentAttribute(): int + { + $retailValue = $this->retail_value; + + if ($retailValue <= 0) { + return 0; + } + + return (int) round(($retailValue - $this->price) / $retailValue * 100); + } + + /** + * Get the savings amount in cents. + */ + public function getSavingsAttribute(): int + { + return max(0, $this->retail_value - $this->price); + } + + /** + * Get formatted savings. + */ + public function getFormattedSavingsAttribute(): string + { + return '$'.number_format($this->savings / 100, 2); + } + + /** + * Check if a user already owns all plugins in the bundle. + */ + public function isOwnedBy(User $user): bool + { + $ownedPluginIds = $user->pluginLicenses() + ->active() + ->pluck('plugin_id') + ->toArray(); + + return $this->plugins->every( + fn (Plugin $plugin) => in_array($plugin->id, $ownedPluginIds) + ); + } + + /** + * Get plugins the user doesn't own yet. + * + * @return Collection + */ + public function getUnownedPluginsFor(User $user): Collection + { + $ownedPluginIds = $user->pluginLicenses() + ->active() + ->pluck('plugin_id') + ->toArray(); + + return $this->plugins->filter( + fn (Plugin $plugin) => ! in_array($plugin->id, $ownedPluginIds) + ); + } + + /** + * Calculate proportional allocation of bundle price to each plugin. + * Used for developer payouts. + * + * @return array Plugin ID => allocated amount in cents + */ + public function calculateProportionalAllocation(): array + { + $retailValue = $this->retail_value; + $bundlePrice = $this->price; + $allocations = []; + + if ($retailValue <= 0) { + return $allocations; + } + + $runningTotal = 0; + $plugins = $this->plugins->filter(fn (Plugin $p) => $p->activePrice)->values(); + $lastIndex = $plugins->count() - 1; + + foreach ($plugins as $index => $plugin) { + $pluginRetail = $plugin->activePrice->amount; + + // For last plugin, allocate remainder to avoid rounding issues + if ($index === $lastIndex) { + $allocations[$plugin->id] = $bundlePrice - $runningTotal; + } else { + $proportion = $pluginRetail / $retailValue; + $allocation = (int) round($bundlePrice * $proportion); + $allocations[$plugin->id] = $allocation; + $runningTotal += $allocation; + } + } + + return $allocations; + } + + public function getRouteKeyName(): string + { + return 'slug'; + } +} diff --git a/app/Models/PluginLicense.php b/app/Models/PluginLicense.php new file mode 100644 index 00000000..a15a4991 --- /dev/null +++ b/app/Models/PluginLicense.php @@ -0,0 +1,104 @@ + 'integer', + 'is_grandfathered' => 'boolean', + 'purchased_at' => 'datetime', + 'expires_at' => 'datetime', + ]; + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * @return BelongsTo + */ + public function plugin(): BelongsTo + { + return $this->belongsTo(Plugin::class); + } + + /** + * @return HasOne + */ + public function payout(): HasOne + { + return $this->hasOne(PluginPayout::class); + } + + /** + * @return BelongsTo + */ + public function pluginBundle(): BelongsTo + { + return $this->belongsTo(PluginBundle::class); + } + + public function wasPurchasedAsBundle(): bool + { + return $this->plugin_bundle_id !== null; + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeActive(Builder $query): Builder + { + return $query->where(function ($q) { + $q->whereNull('expires_at') + ->orWhere('expires_at', '>', now()); + }); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeForUser(Builder $query, User $user): Builder + { + return $query->where('user_id', $user->id); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeForPlugin(Builder $query, Plugin $plugin): Builder + { + return $query->where('plugin_id', $plugin->id); + } + + public function isActive(): bool + { + if ($this->expires_at === null) { + return true; + } + + return $this->expires_at->isFuture(); + } + + public function isExpired(): bool + { + return ! $this->isActive(); + } +} diff --git a/app/Models/PluginPayout.php b/app/Models/PluginPayout.php new file mode 100644 index 00000000..200d1633 --- /dev/null +++ b/app/Models/PluginPayout.php @@ -0,0 +1,114 @@ + 'integer', + 'platform_fee' => 'integer', + 'developer_amount' => 'integer', + 'status' => PayoutStatus::class, + 'transferred_at' => 'datetime', + ]; + + /** + * @return BelongsTo + */ + public function pluginLicense(): BelongsTo + { + return $this->belongsTo(PluginLicense::class); + } + + /** + * @return BelongsTo + */ + public function developerAccount(): BelongsTo + { + return $this->belongsTo(DeveloperAccount::class); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopePending(Builder $query): Builder + { + return $query->where('status', PayoutStatus::Pending); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeTransferred(Builder $query): Builder + { + return $query->where('status', PayoutStatus::Transferred); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeFailed(Builder $query): Builder + { + return $query->where('status', PayoutStatus::Failed); + } + + /** + * @return array{platform_fee: int, developer_amount: int} + */ + public static function calculateSplit(int $grossAmount): array + { + $platformFee = (int) round($grossAmount * self::PLATFORM_FEE_PERCENT / 100); + $developerAmount = $grossAmount - $platformFee; + + return [ + 'platform_fee' => $platformFee, + 'developer_amount' => $developerAmount, + ]; + } + + public function isPending(): bool + { + return $this->status === PayoutStatus::Pending; + } + + public function isTransferred(): bool + { + return $this->status === PayoutStatus::Transferred; + } + + public function isFailed(): bool + { + return $this->status === PayoutStatus::Failed; + } + + public function markAsTransferred(string $stripeTransferId): void + { + $this->update([ + 'status' => PayoutStatus::Transferred, + 'stripe_transfer_id' => $stripeTransferId, + 'transferred_at' => now(), + ]); + } + + public function markAsFailed(): void + { + $this->update([ + 'status' => PayoutStatus::Failed, + ]); + } +} diff --git a/app/Models/PluginPrice.php b/app/Models/PluginPrice.php new file mode 100644 index 00000000..f19cb602 --- /dev/null +++ b/app/Models/PluginPrice.php @@ -0,0 +1,51 @@ + 'integer', + 'is_active' => 'boolean', + ]; + + /** + * @return BelongsTo + */ + public function plugin(): BelongsTo + { + return $this->belongsTo(Plugin::class); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true); + } + + public function getFormattedAmountAttribute(): string + { + return number_format($this->amount / 100, 2); + } + + public function getDiscountedAmount(int $discountPercent): int + { + if ($discountPercent <= 0 || $discountPercent > 100) { + return $this->amount; + } + + return (int) round($this->amount * (100 - $discountPercent) / 100); + } +} diff --git a/app/Models/PluginVersion.php b/app/Models/PluginVersion.php new file mode 100644 index 00000000..b26ba518 --- /dev/null +++ b/app/Models/PluginVersion.php @@ -0,0 +1,43 @@ + 'boolean', + 'packaged_at' => 'datetime', + 'published_at' => 'datetime', + ]; + + /** + * @return BelongsTo + */ + public function plugin(): BelongsTo + { + return $this->belongsTo(Plugin::class); + } + + public function isPackaged(): bool + { + return $this->is_packaged; + } + + public function isPublished(): bool + { + return $this->published_at !== null; + } + + public function getDownloadPath(): string + { + return $this->storage_path ?? ''; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index bd2d710b..8637a0d2 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -7,6 +7,7 @@ use Filament\Panel; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Collection; @@ -22,6 +23,7 @@ class User extends Authenticatable implements FilamentUser protected $hidden = [ 'password', 'remember_token', + 'github_token', ]; protected $casts = [ @@ -57,6 +59,38 @@ public function wallOfLoveSubmissions(): HasMany return $this->hasMany(WallOfLoveSubmission::class); } + /** + * @return HasMany + */ + public function plugins(): HasMany + { + return $this->hasMany(Plugin::class); + } + + /** + * @return HasMany + */ + public function pluginLicenses(): HasMany + { + return $this->hasMany(PluginLicense::class); + } + + /** + * @return HasOne + */ + public function developerAccount(): HasOne + { + return $this->hasOne(DeveloperAccount::class); + } + + /** + * @return HasOne + */ + public function purchaseHistory(): HasOne + { + return $this->hasOne(UserPurchaseHistory::class); + } + public function hasActiveMaxLicense(): bool { return $this->licenses() @@ -95,6 +129,11 @@ public function hasActualLicense(): bool return $this->licenses()->exists(); } + public function getDisplayNameAttribute(): string + { + return $this->attributes['display_name'] ?? $this->name ?? 'Unknown'; + } + public function getFirstNameAttribute(): ?string { if (empty($this->name)) { @@ -125,4 +164,57 @@ public function findStripeCustomerRecords(): Collection return collect($search->data); } + + public function getPluginLicenseKey(): string + { + if (! $this->plugin_license_key) { + $this->plugin_license_key = bin2hex(random_bytes(32)); + $this->save(); + } + + return $this->plugin_license_key; + } + + public function regeneratePluginLicenseKey(): string + { + $this->plugin_license_key = bin2hex(random_bytes(32)); + $this->save(); + + return $this->plugin_license_key; + } + + public function hasPluginAccess(Plugin $plugin): bool + { + if ($plugin->isFree()) { + return true; + } + + // Authors always have access to their own plugins + if ($plugin->user_id === $this->id) { + return true; + } + + return $this->pluginLicenses() + ->forPlugin($plugin) + ->active() + ->exists(); + } + + public function getGitHubToken(): ?string + { + if (! $this->github_token) { + return null; + } + + try { + return decrypt($this->github_token); + } catch (\Exception) { + return null; + } + } + + public function hasGitHubToken(): bool + { + return $this->getGitHubToken() !== null; + } } diff --git a/app/Models/UserPurchaseHistory.php b/app/Models/UserPurchaseHistory.php new file mode 100644 index 00000000..1e96ad97 --- /dev/null +++ b/app/Models/UserPurchaseHistory.php @@ -0,0 +1,42 @@ + 'integer', + 'first_purchase_at' => 'datetime', + 'grandfathering_tier' => GrandfatheringTier::class, + 'recalculated_at' => 'datetime', + ]; + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function hasPurchaseHistory(): bool + { + return $this->first_purchase_at !== null; + } + + public function getTier(): GrandfatheringTier + { + return $this->grandfathering_tier ?? GrandfatheringTier::None; + } +} diff --git a/app/Notifications/PluginApproved.php b/app/Notifications/PluginApproved.php new file mode 100644 index 00000000..65eec4d8 --- /dev/null +++ b/app/Notifications/PluginApproved.php @@ -0,0 +1,54 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage) + ->subject('Your Plugin Has Been Approved!') + ->greeting('Great news!') + ->line("Your plugin **{$this->plugin->name}** has been approved and is now listed in the NativePHP Plugin Directory.") + ->action('View Plugin Directory', url('/plugins')) + ->line('Thank you for contributing to the NativePHP ecosystem!'); + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'plugin_id' => $this->plugin->id, + 'plugin_name' => $this->plugin->name, + ]; + } +} diff --git a/app/Notifications/PluginRejected.php b/app/Notifications/PluginRejected.php new file mode 100644 index 00000000..a1951f51 --- /dev/null +++ b/app/Notifications/PluginRejected.php @@ -0,0 +1,57 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage) + ->subject('Plugin Submission Update') + ->greeting('Hello,') + ->line("Unfortunately, your plugin **{$this->plugin->name}** was not approved for the NativePHP Plugin Directory.") + ->line('**Reason:**') + ->line($this->plugin->rejection_reason) + ->action('View Your Plugins', url('/customer/plugins')) + ->line('If you have questions about this decision, please reach out to us.'); + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'plugin_id' => $this->plugin->id, + 'plugin_name' => $this->plugin->name, + 'rejection_reason' => $this->plugin->rejection_reason, + ]; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index edacad82..9331cdf2 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,9 +3,12 @@ namespace App\Providers; use App\Features\ShowAuthButtons; +use App\Features\ShowPlugins; +use App\Services\CartService; use App\Support\GitHub; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Queue\Events\JobFailed; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\View; @@ -52,6 +55,18 @@ private function registerSharedViewVariables(): void View::share('bskyLink', 'https://bsky.app/profile/nativephp.com'); View::share('openCollectiveLink', 'https://opencollective.com/nativephp'); View::share('githubLink', 'https://github.com/nativephp'); + + // Share cart count with navigation components + View::composer(['components.navigation-bar', 'components.navbar.mobile-menu'], function ($view) { + $cartCount = 0; + + if (Feature::active(ShowPlugins::class)) { + $cartService = app(CartService::class); + $cartCount = $cartService->getCartItemCount(Auth::user()); + } + + $view->with('cartCount', $cartCount); + }); } private function sendFailingJobsToSentry(): void diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 091a8f97..b48530e3 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -17,7 +17,7 @@ class RouteServiceProvider extends ServiceProvider * * @var string */ - public const HOME = '/customer/licenses'; + public const HOME = '/dashboard'; /** * Define your route model bindings, pattern filters, and other route configuration. diff --git a/app/Services/CartService.php b/app/Services/CartService.php new file mode 100644 index 00000000..153550a6 --- /dev/null +++ b/app/Services/CartService.php @@ -0,0 +1,325 @@ +getCartForUser($user); + } + + return $this->getCartForSession(); + } + + protected function getCartForUser(User $user): Cart + { + $cart = Cart::where('user_id', $user->id)->first(); + + if (! $cart) { + $cart = Cart::create([ + 'user_id' => $user->id, + 'expires_at' => now()->addDays(self::CART_EXPIRY_DAYS), + ]); + } + + return $cart; + } + + protected function getCartForSession(): Cart + { + $sessionId = Session::get(self::SESSION_KEY); + + if ($sessionId) { + $cart = Cart::where('session_id', $sessionId) + ->whereNull('user_id') + ->first(); + + if ($cart && ! $cart->isExpired()) { + return $cart; + } + } + + $sessionId = Session::getId(); + Session::put(self::SESSION_KEY, $sessionId); + + $cart = Cart::create([ + 'session_id' => $sessionId, + 'expires_at' => now()->addDays(self::CART_EXPIRY_DAYS), + ]); + + return $cart; + } + + public function addPlugin(Cart $cart, Plugin $plugin): CartItem + { + if (! $plugin->is_active) { + throw new \InvalidArgumentException('This plugin is not available for purchase'); + } + + if (! $plugin->isPaid()) { + throw new \InvalidArgumentException('Only paid plugins can be added to cart'); + } + + $activePrice = $plugin->activePrice; + + if (! $activePrice) { + throw new \InvalidArgumentException('Plugin has no active price'); + } + + if ($cart->hasPlugin($plugin)) { + return $cart->items()->where('plugin_id', $plugin->id)->first(); + } + + return $cart->items()->create([ + 'plugin_id' => $plugin->id, + 'plugin_price_id' => $activePrice->id, + 'price_at_addition' => $activePrice->amount, + 'currency' => $activePrice->currency, + ]); + } + + public function removePlugin(Cart $cart, Plugin $plugin): bool + { + return $cart->items()->where('plugin_id', $plugin->id)->delete() > 0; + } + + public function addBundle(Cart $cart, PluginBundle $bundle): CartItem + { + if (! $bundle->isActive()) { + throw new \InvalidArgumentException('Bundle is not available for purchase'); + } + + if ($cart->hasBundle($bundle)) { + return $cart->items()->where('plugin_bundle_id', $bundle->id)->first(); + } + + // Check if user owns all plugins in the bundle + $user = $cart->user; + if ($user && $bundle->isOwnedBy($user)) { + throw new \InvalidArgumentException('You already own all plugins in this bundle'); + } + + return $cart->items()->create([ + 'plugin_bundle_id' => $bundle->id, + 'bundle_price_at_addition' => $bundle->price, + 'currency' => $bundle->currency, + ]); + } + + public function removeBundle(Cart $cart, PluginBundle $bundle): bool + { + return $cart->items()->where('plugin_bundle_id', $bundle->id)->delete() > 0; + } + + public function transferGuestCartToUser(User $user): ?Cart + { + $sessionId = Session::get(self::SESSION_KEY); + + if (! $sessionId) { + return null; + } + + $guestCart = Cart::where('session_id', $sessionId) + ->whereNull('user_id') + ->first(); + + if (! $guestCart || $guestCart->isEmpty()) { + return null; + } + + $userCart = Cart::where('user_id', $user->id)->first(); + + if ($userCart) { + // Merge guest cart items into user cart + foreach ($guestCart->items as $item) { + if ($item->isBundle()) { + if (! $userCart->hasBundle($item->pluginBundle)) { + $item->update(['cart_id' => $userCart->id]); + } + } elseif (! $userCart->hasPlugin($item->plugin)) { + $item->update(['cart_id' => $userCart->id]); + } + } + + $guestCart->delete(); + Session::forget(self::SESSION_KEY); + + return $userCart->fresh(); + } + + // Assign guest cart to user + $guestCart->assignToUser($user); + Session::forget(self::SESSION_KEY); + + return $guestCart; + } + + public function refreshPrices(Cart $cart): array + { + $changes = []; + + foreach ($cart->items as $item) { + if ($item->isBundle()) { + $bundle = $item->pluginBundle; + + if (! $bundle || ! $bundle->isActive()) { + $changes[] = [ + 'item' => $item, + 'name' => $bundle?->name ?? 'Bundle', + 'type' => 'unavailable', + 'old_price' => $item->bundle_price_at_addition, + ]; + $item->delete(); + + continue; + } + + if ($bundle->price !== $item->bundle_price_at_addition) { + $changes[] = [ + 'item' => $item, + 'name' => $bundle->name, + 'type' => 'price_changed', + 'old_price' => $item->bundle_price_at_addition, + 'new_price' => $bundle->price, + ]; + + $item->update([ + 'bundle_price_at_addition' => $bundle->price, + 'currency' => $bundle->currency, + ]); + } + } else { + $plugin = $item->plugin; + + // Remove inactive plugins + if (! $plugin || ! $plugin->is_active) { + $changes[] = [ + 'item' => $item, + 'name' => $plugin?->name ?? 'Plugin', + 'type' => 'unavailable', + 'old_price' => $item->price_at_addition, + ]; + $item->delete(); + + continue; + } + + $currentPrice = $plugin->activePrice; + + if (! $currentPrice) { + $changes[] = [ + 'item' => $item, + 'name' => $plugin->name, + 'type' => 'unavailable', + 'old_price' => $item->price_at_addition, + ]; + $item->delete(); + + continue; + } + + if (! $item->hasPriceChanged()) { + continue; + } + + $changes[] = [ + 'item' => $item, + 'name' => $item->plugin->name, + 'type' => 'price_changed', + 'old_price' => $item->price_at_addition, + 'new_price' => $currentPrice->amount, + ]; + + $item->update([ + 'plugin_price_id' => $currentPrice->id, + 'price_at_addition' => $currentPrice->amount, + 'currency' => $currentPrice->currency, + ]); + } + } + + return $changes; + } + + public function removeAlreadyOwned(Cart $cart, User $user): array + { + $removed = []; + + foreach ($cart->items as $item) { + if ($item->isBundle()) { + $bundle = $item->pluginBundle; + if ($bundle && $bundle->isOwnedBy($user)) { + $removed[] = ['type' => 'bundle', 'item' => $bundle]; + $item->delete(); + } + } elseif ($user->hasPluginAccess($item->plugin)) { + $removed[] = ['type' => 'plugin', 'item' => $item->plugin]; + $item->delete(); + } + } + + return $removed; + } + + public function getCartItemCount(?User $user = null): int + { + if ($user) { + $cart = Cart::where('user_id', $user->id)->first(); + } else { + $sessionId = Session::get(self::SESSION_KEY); + $cart = $sessionId + ? Cart::where('session_id', $sessionId)->whereNull('user_id')->first() + : null; + } + + return $cart ? $cart->itemCount() : 0; + } + + /** + * Exchange individual plugins in the cart for a bundle. + */ + public function exchangeForBundle(Cart $cart, PluginBundle $bundle): CartItem + { + if (! $bundle->isActive()) { + throw new \InvalidArgumentException('Bundle is not available for purchase'); + } + + if ($cart->hasBundle($bundle)) { + throw new \InvalidArgumentException('Bundle is already in your cart'); + } + + // Check if user owns all plugins in the bundle + $user = $cart->user; + if ($user && $bundle->isOwnedBy($user)) { + throw new \InvalidArgumentException('You already own all plugins in this bundle'); + } + + // Get the plugin IDs in this bundle + $bundlePluginIds = $bundle->plugins->pluck('id')->toArray(); + + // Remove individual plugin items that are in the bundle + $cart->items() + ->whereIn('plugin_id', $bundlePluginIds) + ->delete(); + + // Add the bundle + return $cart->items()->create([ + 'plugin_bundle_id' => $bundle->id, + 'bundle_price_at_addition' => $bundle->price, + 'currency' => $bundle->currency, + ]); + } +} diff --git a/app/Services/GitHubUserService.php b/app/Services/GitHubUserService.php new file mode 100644 index 00000000..b8d5549d --- /dev/null +++ b/app/Services/GitHubUserService.php @@ -0,0 +1,146 @@ +user->getGitHubToken(); + + if (! $token) { + return collect(); + } + + $cacheKey = "github_repos_{$this->user->id}"; + + return Cache::remember($cacheKey, now()->addMinutes(5), function () use ($token, $includePrivate) { + return $this->fetchRepositories($token, $includePrivate); + }); + } + + public function clearRepositoryCache(): void + { + Cache::forget("github_repos_{$this->user->id}"); + } + + protected function fetchRepositories(string $token, bool $includePrivate): Collection + { + $repos = collect(); + $page = 1; + $perPage = 100; + + do { + $response = Http::withToken($token) + ->get('https://api.github.com/user/repos', [ + 'per_page' => $perPage, + 'page' => $page, + 'sort' => 'updated', + 'direction' => 'desc', + 'affiliation' => 'owner,collaborator,organization_member', + ]); + + if ($response->failed()) { + Log::warning('Failed to fetch GitHub repos', [ + 'user_id' => $this->user->id, + 'status' => $response->status(), + 'response' => $response->json(), + ]); + + break; + } + + $pageRepos = collect($response->json()); + $repos = $repos->concat($pageRepos); + $page++; + } while ($pageRepos->count() === $perPage && $page <= 10); + + if (! $includePrivate) { + $repos = $repos->where('private', false); + } + + return $repos->map(function ($repo) { + return [ + 'id' => $repo['id'], + 'name' => $repo['name'], + 'full_name' => $repo['full_name'], + 'private' => $repo['private'], + 'html_url' => $repo['html_url'], + 'description' => $repo['description'], + 'default_branch' => $repo['default_branch'], + 'pushed_at' => $repo['pushed_at'], + ]; + })->values(); + } + + public function getRepository(string $owner, string $repo): ?array + { + $token = $this->user->getGitHubToken(); + + if (! $token) { + return null; + } + + $response = Http::withToken($token) + ->get("https://api.github.com/repos/{$owner}/{$repo}"); + + if ($response->failed()) { + return null; + } + + $data = $response->json(); + + return [ + 'id' => $data['id'], + 'name' => $data['name'], + 'full_name' => $data['full_name'], + 'private' => $data['private'], + 'html_url' => $data['html_url'], + 'description' => $data['description'], + 'default_branch' => $data['default_branch'], + ]; + } + + public function getComposerJson(string $owner, string $repo, string $branch = 'main'): ?array + { + $token = $this->user->getGitHubToken(); + + if (! $token) { + return null; + } + + $response = Http::withToken($token) + ->get("https://api.github.com/repos/{$owner}/{$repo}/contents/composer.json", [ + 'ref' => $branch, + ]); + + if ($response->failed()) { + return null; + } + + $data = $response->json(); + + if (! isset($data['content'])) { + return null; + } + + $content = base64_decode($data['content']); + + return json_decode($content, true); + } +} diff --git a/app/Services/GrandfatheringService.php b/app/Services/GrandfatheringService.php new file mode 100644 index 00000000..a569fa9f --- /dev/null +++ b/app/Services/GrandfatheringService.php @@ -0,0 +1,183 @@ +purchaseHistory; + + if (! $purchaseHistory || ! $purchaseHistory->hasPurchaseHistory()) { + return GrandfatheringTier::None; + } + + $firstPurchaseDate = $purchaseHistory->first_purchase_at; + $totalSpent = $purchaseHistory->total_spent; + + $isPostCutoff = $firstPurchaseDate->isAfter(Carbon::parse(self::CUTOFF_DATE)); + + if ($isPostCutoff && $totalSpent >= self::HIGH_SPEND_THRESHOLD) { + return GrandfatheringTier::FreeOfficialPlugins; + } + + if ($isPostCutoff || ! $isPostCutoff) { + return GrandfatheringTier::Discounted; + } + + return GrandfatheringTier::None; + } + + public function recalculateForUser(User $user): UserPurchaseHistory + { + $purchaseData = $this->calculatePurchaseData($user); + + $purchaseHistory = $user->purchaseHistory ?? new UserPurchaseHistory(['user_id' => $user->id]); + + $purchaseHistory->fill([ + 'total_spent' => $purchaseData['total_spent'], + 'first_purchase_at' => $purchaseData['first_purchase_at'], + 'recalculated_at' => now(), + ]); + + $purchaseHistory->grandfathering_tier = $this->determineUserTierFromData($purchaseData); + + $purchaseHistory->save(); + + Log::info("Recalculated grandfathering for user {$user->id}", [ + 'tier' => $purchaseHistory->grandfathering_tier->value, + 'total_spent' => $purchaseHistory->total_spent, + 'first_purchase_at' => $purchaseHistory->first_purchase_at?->toIso8601String(), + ]); + + return $purchaseHistory; + } + + /** + * @return array{total_spent: int, first_purchase_at: ?Carbon} + */ + protected function calculatePurchaseData(User $user): array + { + $totalSpent = 0; + $firstPurchaseAt = null; + + $licenses = $user->licenses()->orderBy('created_at', 'asc')->get(); + + foreach ($licenses as $license) { + if ($license->subscription_item_id && $license->subscriptionItem) { + $subscriptionItem = $license->subscriptionItem; + $subscription = $subscriptionItem->subscription; + + if ($subscription && $subscription->stripe_price) { + $totalSpent += $this->getAmountFromStripe($user, $subscription); + } + } + + if ($firstPurchaseAt === null) { + $firstPurchaseAt = $license->created_at; + } + } + + if ($totalSpent === 0 && $user->stripe_id) { + $stripeData = $this->calculateFromStripeInvoices($user); + $totalSpent = $stripeData['total_spent']; + $firstPurchaseAt = $stripeData['first_purchase_at'] ?? $firstPurchaseAt; + } + + return [ + 'total_spent' => $totalSpent, + 'first_purchase_at' => $firstPurchaseAt, + ]; + } + + /** + * @param array{total_spent: int, first_purchase_at: ?Carbon} $data + */ + protected function determineUserTierFromData(array $data): GrandfatheringTier + { + if (! $data['first_purchase_at']) { + return GrandfatheringTier::None; + } + + $isPostCutoff = $data['first_purchase_at']->isAfter(Carbon::parse(self::CUTOFF_DATE)); + + if ($isPostCutoff && $data['total_spent'] >= self::HIGH_SPEND_THRESHOLD) { + return GrandfatheringTier::FreeOfficialPlugins; + } + + return GrandfatheringTier::Discounted; + } + + protected function getAmountFromStripe(User $user, $subscription): int + { + try { + $invoices = $user->invoices(); + + foreach ($invoices as $invoice) { + if ($invoice->subscription === $subscription->stripe_id) { + return $invoice->rawTotal(); + } + } + } catch (\Exception $e) { + Log::warning("Failed to get Stripe invoice data for user {$user->id}: {$e->getMessage()}"); + } + + return 0; + } + + /** + * @return array{total_spent: int, first_purchase_at: ?Carbon} + */ + protected function calculateFromStripeInvoices(User $user): array + { + $totalSpent = 0; + $firstPurchaseAt = null; + + try { + $invoices = $user->invoices(); + + foreach ($invoices as $invoice) { + if ($invoice->paid) { + $totalSpent += $invoice->rawTotal(); + + $invoiceDate = Carbon::createFromTimestamp($invoice->created); + if ($firstPurchaseAt === null || $invoiceDate->lt($firstPurchaseAt)) { + $firstPurchaseAt = $invoiceDate; + } + } + } + } catch (\Exception $e) { + Log::warning("Failed to calculate from Stripe invoices for user {$user->id}: {$e->getMessage()}"); + } + + return [ + 'total_spent' => $totalSpent, + 'first_purchase_at' => $firstPurchaseAt, + ]; + } + + public function getApplicableDiscount(User $user, bool $isOfficialPlugin): int + { + $tier = $this->determineUserTier($user); + + if ($tier === GrandfatheringTier::FreeOfficialPlugins && $isOfficialPlugin) { + return 100; + } + + if ($tier === GrandfatheringTier::Discounted) { + return $tier->getDiscountPercent(); + } + + return 0; + } +} diff --git a/app/Services/PluginSyncService.php b/app/Services/PluginSyncService.php new file mode 100644 index 00000000..38d2066d --- /dev/null +++ b/app/Services/PluginSyncService.php @@ -0,0 +1,160 @@ +getRepositoryOwnerAndName(); + + if (! $repo) { + Log::warning("Plugin {$plugin->id} has no valid repository URL"); + + return false; + } + + $token = $this->getGitHubToken($plugin); + + $readme = $this->fetchFileFromGitHub($repo['owner'], $repo['repo'], 'README.md', $token); + $composerJson = $this->fetchFileFromGitHub($repo['owner'], $repo['repo'], 'composer.json', $token); + $nativephpJson = $this->fetchFileFromGitHub($repo['owner'], $repo['repo'], 'nativephp.json', $token); + + if (! $composerJson) { + Log::warning("Plugin {$plugin->id}: Could not fetch composer.json"); + + return false; + } + + $composerData = json_decode($composerJson, true); + $nativephpData = $nativephpJson ? json_decode($nativephpJson, true) : null; + + $updateData = [ + 'composer_data' => $composerData, + 'nativephp_data' => $nativephpData, + 'last_synced_at' => now(), + ]; + + if ($composerData) { + if (isset($composerData['name']) && ! $plugin->name) { + $updateData['name'] = $composerData['name']; + } + + if (isset($composerData['description'])) { + $updateData['description'] = $composerData['description']; + } + } + + if ($nativephpData) { + $updateData['ios_version'] = $this->extractIosVersion($nativephpData); + $updateData['android_version'] = $this->extractAndroidVersion($nativephpData); + } + + if ($readme) { + $updateData['readme_html'] = CommonMark::convertToHtml($readme); + } + + // Fetch the latest tag/release + $latestTag = $this->fetchLatestTag($repo['owner'], $repo['repo'], $token); + if ($latestTag) { + $updateData['latest_version'] = ltrim($latestTag, 'v'); + } + + $plugin->update($updateData); + + Log::info("Plugin {$plugin->id} synced successfully"); + + return true; + } + + public function fetchLatestTag(string $owner, string $repo, ?string $token): ?string + { + try { + $request = Http::timeout(10); + + if ($token) { + $request = $request->withToken($token); + } + + // First try to get the latest release + $response = $request->get("https://api.github.com/repos/{$owner}/{$repo}/releases/latest"); + + if ($response->successful()) { + return $response->json('tag_name'); + } + + // Fall back to tags if no releases exist + $tagsResponse = Http::timeout(10) + ->when($token, fn ($http) => $http->withToken($token)) + ->get("https://api.github.com/repos/{$owner}/{$repo}/tags", [ + 'per_page' => 1, + ]); + + if ($tagsResponse->successful() && count($tagsResponse->json()) > 0) { + return $tagsResponse->json()[0]['name']; + } + } catch (\Exception $e) { + Log::warning("Failed to fetch latest tag for {$owner}/{$repo}: {$e->getMessage()}"); + } + + return null; + } + + protected function fetchFileFromGitHub(string $owner, string $repo, string $path, ?string $token): ?string + { + try { + $request = Http::timeout(10); + + if ($token) { + $request = $request->withToken($token); + } + + $response = $request->get("https://api.github.com/repos/{$owner}/{$repo}/contents/{$path}"); + + if ($response->successful()) { + $data = $response->json(); + + if (isset($data['content'])) { + return base64_decode($data['content']); + } + } + + $baseUrl = "https://raw.githubusercontent.com/{$owner}/{$repo}/main"; + $fallbackResponse = Http::timeout(10)->get("{$baseUrl}/{$path}"); + + if ($fallbackResponse->successful()) { + return $fallbackResponse->body(); + } + } catch (\Exception $e) { + Log::warning("Failed to fetch {$path} from {$owner}/{$repo}: {$e->getMessage()}"); + } + + return null; + } + + protected function getGitHubToken(Plugin $plugin): ?string + { + $user = $plugin->user; + + if ($user && $user->hasGitHubToken()) { + return $user->getGitHubToken(); + } + + return config('services.github.token'); + } + + protected function extractIosVersion(array $nativephpData): ?string + { + return $nativephpData['ios']['min_version'] ?? null; + } + + protected function extractAndroidVersion(array $nativephpData): ?string + { + return $nativephpData['android']['min_version'] ?? null; + } +} diff --git a/app/Services/SatisService.php b/app/Services/SatisService.php new file mode 100644 index 00000000..55690f0d --- /dev/null +++ b/app/Services/SatisService.php @@ -0,0 +1,183 @@ +apiUrl = config('services.satis.url'); + $this->apiKey = config('services.satis.api_key'); + } + + /** + * Trigger a full satis build with all approved plugins. + */ + public function buildAll(): array + { + $plugins = $this->getApprovedPlugins(); + + return $this->triggerBuild($plugins); + } + + /** + * Trigger a satis build for specific plugins. + * + * @param array|\Illuminate\Support\Collection $plugins + */ + public function build($plugins): array + { + $pluginData = collect($plugins)->map(fn (Plugin $plugin) => [ + 'name' => $plugin->name, + 'repository_url' => $plugin->repository_url, + 'is_official' => $plugin->is_official ?? false, + ])->values()->all(); + + return $this->triggerBuild($pluginData); + } + + /** + * Remove a package from satis. + */ + public function removePackage(string $packageName): array + { + if (! $this->apiUrl || ! $this->apiKey) { + return [ + 'success' => false, + 'error' => 'Satis API not configured', + ]; + } + + try { + $response = Http::withToken($this->apiKey) + ->timeout(30) + ->delete("{$this->apiUrl}/api/packages/{$packageName}"); + + if ($response->successful()) { + Log::info('Satis package removal triggered', [ + 'package' => $packageName, + 'job_id' => $response->json('job_id'), + ]); + + return [ + 'success' => true, + 'job_id' => $response->json('job_id'), + 'message' => $response->json('message'), + ]; + } + + Log::error('Satis package removal failed', [ + 'package' => $packageName, + 'status' => $response->status(), + 'body' => $response->body(), + ]); + + return [ + 'success' => false, + 'error' => $response->json('error') ?? 'Unknown error', + 'status' => $response->status(), + ]; + } catch (\Exception $e) { + Log::error('Satis package removal exception', [ + 'package' => $packageName, + 'error' => $e->getMessage(), + ]); + + return [ + 'success' => false, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get all approved plugins formatted for satis. + * + * @return array + */ + protected function getApprovedPlugins(): array + { + return Plugin::query() + ->approved() + ->get() + ->map(fn (Plugin $plugin) => [ + 'name' => $plugin->name, + 'repository_url' => $plugin->repository_url, + 'is_official' => $plugin->is_official ?? false, + ]) + ->values() + ->all(); + } + + /** + * @param array $plugins + */ + protected function triggerBuild(array $plugins): array + { + if (! $this->apiUrl || ! $this->apiKey) { + return [ + 'success' => false, + 'error' => 'Satis API not configured. Set SATIS_URL and SATIS_API_KEY in .env', + ]; + } + + if (empty($plugins)) { + return [ + 'success' => false, + 'error' => 'No plugins to build', + ]; + } + + try { + $response = Http::withToken($this->apiKey) + ->accept('application/json') + ->timeout(30) + ->post("{$this->apiUrl}/api/build", [ + 'plugins' => $plugins, + ]); + + if ($response->successful()) { + Log::info('Satis build triggered', [ + 'plugins_count' => count($plugins), + 'job_id' => $response->json('job_id'), + ]); + + return [ + 'success' => true, + 'job_id' => $response->json('job_id'), + 'message' => $response->json('message'), + 'plugins_count' => count($plugins), + ]; + } + + Log::error('Satis build trigger failed', [ + 'url' => "{$this->apiUrl}/api/build", + 'status' => $response->status(), + 'body' => $response->body(), + ]); + + return [ + 'success' => false, + 'error' => $response->json('error') ?? $response->body() ?: "HTTP {$response->status()}", + 'status' => $response->status(), + ]; + } catch (\Exception $e) { + Log::error('Satis build trigger exception', [ + 'error' => $e->getMessage(), + ]); + + return [ + 'success' => false, + 'error' => $e->getMessage(), + ]; + } + } +} diff --git a/app/Services/StripeConnectService.php b/app/Services/StripeConnectService.php new file mode 100644 index 00000000..ae4055a2 --- /dev/null +++ b/app/Services/StripeConnectService.php @@ -0,0 +1,372 @@ +stripe = new StripeClient(config('cashier.secret')); + } + + public function createConnectAccount(User $user): DeveloperAccount + { + $account = $this->stripe->accounts->create([ + 'type' => 'express', + 'email' => $user->email, + 'metadata' => [ + 'user_id' => $user->id, + ], + 'capabilities' => [ + 'transfers' => ['requested' => true], + ], + ]); + + return DeveloperAccount::create([ + 'user_id' => $user->id, + 'stripe_connect_account_id' => $account->id, + 'stripe_connect_status' => StripeConnectStatus::Pending, + 'payouts_enabled' => false, + 'charges_enabled' => false, + ]); + } + + public function createOnboardingLink(DeveloperAccount $account): string + { + $accountLink = $this->stripe->accountLinks->create([ + 'account' => $account->stripe_connect_account_id, + 'refresh_url' => route('customer.developer.onboarding.refresh'), + 'return_url' => route('customer.developer.onboarding.return'), + 'type' => 'account_onboarding', + ]); + + return $accountLink->url; + } + + public function refreshAccountStatus(DeveloperAccount $account): void + { + $stripeAccount = $this->stripe->accounts->retrieve($account->stripe_connect_account_id); + + $account->update([ + 'payouts_enabled' => $stripeAccount->payouts_enabled, + 'charges_enabled' => $stripeAccount->charges_enabled, + 'stripe_connect_status' => $this->determineStatus($stripeAccount), + 'onboarding_completed_at' => $stripeAccount->details_submitted ? now() : null, + ]); + + Log::info('Refreshed developer account status', [ + 'developer_account_id' => $account->id, + 'stripe_account_id' => $account->stripe_connect_account_id, + 'status' => $account->stripe_connect_status->value, + ]); + } + + public function createCheckoutSession(PluginPrice $price, User $buyer): \Stripe\Checkout\Session + { + $plugin = $price->plugin; + $developerAccount = $plugin->developerAccount; + + // Ensure the buyer has a Stripe customer ID (required for Stripe Accounts V2) + if (! $buyer->stripe_id) { + $buyer->createAsStripeCustomer(); + } + + $sessionParams = [ + 'mode' => 'payment', + 'line_items' => [ + [ + 'price_data' => [ + 'currency' => strtolower($price->currency), + 'unit_amount' => $price->amount, + 'product_data' => [ + 'name' => $plugin->name, + 'description' => $plugin->description ?? 'NativePHP Plugin', + ], + ], + 'quantity' => 1, + ], + ], + 'success_url' => route('plugins.purchase.success', ['plugin' => $plugin->id]).'?session_id={CHECKOUT_SESSION_ID}', + 'cancel_url' => route('plugins.purchase.cancel', ['plugin' => $plugin->id]), + 'customer' => $buyer->stripe_id, + 'customer_update' => [ + 'name' => 'auto', + 'address' => 'auto', + ], + 'metadata' => [ + 'plugin_id' => $plugin->id, + 'user_id' => $buyer->id, + 'price_id' => $price->id, + ], + 'billing_address_collection' => 'required', + 'tax_id_collection' => ['enabled' => true], + 'invoice_creation' => [ + 'enabled' => true, + 'invoice_data' => [ + 'description' => 'NativePHP Plugin Purchase', + 'footer' => 'Thank you for your purchase!', + ], + ], + ]; + + if ($developerAccount && $developerAccount->canReceivePayouts()) { + $split = PluginPayout::calculateSplit($price->amount); + + $sessionParams['payment_intent_data'] = [ + 'transfer_data' => [ + 'destination' => $developerAccount->stripe_connect_account_id, + 'amount' => $split['developer_amount'], + ], + ]; + } + + return $this->stripe->checkout->sessions->create($sessionParams); + } + + public function processSuccessfulPayment(string $sessionId): PluginLicense + { + $session = $this->stripe->checkout->sessions->retrieve($sessionId, [ + 'expand' => ['payment_intent'], + ]); + + $pluginId = $session->metadata->plugin_id; + $userId = $session->metadata->user_id; + $priceId = $session->metadata->price_id; + + $plugin = Plugin::findOrFail($pluginId); + $user = User::findOrFail($userId); + $price = PluginPrice::findOrFail($priceId); + + $license = PluginLicense::create([ + 'user_id' => $user->id, + 'plugin_id' => $plugin->id, + 'stripe_payment_intent_id' => $session->payment_intent->id, + 'price_paid' => $session->amount_total, + 'currency' => strtoupper($session->currency), + 'is_grandfathered' => false, + 'purchased_at' => now(), + ]); + + if ($plugin->developerAccount && $plugin->developerAccount->canReceivePayouts()) { + $this->createPayout($license, $plugin->developerAccount); + } + + $user->getPluginLicenseKey(); + + Log::info('Created plugin license from successful payment', [ + 'license_id' => $license->id, + 'user_id' => $user->id, + 'plugin_id' => $plugin->id, + ]); + + return $license; + } + + /** + * Process a multi-item cart payment and create licenses for each plugin. + * + * @return array + */ + public function processMultiItemPayment(string $sessionId): array + { + $session = $this->stripe->checkout->sessions->retrieve($sessionId, [ + 'expand' => ['payment_intent', 'line_items'], + ]); + + $userId = $session->metadata->user_id; + $pluginIds = explode(',', $session->metadata->plugin_ids); + $priceIds = explode(',', $session->metadata->price_ids); + + $user = User::findOrFail($userId); + $licenses = []; + + // Get line items to match prices + $lineItems = $session->line_items->data; + + foreach ($pluginIds as $index => $pluginId) { + $plugin = Plugin::findOrFail($pluginId); + $priceId = $priceIds[$index] ?? null; + $price = $priceId ? PluginPrice::find($priceId) : null; + + // Get the amount from line items + $amount = isset($lineItems[$index]) + ? $lineItems[$index]->amount_total + : ($price ? $price->amount : 0); + + $license = PluginLicense::create([ + 'user_id' => $user->id, + 'plugin_id' => $plugin->id, + 'stripe_payment_intent_id' => $session->payment_intent->id, + 'price_paid' => $amount, + 'currency' => strtoupper($session->currency), + 'is_grandfathered' => false, + 'purchased_at' => now(), + ]); + + if ($plugin->developerAccount && $plugin->developerAccount->canReceivePayouts()) { + $this->createPayout($license, $plugin->developerAccount); + } + + $licenses[] = $license; + + Log::info('Created plugin license from cart payment', [ + 'license_id' => $license->id, + 'user_id' => $user->id, + 'plugin_id' => $plugin->id, + ]); + } + + $user->getPluginLicenseKey(); + + return $licenses; + } + + public function createPayout(PluginLicense $license, DeveloperAccount $developerAccount): PluginPayout + { + $split = PluginPayout::calculateSplit($license->price_paid); + + return PluginPayout::create([ + 'plugin_license_id' => $license->id, + 'developer_account_id' => $developerAccount->id, + 'gross_amount' => $license->price_paid, + 'platform_fee' => $split['platform_fee'], + 'developer_amount' => $split['developer_amount'], + 'status' => PayoutStatus::Pending, + ]); + } + + public function processTransfer(PluginPayout $payout): bool + { + if (! $payout->isPending()) { + return false; + } + + $developerAccount = $payout->developerAccount; + + if (! $developerAccount->canReceivePayouts()) { + Log::warning('Developer account cannot receive payouts', [ + 'payout_id' => $payout->id, + 'developer_account_id' => $developerAccount->id, + ]); + + return false; + } + + // Get the charge ID from the payment intent to use as source_transaction + // This ensures the transfer uses funds from this specific charge and waits for them to be available + $chargeId = $this->getChargeIdFromPayout($payout); + + try { + $transferParams = [ + 'amount' => $payout->developer_amount, + 'currency' => 'usd', + 'destination' => $developerAccount->stripe_connect_account_id, + 'metadata' => [ + 'payout_id' => $payout->id, + 'plugin_license_id' => $payout->plugin_license_id, + ], + ]; + + // Link transfer to the source charge - Stripe will wait for funds to be available + if ($chargeId) { + $transferParams['source_transaction'] = $chargeId; + } + + $transfer = $this->stripe->transfers->create($transferParams); + + $payout->markAsTransferred($transfer->id); + + Log::info('Processed transfer for payout', [ + 'payout_id' => $payout->id, + 'transfer_id' => $transfer->id, + 'amount' => $payout->developer_amount, + 'source_transaction' => $chargeId, + ]); + + return true; + } catch (\Exception $e) { + Log::error('Failed to process transfer', [ + 'payout_id' => $payout->id, + 'charge_id' => $chargeId, + 'error' => $e->getMessage(), + ]); + + $payout->markAsFailed(); + + return false; + } + } + + protected function getChargeIdFromPayout(PluginPayout $payout): ?string + { + $license = $payout->pluginLicense; + + if (! $license || ! $license->stripe_payment_intent_id) { + return null; + } + + try { + $paymentIntent = $this->stripe->paymentIntents->retrieve($license->stripe_payment_intent_id); + + return $paymentIntent->latest_charge; + } catch (\Exception $e) { + Log::warning('Could not retrieve charge ID from payment intent', [ + 'payment_intent_id' => $license->stripe_payment_intent_id, + 'error' => $e->getMessage(), + ]); + + return null; + } + } + + protected function determineStatus(\Stripe\Account $account): StripeConnectStatus + { + if ($account->payouts_enabled && $account->charges_enabled) { + return StripeConnectStatus::Active; + } + + if ($account->requirements?->disabled_reason) { + return StripeConnectStatus::Disabled; + } + + return StripeConnectStatus::Pending; + } + + public function createProductAndPrice(Plugin $plugin, int $amountCents, string $currency = 'usd'): PluginPrice + { + $product = $this->stripe->products->create([ + 'name' => $plugin->name, + 'description' => $plugin->description, + 'metadata' => [ + 'plugin_id' => $plugin->id, + ], + ]); + + $price = $this->stripe->prices->create([ + 'product' => $product->id, + 'unit_amount' => $amountCents, + 'currency' => $currency, + ]); + + return PluginPrice::create([ + 'plugin_id' => $plugin->id, + 'stripe_price_id' => $price->id, + 'amount' => $amountCents, + 'currency' => strtoupper($currency), + 'is_active' => true, + ]); + } +} diff --git a/composer.json b/composer.json index a5815902..85864f4f 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,7 @@ "laravel/socialite": "^5.24", "laravel/tinker": "^2.8", "league/commonmark": "^2.4", + "league/flysystem-aws-s3-v3": "^3.30", "livewire/livewire": "^3.6.4", "sentry/sentry-laravel": "^4.13", "simonhamp/the-og": "^0.7.0", diff --git a/composer.lock b/composer.lock index 6ecd4711..5fea3c3d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4bbe279a024738785d34d58e55b4b63c", + "content-hash": "af06c7ca6c76ed93b6fd1145f77cf32a", "packages": [ { "name": "anourvalar/eloquent-serialize", - "version": "1.3.4", + "version": "1.3.5", "source": { "type": "git", "url": "https://github.com/AnourValar/eloquent-serialize.git", - "reference": "0934a98866e02b73e38696961a9d7984b834c9d9" + "reference": "1a7dead8d532657e5358f8f27c0349373517681e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/AnourValar/eloquent-serialize/zipball/0934a98866e02b73e38696961a9d7984b834c9d9", - "reference": "0934a98866e02b73e38696961a9d7984b834c9d9", + "url": "https://api.github.com/repos/AnourValar/eloquent-serialize/zipball/1a7dead8d532657e5358f8f27c0349373517681e", + "reference": "1a7dead8d532657e5358f8f27c0349373517681e", "shasum": "" }, "require": { @@ -68,9 +68,9 @@ ], "support": { "issues": "https://github.com/AnourValar/eloquent-serialize/issues", - "source": "https://github.com/AnourValar/eloquent-serialize/tree/1.3.4" + "source": "https://github.com/AnourValar/eloquent-serialize/tree/1.3.5" }, - "time": "2025-07-30T15:45:57+00:00" + "time": "2025-12-04T13:38:21+00:00" }, { "name": "artesaos/seotools", @@ -143,6 +143,157 @@ }, "time": "2025-03-07T14:44:43+00:00" }, + { + "name": "aws/aws-crt-php", + "version": "v1.2.7", + "source": { + "type": "git", + "url": "https://github.com/awslabs/aws-crt-php.git", + "reference": "d71d9906c7bb63a28295447ba12e74723bd3730e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e", + "reference": "d71d9906c7bb63a28295447ba12e74723bd3730e", + "shasum": "" + }, + "require": { + "php": ">=5.5" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35||^5.6.3||^9.5", + "yoast/phpunit-polyfills": "^1.0" + }, + "suggest": { + "ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality." + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "AWS SDK Common Runtime Team", + "email": "aws-sdk-common-runtime@amazon.com" + } + ], + "description": "AWS Common Runtime for PHP", + "homepage": "https://github.com/awslabs/aws-crt-php", + "keywords": [ + "amazon", + "aws", + "crt", + "sdk" + ], + "support": { + "issues": "https://github.com/awslabs/aws-crt-php/issues", + "source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7" + }, + "time": "2024-10-18T22:15:13+00:00" + }, + { + "name": "aws/aws-sdk-php", + "version": "3.369.15", + "source": { + "type": "git", + "url": "https://github.com/aws/aws-sdk-php.git", + "reference": "7c62f41fb0460c3e5d5c1f70e93e726f1daa75f5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/7c62f41fb0460c3e5d5c1f70e93e726f1daa75f5", + "reference": "7c62f41fb0460c3e5d5c1f70e93e726f1daa75f5", + "shasum": "" + }, + "require": { + "aws/aws-crt-php": "^1.2.3", + "ext-json": "*", + "ext-pcre": "*", + "ext-simplexml": "*", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/promises": "^2.0", + "guzzlehttp/psr7": "^2.4.5", + "mtdowling/jmespath.php": "^2.8.0", + "php": ">=8.1", + "psr/http-message": "^1.0 || ^2.0", + "symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0" + }, + "require-dev": { + "andrewsville/php-token-reflection": "^1.4", + "aws/aws-php-sns-message-validator": "~1.0", + "behat/behat": "~3.0", + "composer/composer": "^2.7.8", + "dms/phpunit-arraysubset-asserts": "^0.4.0", + "doctrine/cache": "~1.4", + "ext-dom": "*", + "ext-openssl": "*", + "ext-sockets": "*", + "phpunit/phpunit": "^9.6", + "psr/cache": "^2.0 || ^3.0", + "psr/simple-cache": "^2.0 || ^3.0", + "sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0", + "yoast/phpunit-polyfills": "^2.0" + }, + "suggest": { + "aws/aws-php-sns-message-validator": "To validate incoming SNS notifications", + "doctrine/cache": "To use the DoctrineCacheAdapter", + "ext-curl": "To send requests using cURL", + "ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages", + "ext-pcntl": "To use client-side monitoring", + "ext-sockets": "To use client-side monitoring" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Aws\\": "src/" + }, + "exclude-from-classmap": [ + "src/data/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Amazon Web Services", + "homepage": "http://aws.amazon.com" + } + ], + "description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project", + "homepage": "http://aws.amazon.com/sdkforphp", + "keywords": [ + "amazon", + "aws", + "cloud", + "dynamodb", + "ec2", + "glacier", + "s3", + "sdk" + ], + "support": { + "forum": "https://github.com/aws/aws-sdk-php/discussions", + "issues": "https://github.com/aws/aws-sdk-php/issues", + "source": "https://github.com/aws/aws-sdk-php/tree/3.369.15" + }, + "time": "2026-01-16T19:18:57+00:00" + }, { "name": "blade-ui-kit/blade-heroicons", "version": "2.6.0", @@ -424,16 +575,16 @@ }, { "name": "composer/ca-bundle", - "version": "1.5.9", + "version": "1.5.10", "source": { "type": "git", "url": "https://github.com/composer/ca-bundle.git", - "reference": "1905981ee626e6f852448b7aaa978f8666c5bc54" + "reference": "961a5e4056dd2e4a2eedcac7576075947c28bf63" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/ca-bundle/zipball/1905981ee626e6f852448b7aaa978f8666c5bc54", - "reference": "1905981ee626e6f852448b7aaa978f8666c5bc54", + "url": "https://api.github.com/repos/composer/ca-bundle/zipball/961a5e4056dd2e4a2eedcac7576075947c28bf63", + "reference": "961a5e4056dd2e4a2eedcac7576075947c28bf63", "shasum": "" }, "require": { @@ -480,7 +631,7 @@ "support": { "irc": "irc://irc.freenode.org/composer", "issues": "https://github.com/composer/ca-bundle/issues", - "source": "https://github.com/composer/ca-bundle/tree/1.5.9" + "source": "https://github.com/composer/ca-bundle/tree/1.5.10" }, "funding": [ { @@ -492,7 +643,7 @@ "type": "github" } ], - "time": "2025-11-06T11:46:17+00:00" + "time": "2025-12-08T15:06:51+00:00" }, { "name": "danharrin/date-format-converter", @@ -676,16 +827,16 @@ }, { "name": "doctrine/dbal", - "version": "3.10.3", + "version": "3.10.4", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "65edaca19a752730f290ec2fb89d593cb40afb43" + "reference": "63a46cb5aa6f60991186cc98c1d1b50c09311868" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/65edaca19a752730f290ec2fb89d593cb40afb43", - "reference": "65edaca19a752730f290ec2fb89d593cb40afb43", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/63a46cb5aa6f60991186cc98c1d1b50c09311868", + "reference": "63a46cb5aa6f60991186cc98c1d1b50c09311868", "shasum": "" }, "require": { @@ -709,8 +860,8 @@ "phpunit/phpunit": "9.6.29", "slevomat/coding-standard": "8.24.0", "squizlabs/php_codesniffer": "4.0.0", - "symfony/cache": "^5.4|^6.0|^7.0", - "symfony/console": "^4.4|^5.4|^6.0|^7.0" + "symfony/cache": "^5.4|^6.0|^7.0|^8.0", + "symfony/console": "^4.4|^5.4|^6.0|^7.0|^8.0" }, "suggest": { "symfony/console": "For helpful console commands such as SQL execution and import of files." @@ -770,7 +921,7 @@ ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/3.10.3" + "source": "https://github.com/doctrine/dbal/tree/3.10.4" }, "funding": [ { @@ -786,7 +937,7 @@ "type": "tidelift" } ], - "time": "2025-10-09T09:05:12+00:00" + "time": "2025-11-29T10:46:08+00:00" }, { "name": "doctrine/deprecations", @@ -838,16 +989,16 @@ }, { "name": "doctrine/event-manager", - "version": "2.0.1", + "version": "2.1.0", "source": { "type": "git", "url": "https://github.com/doctrine/event-manager.git", - "reference": "b680156fa328f1dfd874fd48c7026c41570b9c6e" + "reference": "c07799fcf5ad362050960a0fd068dded40b1e312" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/event-manager/zipball/b680156fa328f1dfd874fd48c7026c41570b9c6e", - "reference": "b680156fa328f1dfd874fd48c7026c41570b9c6e", + "url": "https://api.github.com/repos/doctrine/event-manager/zipball/c07799fcf5ad362050960a0fd068dded40b1e312", + "reference": "c07799fcf5ad362050960a0fd068dded40b1e312", "shasum": "" }, "require": { @@ -857,10 +1008,10 @@ "doctrine/common": "<2.9" }, "require-dev": { - "doctrine/coding-standard": "^12", - "phpstan/phpstan": "^1.8.8", - "phpunit/phpunit": "^10.5", - "vimeo/psalm": "^5.24" + "doctrine/coding-standard": "^14", + "phpdocumentor/guides-cli": "^1.4", + "phpstan/phpstan": "^2.1.32", + "phpunit/phpunit": "^10.5.58" }, "type": "library", "autoload": { @@ -909,7 +1060,7 @@ ], "support": { "issues": "https://github.com/doctrine/event-manager/issues", - "source": "https://github.com/doctrine/event-manager/tree/2.0.1" + "source": "https://github.com/doctrine/event-manager/tree/2.1.0" }, "funding": [ { @@ -925,7 +1076,7 @@ "type": "tidelift" } ], - "time": "2024-05-22T20:47:39+00:00" + "time": "2026-01-17T22:40:21+00:00" }, { "name": "doctrine/inflector", @@ -1316,16 +1467,16 @@ }, { "name": "filament/actions", - "version": "v3.3.45", + "version": "v3.3.47", "source": { "type": "git", "url": "https://github.com/filamentphp/actions.git", - "reference": "4582f2da9ed0660685b8e0849d32f106bc8a4b2d" + "reference": "f8ea2b015b12c00522f1d6a7bcb9453b5f08beb1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/actions/zipball/4582f2da9ed0660685b8e0849d32f106bc8a4b2d", - "reference": "4582f2da9ed0660685b8e0849d32f106bc8a4b2d", + "url": "https://api.github.com/repos/filamentphp/actions/zipball/f8ea2b015b12c00522f1d6a7bcb9453b5f08beb1", + "reference": "f8ea2b015b12c00522f1d6a7bcb9453b5f08beb1", "shasum": "" }, "require": { @@ -1365,20 +1516,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2025-09-28T22:06:00+00:00" + "time": "2026-01-01T16:29:27+00:00" }, { "name": "filament/filament", - "version": "v3.3.45", + "version": "v3.3.47", "source": { "type": "git", "url": "https://github.com/filamentphp/panels.git", - "reference": "1cc3a0b06cb287048c53d49b3915064a8fc6449f" + "reference": "790e3c163e93f5746beea88b93d38673424984b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/panels/zipball/1cc3a0b06cb287048c53d49b3915064a8fc6449f", - "reference": "1cc3a0b06cb287048c53d49b3915064a8fc6449f", + "url": "https://api.github.com/repos/filamentphp/panels/zipball/790e3c163e93f5746beea88b93d38673424984b6", + "reference": "790e3c163e93f5746beea88b93d38673424984b6", "shasum": "" }, "require": { @@ -1430,20 +1581,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2025-11-11T10:10:18+00:00" + "time": "2026-01-01T16:29:34+00:00" }, { "name": "filament/forms", - "version": "v3.3.45", + "version": "v3.3.47", "source": { "type": "git", "url": "https://github.com/filamentphp/forms.git", - "reference": "da5401bf3684b6abc6cf1d8e152f01b25d815319" + "reference": "f708ce490cff3770071d18e9ea678eb4b7c65c58" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/forms/zipball/da5401bf3684b6abc6cf1d8e152f01b25d815319", - "reference": "da5401bf3684b6abc6cf1d8e152f01b25d815319", + "url": "https://api.github.com/repos/filamentphp/forms/zipball/f708ce490cff3770071d18e9ea678eb4b7c65c58", + "reference": "f708ce490cff3770071d18e9ea678eb4b7c65c58", "shasum": "" }, "require": { @@ -1486,20 +1637,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2025-10-06T21:42:10+00:00" + "time": "2026-01-01T16:29:33+00:00" }, { "name": "filament/infolists", - "version": "v3.3.45", + "version": "v3.3.47", "source": { "type": "git", "url": "https://github.com/filamentphp/infolists.git", - "reference": "5a519cf36a20039ccba8491a52028a8619cb70cb" + "reference": "ac7fc1c8acc651c6c793696f0772747791c91155" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/infolists/zipball/5a519cf36a20039ccba8491a52028a8619cb70cb", - "reference": "5a519cf36a20039ccba8491a52028a8619cb70cb", + "url": "https://api.github.com/repos/filamentphp/infolists/zipball/ac7fc1c8acc651c6c793696f0772747791c91155", + "reference": "ac7fc1c8acc651c6c793696f0772747791c91155", "shasum": "" }, "require": { @@ -1537,20 +1688,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2025-11-11T10:09:16+00:00" + "time": "2026-01-01T16:28:31+00:00" }, { "name": "filament/notifications", - "version": "v3.3.45", + "version": "v3.3.47", "source": { "type": "git", "url": "https://github.com/filamentphp/notifications.git", - "reference": "e94502a23ccdb2a74c7cc408db3291c36371231c" + "reference": "3a6ef54b6a8cefc79858e7033e4d6b65fb2d859b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/notifications/zipball/e94502a23ccdb2a74c7cc408db3291c36371231c", - "reference": "e94502a23ccdb2a74c7cc408db3291c36371231c", + "url": "https://api.github.com/repos/filamentphp/notifications/zipball/3a6ef54b6a8cefc79858e7033e4d6b65fb2d859b", + "reference": "3a6ef54b6a8cefc79858e7033e4d6b65fb2d859b", "shasum": "" }, "require": { @@ -1589,20 +1740,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2025-11-11T10:09:28+00:00" + "time": "2026-01-01T16:29:16+00:00" }, { "name": "filament/support", - "version": "v3.3.45", + "version": "v3.3.47", "source": { "type": "git", "url": "https://github.com/filamentphp/support.git", - "reference": "afafd5e7a2f8cf052f70f989b52d82d0a1df5c78" + "reference": "c37f4b9045a7c514974e12562b5a41813860b505" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/support/zipball/afafd5e7a2f8cf052f70f989b52d82d0a1df5c78", - "reference": "afafd5e7a2f8cf052f70f989b52d82d0a1df5c78", + "url": "https://api.github.com/repos/filamentphp/support/zipball/c37f4b9045a7c514974e12562b5a41813860b505", + "reference": "c37f4b9045a7c514974e12562b5a41813860b505", "shasum": "" }, "require": { @@ -1648,20 +1799,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2025-08-12T13:15:44+00:00" + "time": "2026-01-09T09:01:14+00:00" }, { "name": "filament/tables", - "version": "v3.3.45", + "version": "v3.3.47", "source": { "type": "git", "url": "https://github.com/filamentphp/tables.git", - "reference": "2e1e3aeeeccd6b74e5d038325af52635d1108e4c" + "reference": "c88d17248827b3fbca09db53d563498d29c6b180" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/tables/zipball/2e1e3aeeeccd6b74e5d038325af52635d1108e4c", - "reference": "2e1e3aeeeccd6b74e5d038325af52635d1108e4c", + "url": "https://api.github.com/repos/filamentphp/tables/zipball/c88d17248827b3fbca09db53d563498d29c6b180", + "reference": "c88d17248827b3fbca09db53d563498d29c6b180", "shasum": "" }, "require": { @@ -1700,20 +1851,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2025-09-17T10:47:13+00:00" + "time": "2026-01-01T16:29:37+00:00" }, { "name": "filament/widgets", - "version": "v3.3.45", + "version": "v3.3.47", "source": { "type": "git", "url": "https://github.com/filamentphp/widgets.git", - "reference": "5b956f884aaef479f6091463cb829e7c9f2afc2c" + "reference": "2bf59fd94007b69c22c161f7a4749ea19560e03e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/widgets/zipball/5b956f884aaef479f6091463cb829e7c9f2afc2c", - "reference": "5b956f884aaef479f6091463cb829e7c9f2afc2c", + "url": "https://api.github.com/repos/filamentphp/widgets/zipball/2bf59fd94007b69c22c161f7a4749ea19560e03e", + "reference": "2bf59fd94007b69c22c161f7a4749ea19560e03e", "shasum": "" }, "require": { @@ -1744,20 +1895,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2025-06-12T15:11:14+00:00" + "time": "2026-01-01T16:29:32+00:00" }, { "name": "firebase/php-jwt", - "version": "v6.11.1", + "version": "v7.0.2", "source": { "type": "git", "url": "https://github.com/firebase/php-jwt.git", - "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66" + "reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/firebase/php-jwt/zipball/d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", - "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/5645b43af647b6947daac1d0f659dd1fbe8d3b65", + "reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65", "shasum": "" }, "require": { @@ -1805,37 +1956,37 @@ ], "support": { "issues": "https://github.com/firebase/php-jwt/issues", - "source": "https://github.com/firebase/php-jwt/tree/v6.11.1" + "source": "https://github.com/firebase/php-jwt/tree/v7.0.2" }, - "time": "2025-04-09T20:32:01+00:00" + "time": "2025-12-16T22:17:28+00:00" }, { "name": "fruitcake/php-cors", - "version": "v1.3.0", + "version": "v1.4.0", "source": { "type": "git", "url": "https://github.com/fruitcake/php-cors.git", - "reference": "3d158f36e7875e2f040f37bc0573956240a5a38b" + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/3d158f36e7875e2f040f37bc0573956240a5a38b", - "reference": "3d158f36e7875e2f040f37bc0573956240a5a38b", + "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", "shasum": "" }, "require": { - "php": "^7.4|^8.0", - "symfony/http-foundation": "^4.4|^5.4|^6|^7" + "php": "^8.1", + "symfony/http-foundation": "^5.4|^6.4|^7.3|^8" }, "require-dev": { - "phpstan/phpstan": "^1.4", + "phpstan/phpstan": "^2", "phpunit/phpunit": "^9", - "squizlabs/php_codesniffer": "^3.5" + "squizlabs/php_codesniffer": "^4" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.2-dev" + "dev-master": "1.3-dev" } }, "autoload": { @@ -1866,7 +2017,7 @@ ], "support": { "issues": "https://github.com/fruitcake/php-cors/issues", - "source": "https://github.com/fruitcake/php-cors/tree/v1.3.0" + "source": "https://github.com/fruitcake/php-cors/tree/v1.4.0" }, "funding": [ { @@ -1878,28 +2029,28 @@ "type": "github" } ], - "time": "2023-10-12T05:21:21+00:00" + "time": "2025-12-03T09:33:47+00:00" }, { "name": "graham-campbell/result-type", - "version": "v1.1.3", + "version": "v1.1.4", "source": { "type": "git", "url": "https://github.com/GrahamCampbell/Result-Type.git", - "reference": "3ba905c11371512af9d9bdd27d99b782216b6945" + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/3ba905c11371512af9d9bdd27d99b782216b6945", - "reference": "3ba905c11371512af9d9bdd27d99b782216b6945", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.3" + "phpoption/phpoption": "^1.9.5" }, "require-dev": { - "phpunit/phpunit": "^8.5.39 || ^9.6.20 || ^10.5.28" + "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" }, "type": "library", "autoload": { @@ -1928,7 +2079,7 @@ ], "support": { "issues": "https://github.com/GrahamCampbell/Result-Type/issues", - "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.3" + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" }, "funding": [ { @@ -1940,7 +2091,7 @@ "type": "tidelift" } ], - "time": "2024-07-20T21:45:45+00:00" + "time": "2025-12-27T19:43:20+00:00" }, { "name": "guzzlehttp/guzzle", @@ -2355,16 +2506,16 @@ }, { "name": "intervention/gif", - "version": "4.2.2", + "version": "4.2.4", "source": { "type": "git", "url": "https://github.com/Intervention/gif.git", - "reference": "5999eac6a39aa760fb803bc809e8909ee67b451a" + "reference": "c3598a16ebe7690cd55640c44144a9df383ea73c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Intervention/gif/zipball/5999eac6a39aa760fb803bc809e8909ee67b451a", - "reference": "5999eac6a39aa760fb803bc809e8909ee67b451a", + "url": "https://api.github.com/repos/Intervention/gif/zipball/c3598a16ebe7690cd55640c44144a9df383ea73c", + "reference": "c3598a16ebe7690cd55640c44144a9df383ea73c", "shasum": "" }, "require": { @@ -2403,7 +2554,7 @@ ], "support": { "issues": "https://github.com/Intervention/gif/issues", - "source": "https://github.com/Intervention/gif/tree/4.2.2" + "source": "https://github.com/Intervention/gif/tree/4.2.4" }, "funding": [ { @@ -2419,20 +2570,20 @@ "type": "ko_fi" } ], - "time": "2025-03-29T07:46:21+00:00" + "time": "2026-01-04T09:27:23+00:00" }, { "name": "intervention/image", - "version": "3.11.4", + "version": "3.11.6", "source": { "type": "git", "url": "https://github.com/Intervention/image.git", - "reference": "8c49eb21a6d2572532d1bc425964264f3e496846" + "reference": "5f6d27d9fd56312c47f347929e7ac15345c605a1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Intervention/image/zipball/8c49eb21a6d2572532d1bc425964264f3e496846", - "reference": "8c49eb21a6d2572532d1bc425964264f3e496846", + "url": "https://api.github.com/repos/Intervention/image/zipball/5f6d27d9fd56312c47f347929e7ac15345c605a1", + "reference": "5f6d27d9fd56312c47f347929e7ac15345c605a1", "shasum": "" }, "require": { @@ -2464,11 +2615,11 @@ { "name": "Oliver Vogel", "email": "oliver@intervention.io", - "homepage": "https://intervention.io/" + "homepage": "https://intervention.io" } ], - "description": "PHP image manipulation", - "homepage": "https://image.intervention.io/", + "description": "PHP Image Processing", + "homepage": "https://image.intervention.io", "keywords": [ "gd", "image", @@ -2479,7 +2630,7 @@ ], "support": { "issues": "https://github.com/Intervention/image/issues", - "source": "https://github.com/Intervention/image/tree/3.11.4" + "source": "https://github.com/Intervention/image/tree/3.11.6" }, "funding": [ { @@ -2495,7 +2646,7 @@ "type": "ko_fi" } ], - "time": "2025-07-30T13:13:19+00:00" + "time": "2025-12-17T13:38:29+00:00" }, { "name": "jean85/pretty-package-versions", @@ -2710,16 +2861,16 @@ }, { "name": "laravel/framework", - "version": "v10.49.1", + "version": "v10.50.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "f857267b80789327cd3e6b077bcf6df5846cf71b" + "reference": "fc41c8ceb4d4a55b23d4030ef4ed86383e4b2bc3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/f857267b80789327cd3e6b077bcf6df5846cf71b", - "reference": "f857267b80789327cd3e6b077bcf6df5846cf71b", + "url": "https://api.github.com/repos/laravel/framework/zipball/fc41c8ceb4d4a55b23d4030ef4ed86383e4b2bc3", + "reference": "fc41c8ceb4d4a55b23d4030ef4ed86383e4b2bc3", "shasum": "" }, "require": { @@ -2914,20 +3065,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-09-30T14:56:54+00:00" + "time": "2025-11-28T18:20:42+00:00" }, { "name": "laravel/nightwatch", - "version": "v1.21.1", + "version": "v1.22.0", "source": { "type": "git", "url": "https://github.com/laravel/nightwatch.git", - "reference": "c77c70b56802ff1fc743772a497eeea742ee2b38" + "reference": "a6ef3f6bccc81e69e17e4f67992c1a3ab6a85110" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/nightwatch/zipball/c77c70b56802ff1fc743772a497eeea742ee2b38", - "reference": "c77c70b56802ff1fc743772a497eeea742ee2b38", + "url": "https://api.github.com/repos/laravel/nightwatch/zipball/a6ef3f6bccc81e69e17e4f67992c1a3ab6a85110", + "reference": "a6ef3f6bccc81e69e17e4f67992c1a3ab6a85110", "shasum": "" }, "require": { @@ -3008,20 +3159,20 @@ "issues": "https://github.com/laravel/nightwatch/issues", "source": "https://github.com/laravel/nightwatch" }, - "time": "2025-12-18T04:19:39+00:00" + "time": "2026-01-15T04:53:20+00:00" }, { "name": "laravel/pennant", - "version": "v1.18.4", + "version": "v1.18.5", "source": { "type": "git", "url": "https://github.com/laravel/pennant.git", - "reference": "b0725624411f9365f915ae4ec69f415d696caf52" + "reference": "c7d824a46b6fa801925dd3b93470382bcc5b2b58" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pennant/zipball/b0725624411f9365f915ae4ec69f415d696caf52", - "reference": "b0725624411f9365f915ae4ec69f415d696caf52", + "url": "https://api.github.com/repos/laravel/pennant/zipball/c7d824a46b6fa801925dd3b93470382bcc5b2b58", + "reference": "c7d824a46b6fa801925dd3b93470382bcc5b2b58", "shasum": "" }, "require": { @@ -3084,7 +3235,7 @@ "issues": "https://github.com/laravel/pennant/issues", "source": "https://github.com/laravel/pennant" }, - "time": "2025-11-20T16:27:35+00:00" + "time": "2025-11-27T16:22:11+00:00" }, { "name": "laravel/prompts", @@ -3273,21 +3424,21 @@ }, { "name": "laravel/socialite", - "version": "v5.24.0", + "version": "v5.24.2", "source": { "type": "git", "url": "https://github.com/laravel/socialite.git", - "reference": "1d19358c28e8951dde6e36603b89d8f09e6cfbfd" + "reference": "5cea2eebf11ca4bc6c2f20495c82a70a9b3d1613" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/socialite/zipball/1d19358c28e8951dde6e36603b89d8f09e6cfbfd", - "reference": "1d19358c28e8951dde6e36603b89d8f09e6cfbfd", + "url": "https://api.github.com/repos/laravel/socialite/zipball/5cea2eebf11ca4bc6c2f20495c82a70a9b3d1613", + "reference": "5cea2eebf11ca4bc6c2f20495c82a70a9b3d1613", "shasum": "" }, "require": { "ext-json": "*", - "firebase/php-jwt": "^6.4", + "firebase/php-jwt": "^6.4|^7.0", "guzzlehttp/guzzle": "^6.0|^7.0", "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", "illuminate/http": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", @@ -3341,20 +3492,20 @@ "issues": "https://github.com/laravel/socialite/issues", "source": "https://github.com/laravel/socialite" }, - "time": "2025-12-09T15:37:06+00:00" + "time": "2026-01-10T16:07:28+00:00" }, { "name": "laravel/tinker", - "version": "v2.10.2", + "version": "v2.11.0", "source": { "type": "git", "url": "https://github.com/laravel/tinker.git", - "reference": "3bcb5f62d6f837e0f093a601e26badafb127bd4c" + "reference": "3d34b97c9a1747a81a3fde90482c092bd8b66468" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/tinker/zipball/3bcb5f62d6f837e0f093a601e26badafb127bd4c", - "reference": "3bcb5f62d6f837e0f093a601e26badafb127bd4c", + "url": "https://api.github.com/repos/laravel/tinker/zipball/3d34b97c9a1747a81a3fde90482c092bd8b66468", + "reference": "3d34b97c9a1747a81a3fde90482c092bd8b66468", "shasum": "" }, "require": { @@ -3363,7 +3514,7 @@ "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", "php": "^7.2.5|^8.0", "psy/psysh": "^0.11.1|^0.12.0", - "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0" + "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0|^8.0" }, "require-dev": { "mockery/mockery": "~1.3.3|^1.4.2", @@ -3405,22 +3556,22 @@ ], "support": { "issues": "https://github.com/laravel/tinker/issues", - "source": "https://github.com/laravel/tinker/tree/v2.10.2" + "source": "https://github.com/laravel/tinker/tree/v2.11.0" }, - "time": "2025-11-20T16:29:12+00:00" + "time": "2025-12-19T19:16:45+00:00" }, { "name": "league/commonmark", - "version": "2.7.1", + "version": "2.8.0", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "10732241927d3971d28e7ea7b5712721fa2296ca" + "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/10732241927d3971d28e7ea7b5712721fa2296ca", - "reference": "10732241927d3971d28e7ea7b5712721fa2296ca", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/4efa10c1e56488e658d10adf7b7b7dcd19940bfb", + "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb", "shasum": "" }, "require": { @@ -3457,7 +3608,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "2.8-dev" + "dev-main": "2.9-dev" } }, "autoload": { @@ -3514,7 +3665,7 @@ "type": "tidelift" } ], - "time": "2025-07-20T12:47:49+00:00" + "time": "2025-11-26T21:48:24+00:00" }, { "name": "league/config", @@ -3600,16 +3751,16 @@ }, { "name": "league/csv", - "version": "9.27.1", + "version": "9.28.0", "source": { "type": "git", "url": "https://github.com/thephpleague/csv.git", - "reference": "26de738b8fccf785397d05ee2fc07b6cd8749797" + "reference": "6582ace29ae09ba5b07049d40ea13eb19c8b5073" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/csv/zipball/26de738b8fccf785397d05ee2fc07b6cd8749797", - "reference": "26de738b8fccf785397d05ee2fc07b6cd8749797", + "url": "https://api.github.com/repos/thephpleague/csv/zipball/6582ace29ae09ba5b07049d40ea13eb19c8b5073", + "reference": "6582ace29ae09ba5b07049d40ea13eb19c8b5073", "shasum": "" }, "require": { @@ -3619,14 +3770,14 @@ "require-dev": { "ext-dom": "*", "ext-xdebug": "*", - "friendsofphp/php-cs-fixer": "^3.75.0", - "phpbench/phpbench": "^1.4.1", - "phpstan/phpstan": "^1.12.27", + "friendsofphp/php-cs-fixer": "^3.92.3", + "phpbench/phpbench": "^1.4.3", + "phpstan/phpstan": "^1.12.32", "phpstan/phpstan-deprecation-rules": "^1.2.1", "phpstan/phpstan-phpunit": "^1.4.2", "phpstan/phpstan-strict-rules": "^1.6.2", - "phpunit/phpunit": "^10.5.16 || ^11.5.22 || ^12.3.6", - "symfony/var-dumper": "^6.4.8 || ^7.3.0" + "phpunit/phpunit": "^10.5.16 || ^11.5.22 || ^12.5.4", + "symfony/var-dumper": "^6.4.8 || ^7.4.0 || ^8.0" }, "suggest": { "ext-dom": "Required to use the XMLConverter and the HTMLConverter classes", @@ -3687,7 +3838,7 @@ "type": "github" } ], - "time": "2025-10-25T08:35:20+00:00" + "time": "2025-12-27T15:18:42+00:00" }, { "name": "league/flysystem", @@ -3772,6 +3923,61 @@ }, "time": "2025-11-10T17:13:11+00:00" }, + { + "name": "league/flysystem-aws-s3-v3", + "version": "3.30.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git", + "reference": "d286e896083bed3190574b8b088b557b59eb66f5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/d286e896083bed3190574b8b088b557b59eb66f5", + "reference": "d286e896083bed3190574b8b088b557b59eb66f5", + "shasum": "" + }, + "require": { + "aws/aws-sdk-php": "^3.295.10", + "league/flysystem": "^3.10.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\AwsS3V3\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "AWS S3 filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "aws", + "file", + "files", + "filesystem", + "s3", + "storage" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.30.1" + }, + "time": "2025-10-20T15:27:33+00:00" + }, { "name": "league/flysystem-local", "version": "3.30.2", @@ -3955,20 +4161,20 @@ }, { "name": "league/uri", - "version": "7.6.0", + "version": "7.8.0", "source": { "type": "git", "url": "https://github.com/thephpleague/uri.git", - "reference": "f625804987a0a9112d954f9209d91fec52182344" + "reference": "4436c6ec8d458e4244448b069cc572d088230b76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri/zipball/f625804987a0a9112d954f9209d91fec52182344", - "reference": "f625804987a0a9112d954f9209d91fec52182344", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/4436c6ec8d458e4244448b069cc572d088230b76", + "reference": "4436c6ec8d458e4244448b069cc572d088230b76", "shasum": "" }, "require": { - "league/uri-interfaces": "^7.6", + "league/uri-interfaces": "^7.8", "php": "^8.1", "psr/http-factory": "^1" }, @@ -3982,11 +4188,11 @@ "ext-gmp": "to improve IPV4 host parsing", "ext-intl": "to handle IDN host with the best performance", "ext-uri": "to use the PHP native URI class", - "jeremykendall/php-domain-parser": "to resolve Public Suffix and Top Level Domain", - "league/uri-components": "Needed to easily manipulate URI objects components", - "league/uri-polyfill": "Needed to backport the PHP URI extension for older versions of PHP", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", "php-64bit": "to improve IPV4 host parsing", - "rowbot/url": "to handle WHATWG URL", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" }, "type": "library", @@ -4041,7 +4247,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri/tree/7.6.0" + "source": "https://github.com/thephpleague/uri/tree/7.8.0" }, "funding": [ { @@ -4049,20 +4255,20 @@ "type": "github" } ], - "time": "2025-11-18T12:17:23+00:00" + "time": "2026-01-14T17:24:56+00:00" }, { "name": "league/uri-interfaces", - "version": "7.6.0", + "version": "7.8.0", "source": { "type": "git", "url": "https://github.com/thephpleague/uri-interfaces.git", - "reference": "ccbfb51c0445298e7e0b7f4481b942f589665368" + "reference": "c5c5cd056110fc8afaba29fa6b72a43ced42acd4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/ccbfb51c0445298e7e0b7f4481b942f589665368", - "reference": "ccbfb51c0445298e7e0b7f4481b942f589665368", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/c5c5cd056110fc8afaba29fa6b72a43ced42acd4", + "reference": "c5c5cd056110fc8afaba29fa6b72a43ced42acd4", "shasum": "" }, "require": { @@ -4075,7 +4281,7 @@ "ext-gmp": "to improve IPV4 host parsing", "ext-intl": "to handle IDN host with the best performance", "php-64bit": "to improve IPV4 host parsing", - "rowbot/url": "to handle WHATWG URL", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" }, "type": "library", @@ -4125,7 +4331,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri-interfaces/tree/7.6.0" + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.0" }, "funding": [ { @@ -4133,20 +4339,20 @@ "type": "github" } ], - "time": "2025-11-18T12:17:23+00:00" + "time": "2026-01-15T06:54:53+00:00" }, { "name": "livewire/livewire", - "version": "v3.7.0", + "version": "v3.7.4", "source": { "type": "git", "url": "https://github.com/livewire/livewire.git", - "reference": "f5f9efe6d5a7059116bd695a89d95ceedf33f3cb" + "reference": "5a8dffd4c0ab357ff7ed5b39e7c2453d962a68e0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/livewire/zipball/f5f9efe6d5a7059116bd695a89d95ceedf33f3cb", - "reference": "f5f9efe6d5a7059116bd695a89d95ceedf33f3cb", + "url": "https://api.github.com/repos/livewire/livewire/zipball/5a8dffd4c0ab357ff7ed5b39e7c2453d962a68e0", + "reference": "5a8dffd4c0ab357ff7ed5b39e7c2453d962a68e0", "shasum": "" }, "require": { @@ -4201,7 +4407,7 @@ "description": "A front-end framework for Laravel.", "support": { "issues": "https://github.com/livewire/livewire/issues", - "source": "https://github.com/livewire/livewire/tree/v3.7.0" + "source": "https://github.com/livewire/livewire/tree/v3.7.4" }, "funding": [ { @@ -4209,7 +4415,7 @@ "type": "github" } ], - "time": "2025-11-12T17:58:16+00:00" + "time": "2026-01-13T09:37:21+00:00" }, { "name": "masterminds/html5", @@ -4474,16 +4680,16 @@ }, { "name": "monolog/monolog", - "version": "3.9.0", + "version": "3.10.0", "source": { "type": "git", "url": "https://github.com/Seldaek/monolog.git", - "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6" + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/10d85740180ecba7896c87e06a166e0c95a0e3b6", - "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", "shasum": "" }, "require": { @@ -4501,7 +4707,7 @@ "graylog2/gelf-php": "^1.4.2 || ^2.0", "guzzlehttp/guzzle": "^7.4.5", "guzzlehttp/psr7": "^2.2", - "mongodb/mongodb": "^1.8", + "mongodb/mongodb": "^1.8 || ^2.0", "php-amqplib/php-amqplib": "~2.4 || ^3", "php-console/php-console": "^3.1.8", "phpstan/phpstan": "^2", @@ -4561,7 +4767,7 @@ ], "support": { "issues": "https://github.com/Seldaek/monolog/issues", - "source": "https://github.com/Seldaek/monolog/tree/3.9.0" + "source": "https://github.com/Seldaek/monolog/tree/3.10.0" }, "funding": [ { @@ -4573,7 +4779,73 @@ "type": "tidelift" } ], - "time": "2025-03-24T10:02:05+00:00" + "time": "2026-01-02T08:56:05+00:00" + }, + { + "name": "mtdowling/jmespath.php", + "version": "2.8.0", + "source": { + "type": "git", + "url": "https://github.com/jmespath/jmespath.php.git", + "reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/a2a865e05d5f420b50cc2f85bb78d565db12a6bc", + "reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-mbstring": "^1.17" + }, + "require-dev": { + "composer/xdebug-handler": "^3.0.3", + "phpunit/phpunit": "^8.5.33" + }, + "bin": [ + "bin/jp.php" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.8-dev" + } + }, + "autoload": { + "files": [ + "src/JmesPath.php" + ], + "psr-4": { + "JmesPath\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "Declaratively specify how to extract elements from a JSON document", + "keywords": [ + "json", + "jsonpath" + ], + "support": { + "issues": "https://github.com/jmespath/jmespath.php/issues", + "source": "https://github.com/jmespath/jmespath.php/tree/2.8.0" + }, + "time": "2024-09-04T18:46:31+00:00" }, { "name": "nesbot/carbon", @@ -4749,20 +5021,20 @@ }, { "name": "nette/utils", - "version": "v4.0.9", + "version": "v4.1.1", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "505a30ad386daa5211f08a318e47015b501cad30" + "reference": "c99059c0315591f1a0db7ad6002000288ab8dc72" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/505a30ad386daa5211f08a318e47015b501cad30", - "reference": "505a30ad386daa5211f08a318e47015b501cad30", + "url": "https://api.github.com/repos/nette/utils/zipball/c99059c0315591f1a0db7ad6002000288ab8dc72", + "reference": "c99059c0315591f1a0db7ad6002000288ab8dc72", "shasum": "" }, "require": { - "php": "8.0 - 8.5" + "php": "8.2 - 8.5" }, "conflict": { "nette/finder": "<3", @@ -4785,7 +5057,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-master": "4.1-dev" } }, "autoload": { @@ -4832,22 +5104,22 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.0.9" + "source": "https://github.com/nette/utils/tree/v4.1.1" }, - "time": "2025-10-31T00:45:47+00:00" + "time": "2025-12-22T12:14:32+00:00" }, { "name": "nikic/php-parser", - "version": "v5.6.2", + "version": "v5.7.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "3a454ca033b9e06b63282ce19562e892747449bb" + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/3a454ca033b9e06b63282ce19562e892747449bb", - "reference": "3a454ca033b9e06b63282ce19562e892747449bb", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", "shasum": "" }, "require": { @@ -4890,9 +5162,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.2" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" }, - "time": "2025-10-21T19:32:17+00:00" + "time": "2025-12-06T11:56:16+00:00" }, { "name": "nunomaduro/termwind", @@ -5324,16 +5596,16 @@ }, { "name": "phpoption/phpoption", - "version": "1.9.4", + "version": "1.9.5", "source": { "type": "git", "url": "https://github.com/schmittjoh/php-option.git", - "reference": "638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d" + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d", - "reference": "638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", "shasum": "" }, "require": { @@ -5383,7 +5655,7 @@ ], "support": { "issues": "https://github.com/schmittjoh/php-option/issues", - "source": "https://github.com/schmittjoh/php-option/tree/1.9.4" + "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" }, "funding": [ { @@ -5395,7 +5667,7 @@ "type": "tidelift" } ], - "time": "2025-08-21T11:53:16+00:00" + "time": "2025-12-27T19:41:33+00:00" }, { "name": "phpseclib/phpseclib", @@ -5970,16 +6242,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.14", + "version": "v0.12.18", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "95c29b3756a23855a30566b745d218bee690bef2" + "reference": "ddff0ac01beddc251786fe70367cd8bbdb258196" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/95c29b3756a23855a30566b745d218bee690bef2", - "reference": "95c29b3756a23855a30566b745d218bee690bef2", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ddff0ac01beddc251786fe70367cd8bbdb258196", + "reference": "ddff0ac01beddc251786fe70367cd8bbdb258196", "shasum": "" }, "require": { @@ -5987,8 +6259,8 @@ "ext-tokenizer": "*", "nikic/php-parser": "^5.0 || ^4.0", "php": "^8.0 || ^7.4", - "symfony/console": "^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", - "symfony/var-dumper": "^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" + "symfony/console": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", + "symfony/var-dumper": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" }, "conflict": { "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" @@ -6043,9 +6315,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.14" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.18" }, - "time": "2025-10-27T17:15:31+00:00" + "time": "2025-12-17T14:35:46+00:00" }, { "name": "ralouphie/getallheaders", @@ -6169,20 +6441,20 @@ }, { "name": "ramsey/uuid", - "version": "4.9.1", + "version": "4.9.2", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440" + "reference": "8429c78ca35a09f27565311b98101e2826affde0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/81f941f6f729b1e3ceea61d9d014f8b6c6800440", - "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0", + "reference": "8429c78ca35a09f27565311b98101e2826affde0", "shasum": "" }, "require": { - "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -6241,9 +6513,9 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.1" + "source": "https://github.com/ramsey/uuid/tree/4.9.2" }, - "time": "2025-09-04T20:59:21+00:00" + "time": "2025-12-14T04:43:48+00:00" }, { "name": "ryangjchandler/blade-capture-directive", @@ -6325,16 +6597,16 @@ }, { "name": "sentry/sentry", - "version": "4.18.1", + "version": "4.19.1", "source": { "type": "git", "url": "https://github.com/getsentry/sentry-php.git", - "reference": "04dcf20b39742b731b676f8b8d4f02d1db488af8" + "reference": "1c21d60bebe67c0122335bd3fe977990435af0a3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/04dcf20b39742b731b676f8b8d4f02d1db488af8", - "reference": "04dcf20b39742b731b676f8b8d4f02d1db488af8", + "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/1c21d60bebe67c0122335bd3fe977990435af0a3", + "reference": "1c21d60bebe67c0122335bd3fe977990435af0a3", "shasum": "" }, "require": { @@ -6397,7 +6669,7 @@ ], "support": { "issues": "https://github.com/getsentry/sentry-php/issues", - "source": "https://github.com/getsentry/sentry-php/tree/4.18.1" + "source": "https://github.com/getsentry/sentry-php/tree/4.19.1" }, "funding": [ { @@ -6409,28 +6681,28 @@ "type": "custom" } ], - "time": "2025-11-11T09:34:53+00:00" + "time": "2025-12-02T15:57:41+00:00" }, { "name": "sentry/sentry-laravel", - "version": "4.19.0", + "version": "4.20.1", "source": { "type": "git", "url": "https://github.com/getsentry/sentry-laravel.git", - "reference": "7fdffd57e8fff0a6f9a18d9a83f32e960af63e3f" + "reference": "503853fa7ee74b34b64e76f1373db86cd11afe72" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/getsentry/sentry-laravel/zipball/7fdffd57e8fff0a6f9a18d9a83f32e960af63e3f", - "reference": "7fdffd57e8fff0a6f9a18d9a83f32e960af63e3f", + "url": "https://api.github.com/repos/getsentry/sentry-laravel/zipball/503853fa7ee74b34b64e76f1373db86cd11afe72", + "reference": "503853fa7ee74b34b64e76f1373db86cd11afe72", "shasum": "" }, "require": { "illuminate/support": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0", "nyholm/psr7": "^1.0", "php": "^7.2 | ^8.0", - "sentry/sentry": "^4.18.0", - "symfony/psr-http-message-bridge": "^1.0 | ^2.0 | ^6.0 | ^7.0" + "sentry/sentry": "^4.19.0", + "symfony/psr-http-message-bridge": "^1.0 | ^2.0 | ^6.0 | ^7.0 | ^8.0" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.11", @@ -6487,7 +6759,7 @@ ], "support": { "issues": "https://github.com/getsentry/sentry-laravel/issues", - "source": "https://github.com/getsentry/sentry-laravel/tree/4.19.0" + "source": "https://github.com/getsentry/sentry-laravel/tree/4.20.1" }, "funding": [ { @@ -6499,7 +6771,7 @@ "type": "custom" } ], - "time": "2025-11-11T09:01:14+00:00" + "time": "2026-01-07T08:53:19+00:00" }, { "name": "simonhamp/the-og", @@ -7108,16 +7380,16 @@ }, { "name": "symfony/console", - "version": "v6.4.27", + "version": "v6.4.31", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "13d3176cf8ad8ced24202844e9f95af11e2959fc" + "reference": "f9f8a889f54c264f9abac3fc0f7a371ffca51997" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/13d3176cf8ad8ced24202844e9f95af11e2959fc", - "reference": "13d3176cf8ad8ced24202844e9f95af11e2959fc", + "url": "https://api.github.com/repos/symfony/console/zipball/f9f8a889f54c264f9abac3fc0f7a371ffca51997", + "reference": "f9f8a889f54c264f9abac3fc0f7a371ffca51997", "shasum": "" }, "require": { @@ -7182,7 +7454,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.27" + "source": "https://github.com/symfony/console/tree/v6.4.31" }, "funding": [ { @@ -7202,24 +7474,24 @@ "type": "tidelift" } ], - "time": "2025-10-06T10:25:16+00:00" + "time": "2025-12-22T08:30:34+00:00" }, { "name": "symfony/css-selector", - "version": "v7.3.6", + "version": "v8.0.0", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "84321188c4754e64273b46b406081ad9b18e8614" + "reference": "6225bd458c53ecdee056214cb4a2ffaf58bd592b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/84321188c4754e64273b46b406081ad9b18e8614", - "reference": "84321188c4754e64273b46b406081ad9b18e8614", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/6225bd458c53ecdee056214cb4a2ffaf58bd592b", + "reference": "6225bd458c53ecdee056214cb4a2ffaf58bd592b", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.4" }, "type": "library", "autoload": { @@ -7251,7 +7523,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v7.3.6" + "source": "https://github.com/symfony/css-selector/tree/v8.0.0" }, "funding": [ { @@ -7271,7 +7543,7 @@ "type": "tidelift" } ], - "time": "2025-10-29T17:24:25+00:00" + "time": "2025-10-30T14:17:19+00:00" }, { "name": "symfony/deprecation-contracts", @@ -7421,16 +7693,16 @@ }, { "name": "symfony/event-dispatcher", - "version": "v7.3.3", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "b7dc69e71de420ac04bc9ab830cf3ffebba48191" + "reference": "9dddcddff1ef974ad87b3708e4b442dc38b2261d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/b7dc69e71de420ac04bc9ab830cf3ffebba48191", - "reference": "b7dc69e71de420ac04bc9ab830cf3ffebba48191", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/9dddcddff1ef974ad87b3708e4b442dc38b2261d", + "reference": "9dddcddff1ef974ad87b3708e4b442dc38b2261d", "shasum": "" }, "require": { @@ -7447,13 +7719,14 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/error-handler": "^6.4|^7.0", - "symfony/expression-language": "^6.4|^7.0", - "symfony/http-foundation": "^6.4|^7.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/stopwatch": "^6.4|^7.0" + "symfony/stopwatch": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -7481,7 +7754,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v7.3.3" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.0" }, "funding": [ { @@ -7501,7 +7774,7 @@ "type": "tidelift" } ], - "time": "2025-08-13T11:49:31+00:00" + "time": "2025-10-28T09:38:46+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -7579,18 +7852,88 @@ ], "time": "2024-09-25T14:21:43+00:00" }, + { + "name": "symfony/filesystem", + "version": "v8.0.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "d937d400b980523dc9ee946bb69972b5e619058d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/d937d400b980523dc9ee946bb69972b5e619058d", + "reference": "d937d400b980523dc9ee946bb69972b5e619058d", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v8.0.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-12-01T09:13:36+00:00" + }, { "name": "symfony/finder", - "version": "v6.4.27", + "version": "v6.4.31", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "a1b6aa435d2fba50793b994a839c32b6064f063b" + "reference": "5547f2e1f0ca8e2e7abe490156b62da778cfbe2b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/a1b6aa435d2fba50793b994a839c32b6064f063b", - "reference": "a1b6aa435d2fba50793b994a839c32b6064f063b", + "url": "https://api.github.com/repos/symfony/finder/zipball/5547f2e1f0ca8e2e7abe490156b62da778cfbe2b", + "reference": "5547f2e1f0ca8e2e7abe490156b62da778cfbe2b", "shasum": "" }, "require": { @@ -7625,7 +7968,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v6.4.27" + "source": "https://github.com/symfony/finder/tree/v6.4.31" }, "funding": [ { @@ -7645,27 +7988,28 @@ "type": "tidelift" } ], - "time": "2025-10-15T18:32:00+00:00" + "time": "2025-12-11T14:52:17+00:00" }, { "name": "symfony/html-sanitizer", - "version": "v7.3.6", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/html-sanitizer.git", - "reference": "3855e827adb1b675adcb98ad7f92681e293f2d77" + "reference": "5b0bbcc3600030b535dd0b17a0e8c56243f96d7f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/3855e827adb1b675adcb98ad7f92681e293f2d77", - "reference": "3855e827adb1b675adcb98ad7f92681e293f2d77", + "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/5b0bbcc3600030b535dd0b17a0e8c56243f96d7f", + "reference": "5b0bbcc3600030b535dd0b17a0e8c56243f96d7f", "shasum": "" }, "require": { "ext-dom": "*", "league/uri": "^6.5|^7.0", "masterminds/html5": "^2.7.2", - "php": ">=8.2" + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" }, "type": "library", "autoload": { @@ -7698,7 +8042,7 @@ "sanitizer" ], "support": { - "source": "https://github.com/symfony/html-sanitizer/tree/v7.3.6" + "source": "https://github.com/symfony/html-sanitizer/tree/v7.4.0" }, "funding": [ { @@ -7718,20 +8062,20 @@ "type": "tidelift" } ], - "time": "2025-10-30T13:22:58+00:00" + "time": "2025-10-30T13:39:42+00:00" }, { "name": "symfony/http-client", - "version": "v7.3.6", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "3c0a55a2c8e21e30a37022801c11c7ab5a6cb2de" + "reference": "d01dfac1e0dc99f18da48b18101c23ce57929616" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/3c0a55a2c8e21e30a37022801c11c7ab5a6cb2de", - "reference": "3c0a55a2c8e21e30a37022801c11c7ab5a6cb2de", + "url": "https://api.github.com/repos/symfony/http-client/zipball/d01dfac1e0dc99f18da48b18101c23ce57929616", + "reference": "d01dfac1e0dc99f18da48b18101c23ce57929616", "shasum": "" }, "require": { @@ -7762,12 +8106,13 @@ "php-http/httplug": "^1.0|^2.0", "psr/http-client": "^1.0", "symfony/amphp-http-client-meta": "^1.0|^2.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/messenger": "^6.4|^7.0", - "symfony/process": "^6.4|^7.0", - "symfony/rate-limiter": "^6.4|^7.0", - "symfony/stopwatch": "^6.4|^7.0" + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -7798,7 +8143,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.3.6" + "source": "https://github.com/symfony/http-client/tree/v7.4.3" }, "funding": [ { @@ -7818,7 +8163,7 @@ "type": "tidelift" } ], - "time": "2025-11-05T17:41:46+00:00" + "time": "2025-12-23T14:50:43+00:00" }, { "name": "symfony/http-client-contracts", @@ -7900,16 +8245,16 @@ }, { "name": "symfony/http-foundation", - "version": "v6.4.29", + "version": "v6.4.31", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "b03d11e015552a315714c127d8d1e0f9e970ec88" + "reference": "a35ee6f47e4775179704d7877a8b0da3cb09241a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/b03d11e015552a315714c127d8d1e0f9e970ec88", - "reference": "b03d11e015552a315714c127d8d1e0f9e970ec88", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/a35ee6f47e4775179704d7877a8b0da3cb09241a", + "reference": "a35ee6f47e4775179704d7877a8b0da3cb09241a", "shasum": "" }, "require": { @@ -7957,7 +8302,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v6.4.29" + "source": "https://github.com/symfony/http-foundation/tree/v6.4.31" }, "funding": [ { @@ -7977,20 +8322,20 @@ "type": "tidelift" } ], - "time": "2025-11-08T16:40:12+00:00" + "time": "2025-12-17T10:10:57+00:00" }, { "name": "symfony/http-kernel", - "version": "v6.4.29", + "version": "v6.4.31", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "18818b48f54c1d2bd92b41d82d8345af50b15658" + "reference": "16b0d46d8e11f480345c15b229cfc827a8a0f731" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/18818b48f54c1d2bd92b41d82d8345af50b15658", - "reference": "18818b48f54c1d2bd92b41d82d8345af50b15658", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/16b0d46d8e11f480345c15b229cfc827a8a0f731", + "reference": "16b0d46d8e11f480345c15b229cfc827a8a0f731", "shasum": "" }, "require": { @@ -8075,7 +8420,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v6.4.29" + "source": "https://github.com/symfony/http-kernel/tree/v6.4.31" }, "funding": [ { @@ -8095,20 +8440,20 @@ "type": "tidelift" } ], - "time": "2025-11-12T11:22:59+00:00" + "time": "2025-12-31T08:27:27+00:00" }, { "name": "symfony/mailer", - "version": "v6.4.27", + "version": "v6.4.31", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "2f096718ed718996551f66e3a24e12b2ed027f95" + "reference": "8835f93333474780fda1b987cae37e33c3e026ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/2f096718ed718996551f66e3a24e12b2ed027f95", - "reference": "2f096718ed718996551f66e3a24e12b2ed027f95", + "url": "https://api.github.com/repos/symfony/mailer/zipball/8835f93333474780fda1b987cae37e33c3e026ca", + "reference": "8835f93333474780fda1b987cae37e33c3e026ca", "shasum": "" }, "require": { @@ -8159,7 +8504,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v6.4.27" + "source": "https://github.com/symfony/mailer/tree/v6.4.31" }, "funding": [ { @@ -8179,7 +8524,7 @@ "type": "tidelift" } ], - "time": "2025-10-24T13:29:09+00:00" + "time": "2025-12-12T07:33:25+00:00" }, { "name": "symfony/mailgun-mailer", @@ -8252,16 +8597,16 @@ }, { "name": "symfony/mime", - "version": "v6.4.26", + "version": "v6.4.30", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "61ab9681cdfe315071eb4fa79b6ad6ab030a9235" + "reference": "69aeef5d2692bb7c18ce133b09f67b27260b7acf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/61ab9681cdfe315071eb4fa79b6ad6ab030a9235", - "reference": "61ab9681cdfe315071eb4fa79b6ad6ab030a9235", + "url": "https://api.github.com/repos/symfony/mime/zipball/69aeef5d2692bb7c18ce133b09f67b27260b7acf", + "reference": "69aeef5d2692bb7c18ce133b09f67b27260b7acf", "shasum": "" }, "require": { @@ -8317,7 +8662,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v6.4.26" + "source": "https://github.com/symfony/mime/tree/v6.4.30" }, "funding": [ { @@ -8337,24 +8682,24 @@ "type": "tidelift" } ], - "time": "2025-09-16T08:22:30+00:00" + "time": "2025-11-16T09:57:53+00:00" }, { "name": "symfony/options-resolver", - "version": "v7.3.3", + "version": "v8.0.0", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "0ff2f5c3df08a395232bbc3c2eb7e84912df911d" + "reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/0ff2f5c3df08a395232bbc3c2eb7e84912df911d", - "reference": "0ff2f5c3df08a395232bbc3c2eb7e84912df911d", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/d2b592535ffa6600c265a3893a7f7fd2bad82dd7", + "reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4", "symfony/deprecation-contracts": "^2.5|^3" }, "type": "library", @@ -8388,7 +8733,7 @@ "options" ], "support": { - "source": "https://github.com/symfony/options-resolver/tree/v7.3.3" + "source": "https://github.com/symfony/options-resolver/tree/v8.0.0" }, "funding": [ { @@ -8408,7 +8753,7 @@ "type": "tidelift" } ], - "time": "2025-08-05T10:16:07+00:00" + "time": "2025-11-12T15:55:31+00:00" }, { "name": "symfony/polyfill-ctype", @@ -9249,16 +9594,16 @@ }, { "name": "symfony/process", - "version": "v6.4.26", + "version": "v6.4.31", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "48bad913268c8cafabbf7034b39c8bb24fbc5ab8" + "reference": "8541b7308fca001320e90bca8a73a28aa5604a6e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/48bad913268c8cafabbf7034b39c8bb24fbc5ab8", - "reference": "48bad913268c8cafabbf7034b39c8bb24fbc5ab8", + "url": "https://api.github.com/repos/symfony/process/zipball/8541b7308fca001320e90bca8a73a28aa5604a6e", + "reference": "8541b7308fca001320e90bca8a73a28aa5604a6e", "shasum": "" }, "require": { @@ -9290,7 +9635,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v6.4.26" + "source": "https://github.com/symfony/process/tree/v6.4.31" }, "funding": [ { @@ -9310,26 +9655,26 @@ "type": "tidelift" } ], - "time": "2025-09-11T09:57:09+00:00" + "time": "2025-12-15T19:26:35+00:00" }, { "name": "symfony/psr-http-message-bridge", - "version": "v7.3.0", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/psr-http-message-bridge.git", - "reference": "03f2f72319e7acaf2a9f6fcbe30ef17eec51594f" + "reference": "0101ff8bd0506703b045b1670960302d302a726c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/03f2f72319e7acaf2a9f6fcbe30ef17eec51594f", - "reference": "03f2f72319e7acaf2a9f6fcbe30ef17eec51594f", + "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/0101ff8bd0506703b045b1670960302d302a726c", + "reference": "0101ff8bd0506703b045b1670960302d302a726c", "shasum": "" }, "require": { "php": ">=8.2", "psr/http-message": "^1.0|^2.0", - "symfony/http-foundation": "^6.4|^7.0" + "symfony/http-foundation": "^6.4|^7.0|^8.0" }, "conflict": { "php-http/discovery": "<1.15", @@ -9339,11 +9684,12 @@ "nyholm/psr7": "^1.1", "php-http/discovery": "^1.15", "psr/log": "^1.1.4|^2|^3", - "symfony/browser-kit": "^6.4|^7.0", - "symfony/config": "^6.4|^7.0", - "symfony/event-dispatcher": "^6.4|^7.0", - "symfony/framework-bundle": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0" + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4.13|^7.1.6|^8.0", + "symfony/http-kernel": "^6.4.13|^7.1.6|^8.0", + "symfony/runtime": "^6.4.13|^7.1.6|^8.0" }, "type": "symfony-bridge", "autoload": { @@ -9377,7 +9723,7 @@ "psr-7" ], "support": { - "source": "https://github.com/symfony/psr-http-message-bridge/tree/v7.3.0" + "source": "https://github.com/symfony/psr-http-message-bridge/tree/v7.4.0" }, "funding": [ { @@ -9388,25 +9734,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-26T08:57:56+00:00" + "time": "2025-11-13T08:38:49+00:00" }, { "name": "symfony/routing", - "version": "v6.4.28", + "version": "v6.4.30", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "ae064a6d9cf39507f9797658465a2ca702965fa8" + "reference": "ea50a13c2711eebcbb66b38ef6382e62e3262859" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/ae064a6d9cf39507f9797658465a2ca702965fa8", - "reference": "ae064a6d9cf39507f9797658465a2ca702965fa8", + "url": "https://api.github.com/repos/symfony/routing/zipball/ea50a13c2711eebcbb66b38ef6382e62e3262859", + "reference": "ea50a13c2711eebcbb66b38ef6382e62e3262859", "shasum": "" }, "require": { @@ -9460,7 +9810,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v6.4.28" + "source": "https://github.com/symfony/routing/tree/v6.4.30" }, "funding": [ { @@ -9480,7 +9830,7 @@ "type": "tidelift" } ], - "time": "2025-10-31T16:43:05+00:00" + "time": "2025-11-22T09:51:35+00:00" }, { "name": "symfony/service-contracts", @@ -9571,22 +9921,23 @@ }, { "name": "symfony/string", - "version": "v7.3.4", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "f96476035142921000338bad71e5247fbc138872" + "reference": "d50e862cb0a0e0886f73ca1f31b865efbb795003" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/f96476035142921000338bad71e5247fbc138872", - "reference": "f96476035142921000338bad71e5247fbc138872", + "url": "https://api.github.com/repos/symfony/string/zipball/d50e862cb0a0e0886f73ca1f31b865efbb795003", + "reference": "d50e862cb0a0e0886f73ca1f31b865efbb795003", "shasum": "" }, "require": { "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-intl-grapheme": "~1.0", + "symfony/polyfill-intl-grapheme": "~1.33", "symfony/polyfill-intl-normalizer": "~1.0", "symfony/polyfill-mbstring": "~1.0" }, @@ -9594,11 +9945,11 @@ "symfony/translation-contracts": "<2.5" }, "require-dev": { - "symfony/emoji": "^7.1", - "symfony/http-client": "^6.4|^7.0", - "symfony/intl": "^6.4|^7.0", + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^6.4|^7.0" + "symfony/var-exporter": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -9637,7 +9988,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.3.4" + "source": "https://github.com/symfony/string/tree/v7.4.0" }, "funding": [ { @@ -9657,20 +10008,20 @@ "type": "tidelift" } ], - "time": "2025-09-11T14:36:48+00:00" + "time": "2025-11-27T13:27:24+00:00" }, { "name": "symfony/translation", - "version": "v6.4.26", + "version": "v6.4.31", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "c8559fe25c7ee7aa9d28f228903a46db008156a4" + "reference": "81579408ecf7dc5aa2d8462a6d5c3a430a80e6f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/c8559fe25c7ee7aa9d28f228903a46db008156a4", - "reference": "c8559fe25c7ee7aa9d28f228903a46db008156a4", + "url": "https://api.github.com/repos/symfony/translation/zipball/81579408ecf7dc5aa2d8462a6d5c3a430a80e6f2", + "reference": "81579408ecf7dc5aa2d8462a6d5c3a430a80e6f2", "shasum": "" }, "require": { @@ -9736,7 +10087,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v6.4.26" + "source": "https://github.com/symfony/translation/tree/v6.4.31" }, "funding": [ { @@ -9756,7 +10107,7 @@ "type": "tidelift" } ], - "time": "2025-09-05T18:17:25+00:00" + "time": "2025-12-18T11:37:55+00:00" }, { "name": "symfony/translation-contracts", @@ -10008,28 +10359,28 @@ }, { "name": "symfony/yaml", - "version": "v7.3.5", + "version": "v7.4.1", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "90208e2fc6f68f613eae7ca25a2458a931b1bacc" + "reference": "24dd4de28d2e3988b311751ac49e684d783e2345" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/90208e2fc6f68f613eae7ca25a2458a931b1bacc", - "reference": "90208e2fc6f68f613eae7ca25a2458a931b1bacc", + "url": "https://api.github.com/repos/symfony/yaml/zipball/24dd4de28d2e3988b311751ac49e684d783e2345", + "reference": "24dd4de28d2e3988b311751ac49e684d783e2345", "shasum": "" }, "require": { "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-ctype": "^1.8" }, "conflict": { "symfony/console": "<6.4" }, "require-dev": { - "symfony/console": "^6.4|^7.0" + "symfony/console": "^6.4|^7.0|^8.0" }, "bin": [ "Resources/bin/yaml-lint" @@ -10060,7 +10411,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.3.5" + "source": "https://github.com/symfony/yaml/tree/v7.4.1" }, "funding": [ { @@ -10080,27 +10431,27 @@ "type": "tidelift" } ], - "time": "2025-09-27T09:00:46+00:00" + "time": "2025-12-04T18:11:45+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", - "version": "v2.3.0", + "version": "v2.4.0", "source": { "type": "git", "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", - "reference": "0d72ac1c00084279c1816675284073c5a337c20d" + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/0d72ac1c00084279c1816675284073c5a337c20d", - "reference": "0d72ac1c00084279c1816675284073c5a337c20d", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/f0292ccf0ec75843d65027214426b6b163b48b41", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "php": "^7.4 || ^8.0", - "symfony/css-selector": "^5.4 || ^6.0 || ^7.0" + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0" }, "require-dev": { "phpstan/phpstan": "^2.0", @@ -10133,9 +10484,9 @@ "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", "support": { "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", - "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.3.0" + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0" }, - "time": "2024-12-21T16:25:41+00:00" + "time": "2025-12-02T11:56:42+00:00" }, { "name": "torchlight/torchlight-commonmark", @@ -10259,26 +10610,26 @@ }, { "name": "vlucas/phpdotenv", - "version": "v5.6.2", + "version": "v5.6.3", "source": { "type": "git", "url": "https://github.com/vlucas/phpdotenv.git", - "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af" + "reference": "955e7815d677a3eaa7075231212f2110983adecc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/24ac4c74f91ee2c193fa1aaa5c249cb0822809af", - "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc", + "reference": "955e7815d677a3eaa7075231212f2110983adecc", "shasum": "" }, "require": { "ext-pcre": "*", - "graham-campbell/result-type": "^1.1.3", + "graham-campbell/result-type": "^1.1.4", "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.3", - "symfony/polyfill-ctype": "^1.24", - "symfony/polyfill-mbstring": "^1.24", - "symfony/polyfill-php80": "^1.24" + "phpoption/phpoption": "^1.9.5", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", @@ -10327,7 +10678,7 @@ ], "support": { "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.2" + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" }, "funding": [ { @@ -10339,7 +10690,7 @@ "type": "tidelift" } ], - "time": "2025-04-30T23:37:27+00:00" + "time": "2025-12-27T19:49:13+00:00" }, { "name": "voku/portable-ascii", @@ -10604,19 +10955,20 @@ }, { "name": "illuminate/json-schema", - "version": "v12.40.1", + "version": "v12.47.0", "source": { "type": "git", "url": "https://github.com/illuminate/json-schema.git", - "reference": "c2b383a6dd66f41208f1443801fe01934c63d030" + "reference": "d161f398dab36f08cf131997362bc2e3ecb0309a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/json-schema/zipball/c2b383a6dd66f41208f1443801fe01934c63d030", - "reference": "c2b383a6dd66f41208f1443801fe01934c63d030", + "url": "https://api.github.com/repos/illuminate/json-schema/zipball/d161f398dab36f08cf131997362bc2e3ecb0309a", + "reference": "d161f398dab36f08cf131997362bc2e3ecb0309a", "shasum": "" }, "require": { + "illuminate/contracts": "^10.50.0|^11.47.0|^12.40.2", "php": "^8.1" }, "type": "library", @@ -10646,38 +10998,38 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-11-03T22:27:03+00:00" + "time": "2025-11-28T18:45:48+00:00" }, { "name": "laravel/boost", - "version": "v1.8.2", + "version": "v1.8.10", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "cf57ba510df44e0d4ed2c1c91360477e92d7d644" + "reference": "aad8b2a423b0a886c2ce7ee92abbfde69992ff32" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/cf57ba510df44e0d4ed2c1c91360477e92d7d644", - "reference": "cf57ba510df44e0d4ed2c1c91360477e92d7d644", + "url": "https://api.github.com/repos/laravel/boost/zipball/aad8b2a423b0a886c2ce7ee92abbfde69992ff32", + "reference": "aad8b2a423b0a886c2ce7ee92abbfde69992ff32", "shasum": "" }, "require": { "guzzlehttp/guzzle": "^7.9", - "illuminate/console": "^10.49.0|^11.45.3|^12.28.1", - "illuminate/contracts": "^10.49.0|^11.45.3|^12.28.1", - "illuminate/routing": "^10.49.0|^11.45.3|^12.28.1", - "illuminate/support": "^10.49.0|^11.45.3|^12.28.1", - "laravel/mcp": "^0.3.4", + "illuminate/console": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/contracts": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/routing": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/support": "^10.49.0|^11.45.3|^12.41.1", + "laravel/mcp": "^0.5.1", "laravel/prompts": "0.1.25|^0.3.6", "laravel/roster": "^0.2.9", "php": "^8.1" }, "require-dev": { - "laravel/pint": "1.20", + "laravel/pint": "^1.20.0", "mockery/mockery": "^1.6.12", "orchestra/testbench": "^8.36.0|^9.15.0|^10.6", - "pestphp/pest": "^2.36.0|^3.8.4", + "pestphp/pest": "^2.36.0|^3.8.4|^4.1.5", "phpstan/phpstan": "^2.1.27", "rector/rector": "^2.1" }, @@ -10712,38 +11064,38 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2025-11-20T18:13:17+00:00" + "time": "2026-01-14T14:51:16+00:00" }, { "name": "laravel/mcp", - "version": "v0.3.4", + "version": "v0.5.2", "source": { "type": "git", "url": "https://github.com/laravel/mcp.git", - "reference": "0b86fb613a0df971cec89271c674a677c2cb4f77" + "reference": "b9bdd8d6f8b547c8733fe6826b1819341597ba3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/0b86fb613a0df971cec89271c674a677c2cb4f77", - "reference": "0b86fb613a0df971cec89271c674a677c2cb4f77", + "url": "https://api.github.com/repos/laravel/mcp/zipball/b9bdd8d6f8b547c8733fe6826b1819341597ba3c", + "reference": "b9bdd8d6f8b547c8733fe6826b1819341597ba3c", "shasum": "" }, "require": { "ext-json": "*", "ext-mbstring": "*", - "illuminate/console": "^10.49.0|^11.45.3|^12.28.1", - "illuminate/container": "^10.49.0|^11.45.3|^12.28.1", - "illuminate/contracts": "^10.49.0|^11.45.3|^12.28.1", - "illuminate/http": "^10.49.0|^11.45.3|^12.28.1", - "illuminate/json-schema": "^12.28.1", - "illuminate/routing": "^10.49.0|^11.45.3|^12.28.1", - "illuminate/support": "^10.49.0|^11.45.3|^12.28.1", - "illuminate/validation": "^10.49.0|^11.45.3|^12.28.1", + "illuminate/console": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/container": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/contracts": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/http": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/json-schema": "^12.41.1", + "illuminate/routing": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/support": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/validation": "^10.49.0|^11.45.3|^12.41.1", "php": "^8.1" }, "require-dev": { - "laravel/pint": "1.20.0", - "orchestra/testbench": "^8.36.0|^9.15.0|^10.6.0", + "laravel/pint": "^1.20", + "orchestra/testbench": "^8.36|^9.15|^10.8", "pestphp/pest": "^2.36.0|^3.8.4|^4.1.0", "phpstan/phpstan": "^2.1.27", "rector/rector": "^2.2.4" @@ -10785,20 +11137,20 @@ "issues": "https://github.com/laravel/mcp/issues", "source": "https://github.com/laravel/mcp" }, - "time": "2025-11-18T14:41:05+00:00" + "time": "2025-12-19T19:32:34+00:00" }, { "name": "laravel/pint", - "version": "v1.25.1", + "version": "v1.27.0", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "5016e263f95d97670d71b9a987bd8996ade6d8d9" + "reference": "c67b4195b75491e4dfc6b00b1c78b68d86f54c90" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/5016e263f95d97670d71b9a987bd8996ade6d8d9", - "reference": "5016e263f95d97670d71b9a987bd8996ade6d8d9", + "url": "https://api.github.com/repos/laravel/pint/zipball/c67b4195b75491e4dfc6b00b1c78b68d86f54c90", + "reference": "c67b4195b75491e4dfc6b00b1c78b68d86f54c90", "shasum": "" }, "require": { @@ -10809,13 +11161,13 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.87.2", - "illuminate/view": "^11.46.0", - "larastan/larastan": "^3.7.1", - "laravel-zero/framework": "^11.45.0", + "friendsofphp/php-cs-fixer": "^3.92.4", + "illuminate/view": "^12.44.0", + "larastan/larastan": "^3.8.1", + "laravel-zero/framework": "^12.0.4", "mockery/mockery": "^1.6.12", - "nunomaduro/termwind": "^2.3.1", - "pestphp/pest": "^2.36.0" + "nunomaduro/termwind": "^2.3.3", + "pestphp/pest": "^3.8.4" }, "bin": [ "builds/pint" @@ -10841,6 +11193,7 @@ "description": "An opinionated code formatter for PHP.", "homepage": "https://laravel.com", "keywords": [ + "dev", "format", "formatter", "lint", @@ -10851,7 +11204,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2025-09-19T02:57:12+00:00" + "time": "2026-01-05T16:49:17+00:00" }, { "name": "laravel/roster", @@ -10916,16 +11269,16 @@ }, { "name": "laravel/sail", - "version": "v1.48.1", + "version": "v1.52.0", "source": { "type": "git", "url": "https://github.com/laravel/sail.git", - "reference": "ef122b223f5fca5e5d88bda5127c846710886329" + "reference": "64ac7d8abb2dbcf2b76e61289451bae79066b0b3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/ef122b223f5fca5e5d88bda5127c846710886329", - "reference": "ef122b223f5fca5e5d88bda5127c846710886329", + "url": "https://api.github.com/repos/laravel/sail/zipball/64ac7d8abb2dbcf2b76e61289451bae79066b0b3", + "reference": "64ac7d8abb2dbcf2b76e61289451bae79066b0b3", "shasum": "" }, "require": { @@ -10975,7 +11328,7 @@ "issues": "https://github.com/laravel/sail/issues", "source": "https://github.com/laravel/sail" }, - "time": "2025-11-17T22:05:34+00:00" + "time": "2026-01-01T02:46:03+00:00" }, { "name": "mockery/mockery", @@ -11657,16 +12010,16 @@ }, { "name": "phpunit/phpunit", - "version": "10.5.58", + "version": "10.5.60", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "e24fb46da450d8e6a5788670513c1af1424f16ca" + "reference": "f2e26f52f80ef77832e359205f216eeac00e320c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/e24fb46da450d8e6a5788670513c1af1424f16ca", - "reference": "e24fb46da450d8e6a5788670513c1af1424f16ca", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/f2e26f52f80ef77832e359205f216eeac00e320c", + "reference": "f2e26f52f80ef77832e359205f216eeac00e320c", "shasum": "" }, "require": { @@ -11738,7 +12091,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.58" + "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.60" }, "funding": [ { @@ -11762,7 +12115,7 @@ "type": "tidelift" } ], - "time": "2025-09-28T12:04:46+00:00" + "time": "2025-12-06T07:50:42+00:00" }, { "name": "sebastian/cli-parser", diff --git a/config/services.php b/config/services.php index 2f9c0223..bd3734e3 100644 --- a/config/services.php +++ b/config/services.php @@ -63,4 +63,14 @@ 'site_key' => env('TURNSTILE_SITE_KEY'), 'secret_key' => env('TURNSTILE_SECRET_KEY'), ], + + 'satis' => [ + 'url' => env('SATIS_API_URL', 'https://plugins.nativephp.com'), + 'api_key' => env('SATIS_API_KEY'), + ], + + 'stripe_connect' => [ + 'client_id' => env('STRIPE_CONNECT_CLIENT_ID'), + 'platform_fee_percent' => 30, + ], ]; diff --git a/database/factories/PluginFactory.php b/database/factories/PluginFactory.php new file mode 100644 index 00000000..9c2861a5 --- /dev/null +++ b/database/factories/PluginFactory.php @@ -0,0 +1,187 @@ + + */ +class PluginFactory extends Factory +{ + protected $model = Plugin::class; + + /** + * @var array + */ + protected array $pluginPrefixes = [ + 'nativephp', + 'laravel', + 'acme', + 'awesome', + 'super', + 'native', + 'mobile', + 'app', + ]; + + /** + * @var array + */ + protected array $pluginSuffixes = [ + 'camera', + 'biometrics', + 'push-notifications', + 'geolocation', + 'bluetooth', + 'nfc', + 'contacts', + 'calendar', + 'health-kit', + 'share', + 'in-app-purchase', + 'admob', + 'analytics', + 'crashlytics', + 'deep-links', + 'local-auth', + 'secure-storage', + 'file-picker', + 'image-picker', + 'video-player', + 'audio-player', + 'speech-to-text', + 'text-to-speech', + 'barcode-scanner', + 'qr-code', + 'maps', + 'payments', + 'social-auth', + 'firebase', + 'sentry', + 'offline-sync', + 'background-tasks', + 'sensors', + 'haptics', + 'clipboard', + 'device-info', + 'network-info', + 'battery', + 'screen-brightness', + 'orientation', + 'keyboard', + 'status-bar', + 'splash-screen', + 'app-icon', + 'widgets', + ]; + + /** + * @var array + */ + protected array $descriptions = [ + 'A powerful plugin that integrates seamlessly with your NativePHP Mobile application, providing essential native functionality.', + 'Easily add native capabilities to your Laravel mobile app with this simple-to-use plugin.', + 'This plugin bridges the gap between PHP and native platform APIs, giving you full control.', + 'Unlock advanced mobile features with minimal configuration. Works on both iOS and Android.', + 'A production-ready plugin built with performance and reliability in mind.', + 'Simplify complex native integrations with this well-documented and tested plugin.', + 'Built by experienced mobile developers, this plugin follows best practices for both platforms.', + 'Zero-config setup that just works. Install via Composer and start using immediately.', + 'Comprehensive feature set with granular permissions control for enhanced security.', + 'Lightweight and fast, this plugin has minimal impact on your app\'s performance.', + ]; + + public function definition(): array + { + $vendor = fake()->randomElement($this->pluginPrefixes); + $package = fake()->randomElement($this->pluginSuffixes); + + return [ + 'user_id' => User::factory(), + 'name' => fake()->unique()->numerify("{$vendor}/{$package}-###"), + 'repository_url' => "https://github.com/{$vendor}/{$package}", + 'webhook_secret' => bin2hex(random_bytes(32)), + 'description' => fake()->randomElement($this->descriptions), + 'ios_version' => fake()->randomElement(['15.0+', '16.0+', '14.0+', '17.0+', null]), + 'android_version' => fake()->randomElement(['12+', '13+', '11+', '14+', null]), + 'type' => PluginType::Free, + 'status' => PluginStatus::Pending, + 'featured' => false, + 'rejection_reason' => null, + 'approved_at' => null, + 'approved_by' => null, + 'created_at' => fake()->dateTimeBetween('-6 months', 'now'), + 'updated_at' => fn (array $attrs) => $attrs['created_at'], + ]; + } + + public function pending(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => PluginStatus::Pending, + 'approved_at' => null, + 'approved_by' => null, + 'rejection_reason' => null, + ]); + } + + public function approved(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => PluginStatus::Approved, + 'approved_at' => fake()->dateTimeBetween($attributes['created_at'], 'now'), + 'approved_by' => User::factory(), + 'rejection_reason' => null, + ]); + } + + public function rejected(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => PluginStatus::Rejected, + 'approved_at' => null, + 'approved_by' => null, + 'rejection_reason' => fake()->randomElement([ + 'Package not found on Packagist. Please ensure your package is published.', + 'Plugin does not meet our quality standards. Please review our plugin guidelines.', + 'Missing required documentation. Please add a README with installation instructions.', + 'Security concerns identified. Please address the issues and resubmit.', + 'Plugin name conflicts with an existing package. Please choose a different name.', + 'Incomplete implementation. Some advertised features are not working as expected.', + ]), + ]); + } + + public function featured(): static + { + return $this->state(fn (array $attributes) => [ + 'featured' => true, + ]); + } + + public function free(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => PluginType::Free, + ]); + } + + public function paid(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => PluginType::Paid, + ]); + } + + public function withoutDescription(): static + { + return $this->state(fn (array $attributes) => [ + 'description' => null, + ]); + } +} diff --git a/database/factories/PluginLicenseFactory.php b/database/factories/PluginLicenseFactory.php new file mode 100644 index 00000000..d3a8e172 --- /dev/null +++ b/database/factories/PluginLicenseFactory.php @@ -0,0 +1,51 @@ + + */ +class PluginLicenseFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => \App\Models\User::factory(), + 'plugin_id' => \App\Models\Plugin::factory(), + 'stripe_payment_intent_id' => 'pi_'.$this->faker->uuid(), + 'price_paid' => $this->faker->numberBetween(1000, 10000), + 'currency' => 'USD', + 'is_grandfathered' => false, + 'purchased_at' => now(), + 'expires_at' => null, + ]; + } + + public function expired(): static + { + return $this->state(fn (array $attributes) => [ + 'expires_at' => now()->subDay(), + ]); + } + + public function expiresIn(int $days): static + { + return $this->state(fn (array $attributes) => [ + 'expires_at' => now()->addDays($days), + ]); + } + + public function grandfathered(): static + { + return $this->state(fn (array $attributes) => [ + 'is_grandfathered' => true, + ]); + } +} diff --git a/database/migrations/2025_12_03_124822_create_plugins_table.php b/database/migrations/2025_12_03_124822_create_plugins_table.php new file mode 100644 index 00000000..c5549804 --- /dev/null +++ b/database/migrations/2025_12_03_124822_create_plugins_table.php @@ -0,0 +1,35 @@ +id(); + $table->string('name'); // Composer package name e.g. vendor/package-name + $table->string('type'); // free or paid + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('status')->default('pending'); + $table->timestamp('approved_at')->nullable(); + $table->foreignId('approved_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + + $table->unique('name'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('plugins'); + } +}; diff --git a/database/migrations/2025_12_03_141900_add_anystack_id_and_rejection_reason_to_plugins_table.php b/database/migrations/2025_12_03_141900_add_anystack_id_and_rejection_reason_to_plugins_table.php new file mode 100644 index 00000000..000cef6f --- /dev/null +++ b/database/migrations/2025_12_03_141900_add_anystack_id_and_rejection_reason_to_plugins_table.php @@ -0,0 +1,29 @@ +string('anystack_id')->nullable()->after('type'); + $table->text('rejection_reason')->nullable()->after('status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plugins', function (Blueprint $table) { + $table->dropColumn(['anystack_id', 'rejection_reason']); + }); + } +}; diff --git a/database/migrations/2025_12_03_154716_create_plugin_activities_table.php b/database/migrations/2025_12_03_154716_create_plugin_activities_table.php new file mode 100644 index 00000000..2ec3fe42 --- /dev/null +++ b/database/migrations/2025_12_03_154716_create_plugin_activities_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('plugin_id')->constrained()->cascadeOnDelete(); + $table->string('type'); // submitted, resubmitted, approved, rejected + $table->string('from_status')->nullable(); + $table->string('to_status'); + $table->text('note')->nullable(); // rejection reason or other notes + $table->foreignId('causer_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + + $table->index(['plugin_id', 'created_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('plugin_activities'); + } +}; diff --git a/database/migrations/2025_12_03_161416_add_featured_to_plugins_table.php b/database/migrations/2025_12_03_161416_add_featured_to_plugins_table.php new file mode 100644 index 00000000..f5d5f55d --- /dev/null +++ b/database/migrations/2025_12_03_161416_add_featured_to_plugins_table.php @@ -0,0 +1,30 @@ +boolean('featured')->default(false)->after('status'); + $table->index(['status', 'featured']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plugins', function (Blueprint $table) { + $table->dropIndex(['status', 'featured']); + $table->dropColumn('featured'); + }); + } +}; diff --git a/database/migrations/2025_12_03_175340_add_description_to_plugins_table.php b/database/migrations/2025_12_03_175340_add_description_to_plugins_table.php new file mode 100644 index 00000000..368dc1f7 --- /dev/null +++ b/database/migrations/2025_12_03_175340_add_description_to_plugins_table.php @@ -0,0 +1,28 @@ +text('description')->nullable()->after('name'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plugins', function (Blueprint $table) { + $table->dropColumn('description'); + }); + } +}; diff --git a/database/migrations/2026_01_07_132448_add_platform_versions_to_plugins_table.php b/database/migrations/2026_01_07_132448_add_platform_versions_to_plugins_table.php new file mode 100644 index 00000000..394b9134 --- /dev/null +++ b/database/migrations/2026_01_07_132448_add_platform_versions_to_plugins_table.php @@ -0,0 +1,29 @@ +string('ios_version')->nullable()->after('description'); + $table->string('android_version')->nullable()->after('ios_version'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plugins', function (Blueprint $table) { + $table->dropColumn(['ios_version', 'android_version']); + }); + } +}; diff --git a/database/migrations/2026_01_07_134655_add_sync_fields_to_plugins_table.php b/database/migrations/2026_01_07_134655_add_sync_fields_to_plugins_table.php new file mode 100644 index 00000000..164cdb7e --- /dev/null +++ b/database/migrations/2026_01_07_134655_add_sync_fields_to_plugins_table.php @@ -0,0 +1,40 @@ +string('repository_url')->nullable()->after('name'); + $table->string('webhook_secret', 64)->nullable()->unique()->after('repository_url'); + $table->longText('readme_html')->nullable()->after('android_version'); + $table->json('composer_data')->nullable()->after('readme_html'); + $table->json('nativephp_data')->nullable()->after('composer_data'); + $table->timestamp('last_synced_at')->nullable()->after('nativephp_data'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plugins', function (Blueprint $table) { + $table->dropColumn([ + 'repository_url', + 'webhook_secret', + 'readme_html', + 'composer_data', + 'nativephp_data', + 'last_synced_at', + ]); + }); + } +}; diff --git a/database/migrations/2026_01_13_212117_create_developer_accounts_table.php b/database/migrations/2026_01_13_212117_create_developer_accounts_table.php new file mode 100644 index 00000000..979d4c6b --- /dev/null +++ b/database/migrations/2026_01_13_212117_create_developer_accounts_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete(); + $table->string('stripe_connect_account_id')->unique(); + $table->string('stripe_connect_status')->default('pending'); + $table->boolean('payouts_enabled')->default(false); + $table->boolean('charges_enabled')->default(false); + $table->timestamp('onboarding_completed_at')->nullable(); + $table->timestamps(); + + $table->index('stripe_connect_status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('developer_accounts'); + } +}; diff --git a/database/migrations/2026_01_13_212117_create_plugin_licenses_table.php b/database/migrations/2026_01_13_212117_create_plugin_licenses_table.php new file mode 100644 index 00000000..aab12f8d --- /dev/null +++ b/database/migrations/2026_01_13_212117_create_plugin_licenses_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('plugin_id')->constrained()->cascadeOnDelete(); + $table->string('stripe_payment_intent_id')->nullable(); + $table->unsignedInteger('price_paid'); + $table->string('currency', 3)->default('USD'); + $table->boolean('is_grandfathered')->default(false); + $table->timestamp('purchased_at'); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + + $table->unique(['user_id', 'plugin_id']); + $table->index('stripe_payment_intent_id'); + $table->index('is_grandfathered'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('plugin_licenses'); + } +}; diff --git a/database/migrations/2026_01_13_212117_create_plugin_prices_table.php b/database/migrations/2026_01_13_212117_create_plugin_prices_table.php new file mode 100644 index 00000000..971e0ccd --- /dev/null +++ b/database/migrations/2026_01_13_212117_create_plugin_prices_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('plugin_id')->constrained()->cascadeOnDelete(); + $table->string('stripe_price_id')->nullable()->unique(); + $table->unsignedInteger('amount'); + $table->string('currency', 3)->default('USD'); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->index(['plugin_id', 'is_active']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('plugin_prices'); + } +}; diff --git a/database/migrations/2026_01_13_212118_create_plugin_payouts_table.php b/database/migrations/2026_01_13_212118_create_plugin_payouts_table.php new file mode 100644 index 00000000..29f739f3 --- /dev/null +++ b/database/migrations/2026_01_13_212118_create_plugin_payouts_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('plugin_license_id')->constrained()->cascadeOnDelete(); + $table->foreignId('developer_account_id')->constrained()->cascadeOnDelete(); + $table->unsignedInteger('gross_amount'); + $table->unsignedInteger('platform_fee'); + $table->unsignedInteger('developer_amount'); + $table->string('stripe_transfer_id')->nullable(); + $table->string('status')->default('pending'); + $table->timestamp('transferred_at')->nullable(); + $table->timestamps(); + + $table->index('status'); + $table->index('stripe_transfer_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('plugin_payouts'); + } +}; diff --git a/database/migrations/2026_01_13_212118_create_user_purchase_history_table.php b/database/migrations/2026_01_13_212118_create_user_purchase_history_table.php new file mode 100644 index 00000000..79f729ce --- /dev/null +++ b/database/migrations/2026_01_13_212118_create_user_purchase_history_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete(); + $table->unsignedInteger('total_spent')->default(0); + $table->timestamp('first_purchase_at')->nullable(); + $table->string('grandfathering_tier')->nullable(); + $table->timestamp('recalculated_at')->nullable(); + $table->timestamps(); + + $table->index('grandfathering_tier'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_purchase_history'); + } +}; diff --git a/database/migrations/2026_01_13_212122_add_plugin_license_key_to_users_table.php b/database/migrations/2026_01_13_212122_add_plugin_license_key_to_users_table.php new file mode 100644 index 00000000..d5a99d7d --- /dev/null +++ b/database/migrations/2026_01_13_212122_add_plugin_license_key_to_users_table.php @@ -0,0 +1,28 @@ +string('plugin_license_key', 64)->nullable()->unique()->after('remember_token'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('plugin_license_key'); + }); + } +}; diff --git a/database/migrations/2026_01_13_212122_add_satis_fields_to_plugins_table.php b/database/migrations/2026_01_13_212122_add_satis_fields_to_plugins_table.php new file mode 100644 index 00000000..c9daea27 --- /dev/null +++ b/database/migrations/2026_01_13_212122_add_satis_fields_to_plugins_table.php @@ -0,0 +1,34 @@ +boolean('is_official')->default(false)->after('type'); + $table->foreignId('developer_account_id')->nullable()->after('user_id')->constrained()->nullOnDelete(); + $table->boolean('satis_included')->default(false)->after('is_official'); + + $table->index('is_official'); + $table->index('satis_included'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plugins', function (Blueprint $table) { + $table->dropForeign(['developer_account_id']); + $table->dropColumn(['is_official', 'developer_account_id', 'satis_included']); + }); + } +}; diff --git a/database/migrations/2026_01_13_233815_create_plugin_versions_table.php b/database/migrations/2026_01_13_233815_create_plugin_versions_table.php new file mode 100644 index 00000000..4771ce24 --- /dev/null +++ b/database/migrations/2026_01_13_233815_create_plugin_versions_table.php @@ -0,0 +1,41 @@ +id(); + $table->foreignId('plugin_id')->constrained()->cascadeOnDelete(); + $table->string('version'); + $table->string('tag_name'); + $table->text('release_notes')->nullable(); + $table->string('github_release_id')->nullable(); + $table->string('commit_sha')->nullable(); + $table->string('storage_path')->nullable(); + $table->unsignedBigInteger('file_size')->nullable(); + $table->boolean('is_packaged')->default(false); + $table->timestamp('packaged_at')->nullable(); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + + $table->unique(['plugin_id', 'version']); + $table->index('is_packaged'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('plugin_versions'); + } +}; diff --git a/database/migrations/2026_01_13_233935_add_github_token_to_users_table.php b/database/migrations/2026_01_13_233935_add_github_token_to_users_table.php new file mode 100644 index 00000000..019c35c3 --- /dev/null +++ b/database/migrations/2026_01_13_233935_add_github_token_to_users_table.php @@ -0,0 +1,28 @@ +text('github_token')->nullable()->after('github_username'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('github_token'); + }); + } +}; diff --git a/database/migrations/2026_01_13_234624_make_plugin_name_nullable.php b/database/migrations/2026_01_13_234624_make_plugin_name_nullable.php new file mode 100644 index 00000000..38066dfb --- /dev/null +++ b/database/migrations/2026_01_13_234624_make_plugin_name_nullable.php @@ -0,0 +1,28 @@ +string('name')->nullable()->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plugins', function (Blueprint $table) { + $table->string('name')->nullable(false)->change(); + }); + } +}; diff --git a/database/migrations/2026_01_13_234703_add_unique_constraint_to_plugins_repository_url.php b/database/migrations/2026_01_13_234703_add_unique_constraint_to_plugins_repository_url.php new file mode 100644 index 00000000..9698dbc3 --- /dev/null +++ b/database/migrations/2026_01_13_234703_add_unique_constraint_to_plugins_repository_url.php @@ -0,0 +1,28 @@ +unique('repository_url'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plugins', function (Blueprint $table) { + $table->dropUnique(['repository_url']); + }); + } +}; diff --git a/database/migrations/2026_01_13_234923_add_logo_to_plugins_table.php b/database/migrations/2026_01_13_234923_add_logo_to_plugins_table.php new file mode 100644 index 00000000..072d275f --- /dev/null +++ b/database/migrations/2026_01_13_234923_add_logo_to_plugins_table.php @@ -0,0 +1,28 @@ +string('logo_path')->nullable()->after('description'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plugins', function (Blueprint $table) { + $table->dropColumn('logo_path'); + }); + } +}; diff --git a/database/migrations/2026_01_14_000258_add_latest_version_to_plugins_table.php b/database/migrations/2026_01_14_000258_add_latest_version_to_plugins_table.php new file mode 100644 index 00000000..c2b083ae --- /dev/null +++ b/database/migrations/2026_01_14_000258_add_latest_version_to_plugins_table.php @@ -0,0 +1,28 @@ +string('latest_version')->nullable()->after('description'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plugins', function (Blueprint $table) { + $table->dropColumn('latest_version'); + }); + } +}; diff --git a/database/migrations/2026_01_14_003107_make_stripe_price_id_nullable_in_plugin_prices.php b/database/migrations/2026_01_14_003107_make_stripe_price_id_nullable_in_plugin_prices.php new file mode 100644 index 00000000..b66db75f --- /dev/null +++ b/database/migrations/2026_01_14_003107_make_stripe_price_id_nullable_in_plugin_prices.php @@ -0,0 +1,28 @@ +string('stripe_price_id')->nullable()->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plugin_prices', function (Blueprint $table) { + $table->string('stripe_price_id')->nullable(false)->change(); + }); + } +}; diff --git a/database/migrations/2026_01_14_012908_create_carts_table.php b/database/migrations/2026_01_14_012908_create_carts_table.php new file mode 100644 index 00000000..f9dae951 --- /dev/null +++ b/database/migrations/2026_01_14_012908_create_carts_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('session_id')->nullable()->index(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + + $table->index(['session_id', 'user_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('carts'); + } +}; diff --git a/database/migrations/2026_01_14_012909_create_cart_items_table.php b/database/migrations/2026_01_14_012909_create_cart_items_table.php new file mode 100644 index 00000000..2adf3e53 --- /dev/null +++ b/database/migrations/2026_01_14_012909_create_cart_items_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('cart_id')->constrained()->cascadeOnDelete(); + $table->foreignId('plugin_id')->constrained()->cascadeOnDelete(); + $table->foreignId('plugin_price_id')->constrained()->cascadeOnDelete(); + $table->integer('price_at_addition')->comment('Price in cents when added to cart'); + $table->string('currency', 3)->default('USD'); + $table->timestamps(); + + $table->unique(['cart_id', 'plugin_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('cart_items'); + } +}; diff --git a/database/migrations/2026_01_14_020212_add_stripe_checkout_session_id_to_plugin_licenses_table.php b/database/migrations/2026_01_14_020212_add_stripe_checkout_session_id_to_plugin_licenses_table.php new file mode 100644 index 00000000..51e003e0 --- /dev/null +++ b/database/migrations/2026_01_14_020212_add_stripe_checkout_session_id_to_plugin_licenses_table.php @@ -0,0 +1,28 @@ +string('stripe_checkout_session_id')->nullable()->unique()->after('stripe_payment_intent_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plugin_licenses', function (Blueprint $table) { + $table->dropColumn('stripe_checkout_session_id'); + }); + } +}; diff --git a/database/migrations/2026_01_14_030546_remove_unique_constraint_from_plugin_licenses_table.php b/database/migrations/2026_01_14_030546_remove_unique_constraint_from_plugin_licenses_table.php new file mode 100644 index 00000000..9193fa29 --- /dev/null +++ b/database/migrations/2026_01_14_030546_remove_unique_constraint_from_plugin_licenses_table.php @@ -0,0 +1,38 @@ +index('user_id', 'plugin_licenses_user_id_index'); + }); + + Schema::table('plugin_licenses', function (Blueprint $table) { + // Now we can drop the unique constraint + $table->dropUnique(['user_id', 'plugin_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plugin_licenses', function (Blueprint $table) { + $table->unique(['user_id', 'plugin_id']); + }); + + Schema::table('plugin_licenses', function (Blueprint $table) { + $table->dropIndex('plugin_licenses_user_id_index'); + }); + } +}; diff --git a/database/migrations/2026_01_16_104150_remove_unique_constraint_from_stripe_checkout_session_id_on_plugin_licenses.php b/database/migrations/2026_01_16_104150_remove_unique_constraint_from_stripe_checkout_session_id_on_plugin_licenses.php new file mode 100644 index 00000000..f51e2967 --- /dev/null +++ b/database/migrations/2026_01_16_104150_remove_unique_constraint_from_stripe_checkout_session_id_on_plugin_licenses.php @@ -0,0 +1,38 @@ +dropUnique(['stripe_checkout_session_id']); + }); + + Schema::table('plugin_licenses', function (Blueprint $table) { + // Add a regular index for query performance + $table->index('stripe_checkout_session_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plugin_licenses', function (Blueprint $table) { + $table->dropIndex(['stripe_checkout_session_id']); + }); + + Schema::table('plugin_licenses', function (Blueprint $table) { + $table->unique('stripe_checkout_session_id'); + }); + } +}; diff --git a/database/migrations/2026_01_16_111854_add_display_name_to_users_table.php b/database/migrations/2026_01_16_111854_add_display_name_to_users_table.php new file mode 100644 index 00000000..c6750b5a --- /dev/null +++ b/database/migrations/2026_01_16_111854_add_display_name_to_users_table.php @@ -0,0 +1,25 @@ +string('display_name')->nullable()->after('name'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('display_name'); + }); + } +}; diff --git a/database/migrations/2026_01_16_120914_create_plugin_bundles_table.php b/database/migrations/2026_01_16_120914_create_plugin_bundles_table.php new file mode 100644 index 00000000..bce6f602 --- /dev/null +++ b/database/migrations/2026_01_16_120914_create_plugin_bundles_table.php @@ -0,0 +1,38 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->string('logo_path')->nullable(); + $table->unsignedInteger('price'); + $table->string('currency', 3)->default('USD'); + $table->boolean('is_active')->default(false); + $table->boolean('is_featured')->default(false); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + + $table->index(['is_active', 'published_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('plugin_bundles'); + } +}; diff --git a/database/migrations/2026_01_16_120915_add_bundle_support_to_cart_items_table.php b/database/migrations/2026_01_16_120915_add_bundle_support_to_cart_items_table.php new file mode 100644 index 00000000..eb780949 --- /dev/null +++ b/database/migrations/2026_01_16_120915_add_bundle_support_to_cart_items_table.php @@ -0,0 +1,31 @@ +foreignId('plugin_bundle_id') + ->nullable() + ->after('plugin_id') + ->constrained() + ->cascadeOnDelete(); + $table->unsignedInteger('bundle_price_at_addition')->nullable()->after('price_at_addition'); + }); + } + + public function down(): void + { + Schema::table('cart_items', function (Blueprint $table) { + $table->dropForeign(['plugin_bundle_id']); + $table->dropColumn(['plugin_bundle_id', 'bundle_price_at_addition']); + }); + } +}; diff --git a/database/migrations/2026_01_16_120915_add_plugin_bundle_id_to_plugin_licenses_table.php b/database/migrations/2026_01_16_120915_add_plugin_bundle_id_to_plugin_licenses_table.php new file mode 100644 index 00000000..f3a8fd01 --- /dev/null +++ b/database/migrations/2026_01_16_120915_add_plugin_bundle_id_to_plugin_licenses_table.php @@ -0,0 +1,30 @@ +foreignId('plugin_bundle_id') + ->nullable() + ->after('plugin_id') + ->constrained() + ->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('plugin_licenses', function (Blueprint $table) { + $table->dropForeign(['plugin_bundle_id']); + $table->dropColumn('plugin_bundle_id'); + }); + } +}; diff --git a/database/migrations/2026_01_16_120915_create_bundle_plugin_table.php b/database/migrations/2026_01_16_120915_create_bundle_plugin_table.php new file mode 100644 index 00000000..8ea93f0d --- /dev/null +++ b/database/migrations/2026_01_16_120915_create_bundle_plugin_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('plugin_bundle_id')->constrained()->cascadeOnDelete(); + $table->foreignId('plugin_id')->constrained()->cascadeOnDelete(); + $table->unsignedInteger('sort_order')->default(0); + $table->timestamps(); + + $table->unique(['plugin_bundle_id', 'plugin_id']); + $table->index(['plugin_bundle_id', 'sort_order']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('bundle_plugin'); + } +}; diff --git a/database/migrations/2026_01_16_155435_make_plugin_id_nullable_on_cart_items_table.php b/database/migrations/2026_01_16_155435_make_plugin_id_nullable_on_cart_items_table.php new file mode 100644 index 00000000..9184470b --- /dev/null +++ b/database/migrations/2026_01_16_155435_make_plugin_id_nullable_on_cart_items_table.php @@ -0,0 +1,24 @@ +foreignId('plugin_id')->nullable()->change(); + $table->foreignId('plugin_price_id')->nullable()->change(); + }); + } + + public function down(): void + { + Schema::table('cart_items', function (Blueprint $table) { + $table->foreignId('plugin_id')->nullable(false)->change(); + $table->foreignId('plugin_price_id')->nullable(false)->change(); + }); + } +}; diff --git a/database/migrations/2026_01_16_155617_make_price_at_addition_nullable_on_cart_items_table.php b/database/migrations/2026_01_16_155617_make_price_at_addition_nullable_on_cart_items_table.php new file mode 100644 index 00000000..01ddf0f2 --- /dev/null +++ b/database/migrations/2026_01_16_155617_make_price_at_addition_nullable_on_cart_items_table.php @@ -0,0 +1,22 @@ +unsignedInteger('price_at_addition')->nullable()->change(); + }); + } + + public function down(): void + { + Schema::table('cart_items', function (Blueprint $table) { + $table->unsignedInteger('price_at_addition')->nullable(false)->change(); + }); + } +}; diff --git a/database/migrations/2026_01_16_162437_add_is_active_to_plugins_table.php b/database/migrations/2026_01_16_162437_add_is_active_to_plugins_table.php new file mode 100644 index 00000000..b2fc25ea --- /dev/null +++ b/database/migrations/2026_01_16_162437_add_is_active_to_plugins_table.php @@ -0,0 +1,28 @@ +boolean('is_active')->default(true)->after('featured'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plugins', function (Blueprint $table) { + $table->dropColumn('is_active'); + }); + } +}; diff --git a/database/migrations/2026_01_17_143327_remove_unused_satis_and_anystack_columns_from_plugins_table.php b/database/migrations/2026_01_17_143327_remove_unused_satis_and_anystack_columns_from_plugins_table.php new file mode 100644 index 00000000..4c5c159e --- /dev/null +++ b/database/migrations/2026_01_17_143327_remove_unused_satis_and_anystack_columns_from_plugins_table.php @@ -0,0 +1,32 @@ +dropIndex(['satis_included']); + $table->dropColumn(['satis_included', 'anystack_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plugins', function (Blueprint $table) { + $table->boolean('satis_included')->default(false)->after('is_official'); + $table->string('anystack_id')->nullable()->after('type'); + + $table->index('satis_included'); + }); + } +}; diff --git a/database/seeders/PluginSeeder.php b/database/seeders/PluginSeeder.php new file mode 100644 index 00000000..4e86658d --- /dev/null +++ b/database/seeders/PluginSeeder.php @@ -0,0 +1,127 @@ +take(20)->get(); + + if ($users->count() < 20) { + $additionalUsers = User::factory() + ->count(20 - $users->count()) + ->create(); + + $users = $users->merge($additionalUsers); + } + + // Get or create an admin user for approvals + $admin = User::query() + ->where('email', 'admin@example.com') + ->first() ?? User::factory()->create([ + 'name' => 'Admin User', + 'email' => 'admin@example.com', + ]); + + // Create 10 featured approved plugins (free) + Plugin::factory() + ->count(10) + ->approved() + ->featured() + ->free() + ->create([ + 'user_id' => fn () => $users->random()->id, + 'approved_by' => $admin->id, + ]); + + // Create 5 featured approved plugins (paid) + Plugin::factory() + ->count(5) + ->approved() + ->featured() + ->paid() + ->create([ + 'user_id' => fn () => $users->random()->id, + 'approved_by' => $admin->id, + ]); + + // Create 30 approved free plugins (not featured) + Plugin::factory() + ->count(30) + ->approved() + ->free() + ->create([ + 'user_id' => fn () => $users->random()->id, + 'approved_by' => $admin->id, + ]); + + // Create 15 approved paid plugins (not featured) + Plugin::factory() + ->count(15) + ->approved() + ->paid() + ->create([ + 'user_id' => fn () => $users->random()->id, + 'approved_by' => $admin->id, + ]); + + // Create 20 pending plugins (mix of free and paid) + Plugin::factory() + ->count(15) + ->pending() + ->free() + ->create([ + 'user_id' => fn () => $users->random()->id, + ]); + + Plugin::factory() + ->count(5) + ->pending() + ->paid() + ->create([ + 'user_id' => fn () => $users->random()->id, + ]); + + // Create 15 rejected plugins + Plugin::factory() + ->count(10) + ->rejected() + ->free() + ->create([ + 'user_id' => fn () => $users->random()->id, + ]); + + Plugin::factory() + ->count(5) + ->rejected() + ->paid() + ->create([ + 'user_id' => fn () => $users->random()->id, + ]); + + // Create a few approved plugins without descriptions + Plugin::factory() + ->count(5) + ->approved() + ->free() + ->withoutDescription() + ->create([ + 'user_id' => fn () => $users->random()->id, + 'approved_by' => $admin->id, + ]); + + $this->command->info('Created plugins:'); + $this->command->info(' - 15 featured approved (10 free, 5 paid)'); + $this->command->info(' - 45 approved non-featured (30 free, 15 paid)'); + $this->command->info(' - 5 approved without descriptions'); + $this->command->info(' - 20 pending (15 free, 5 paid)'); + $this->command->info(' - 15 rejected (10 free, 5 paid)'); + $this->command->info(' Total: 100 plugins (65 approved)'); + } +} diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 074f95dc..294e57b9 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -87,6 +87,26 @@ class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" + +
+
+
+
+
+
+ Or continue with +
+
+ + +
diff --git a/resources/views/auth/register.blade.php b/resources/views/auth/register.blade.php index 55a06908..18771ad7 100644 --- a/resources/views/auth/register.blade.php +++ b/resources/views/auth/register.blade.php @@ -102,6 +102,33 @@ class="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-3 Privacy Policy.

+ +
+
+
+
+
+
+ Or continue with +
+
+ + + +

+ By signing up with GitHub, you agree to our + Terms of Service + and + Privacy Policy. +

+
diff --git a/resources/views/bundle-show.blade.php b/resources/views/bundle-show.blade.php new file mode 100644 index 00000000..623b794a --- /dev/null +++ b/resources/views/bundle-show.blade.php @@ -0,0 +1,214 @@ + +
+
+ {{-- Blurred circle - Decorative --}} + + + {{-- Back button --}} + + + {{-- Bundle icon and title --}} +
+ @if ($bundle->hasLogo()) + {{ $bundle->name }} logo + @else +
+ + + +
+ @endif +
+ + Bundle + +

+ {{ $bundle->name }} +

+

+ {{ $bundle->plugins->count() }} plugins included +

+
+
+
+ + {{-- Divider --}} + + +
+ {{-- Main content - Description and Plugins --}} +
+ {{-- Description --}} + @if ($bundle->description) +
+

About this Bundle

+

{{ $bundle->description }}

+
+ @endif + + {{-- Included Plugins --}} +
+

+ Included Plugins +

+

+ Purchase this bundle to get access to all {{ $bundle->plugins->count() }} plugins. +

+
+ @foreach ($bundle->plugins as $plugin) + + @endforeach +
+
+
+ + {{-- Sidebar - Purchase Box --}} + +
+
+
diff --git a/resources/views/cart/show.blade.php b/resources/views/cart/show.blade.php new file mode 100644 index 00000000..59eaf975 --- /dev/null +++ b/resources/views/cart/show.blade.php @@ -0,0 +1,284 @@ + +
+ {{-- Header --}} +
+ + + + + Back to Plugin Directory + +

Your Cart

+
+ + {{-- Flash Messages --}} + @if (session('success')) +
+

{!! session('success') !!}

+
+ @endif + + @if (session('error')) +
+

{{ session('error') }}

+
+ @endif + + @if (session('info')) +
+

{{ session('info') }}

+
+ @endif + + {{-- Price Change Notifications --}} + @if (count($priceChanges) > 0) +
+

Price Updates

+
    + @foreach ($priceChanges as $change) + @if ($change['type'] === 'price_changed') +
  • {{ $change['name'] }}: Price changed from ${{ number_format($change['old_price'] / 100) }} to ${{ number_format($change['new_price'] / 100) }}
  • + @else +
  • {{ $change['name'] }}: No longer available and was removed from your cart
  • + @endif + @endforeach +
+
+ @endif + + {{-- Bundle Upgrade Suggestions --}} + @if ($bundleUpgrades->isNotEmpty()) + @foreach ($bundleUpgrades as $bundle) +
+
+
+ @if ($bundle->hasLogo()) + {{ $bundle->name }} + @else +
+ + + +
+ @endif +
+

+ Save with the {{ $bundle->name }} bundle! +

+

+ Your cart includes all {{ $bundle->plugins->count() }} plugins in this bundle. + Switch to the bundle and save {{ $bundle->formatted_savings }} ({{ $bundle->discount_percent }}% off). +

+
+
+
+ @csrf + +
+
+
+ @endforeach + @endif + + @if ($cart->isEmpty()) + {{-- Empty Cart --}} +
+ + + +

Your cart is empty

+

Browse our plugin directory to find plugins for your app.

+ + Browse Plugins + +
+ @else +
+ {{-- Cart Items --}} +
+
+
    + @foreach ($cart->items as $item) + @if ($item->isBundle()) + {{-- Bundle Item --}} +
  • plugin_bundle_id) + x-data="{ highlight: true }" + x-init="setTimeout(() => highlight = false, 3000)" + :class="highlight ? 'bg-amber-50 dark:bg-amber-900/20 ring-2 ring-amber-500 ring-inset' : ''" + class="flex gap-4 p-6 transition-all duration-1000" + @else + class="flex gap-4 p-6" + @endif + > + {{-- Bundle Logo --}} +
    + @if ($item->pluginBundle->hasLogo()) + {{ $item->pluginBundle->name }} + @else +
    + + + +
    + @endif +
    + + {{-- Bundle Details --}} +
    +
    +
    + +

    + {{ $item->pluginBundle->plugins->count() }} plugins included +

    + @if ($item->pluginBundle->discount_percent > 0) +

    + Save {{ $item->pluginBundle->discount_percent }}% ({{ $item->pluginBundle->formatted_savings }}) +

    + @endif +
    +

    + {{ $item->getFormattedPrice() }} +

    +
    + +
    +
    + @csrf + @method('DELETE') + +
    +
    +
    +
  • + @else + {{-- Plugin Item --}} +
  • plugin_id) + x-data="{ highlight: true }" + x-init="setTimeout(() => highlight = false, 3000)" + :class="highlight ? 'bg-green-50 dark:bg-green-900/20 ring-2 ring-green-500 ring-inset' : ''" + class="flex gap-4 p-6 transition-all duration-1000" + @else + class="flex gap-4 p-6" + @endif + > + {{-- Plugin Logo --}} +
    + @if ($item->plugin->hasLogo()) + {{ $item->plugin->name }} + @else +
    + +
    + @endif +
    + + {{-- Plugin Details --}} +
    +
    +
    +

    + + {{ $item->plugin->name }} + +

    +

    + by {{ $item->plugin->user->display_name }} +

    +
    +

    + {{ $item->getFormattedPrice() }} +

    +
    + +
    +
    + @csrf + @method('DELETE') + +
    +
    +
    +
  • + @endif + @endforeach +
+
+ + {{-- Clear Cart --}} +
+
+ @csrf + @method('DELETE') + +
+
+
+ + {{-- Order Summary --}} +
+
+

Order Summary

+ +
+
+
Subtotal ({{ $cart->itemCount() }} {{ Str::plural('item', $cart->itemCount()) }})
+
{{ $cart->getFormattedSubtotal() }}
+
+
+ +
+
+
Total
+
{{ $cart->getFormattedSubtotal() }}
+
+
+ +
+ @csrf + +
+ + @guest +

+ You'll need to log in or create an account to complete your purchase. +

+ @endguest + +

+ Secure checkout powered by Stripe +

+
+
+
+ @endif +
+
diff --git a/resources/views/cart/success.blade.php b/resources/views/cart/success.blade.php new file mode 100644 index 00000000..11caa701 --- /dev/null +++ b/resources/views/cart/success.blade.php @@ -0,0 +1,148 @@ + +
+
+ {{-- Loading State --}} + + + {{-- Success State --}} + + + {{-- Timeout/Error State --}} + +
+
+
diff --git a/resources/views/components/bundle-card.blade.php b/resources/views/components/bundle-card.blade.php new file mode 100644 index 00000000..c726514a --- /dev/null +++ b/resources/views/components/bundle-card.blade.php @@ -0,0 +1,60 @@ +@props(['bundle']) + + +
+ @if ($bundle->hasLogo()) + {{ $bundle->name }} logo + @else +
+ + + +
+ @endif + + + Bundle + +
+ +
+

+ {{ $bundle->name }} +

+ @if ($bundle->description) +

+ {{ $bundle->description }} +

+ @endif + +

+ {{ $bundle->plugins->count() }} plugins included +

+
+ +
+
+ + {{ $bundle->formatted_price }} + + @if ($bundle->discount_percent > 0) + + {{ $bundle->formatted_retail_value }} + + @endif +
+ + @if ($bundle->discount_percent > 0) + + Save {{ $bundle->discount_percent }}% + + @endif +
+
diff --git a/resources/views/components/icons/puzzle.blade.php b/resources/views/components/icons/puzzle.blade.php new file mode 100644 index 00000000..2ece32d5 --- /dev/null +++ b/resources/views/components/icons/puzzle.blade.php @@ -0,0 +1,12 @@ + + + diff --git a/resources/views/components/navbar/device-dropdowns.blade.php b/resources/views/components/navbar/device-dropdowns.blade.php index 87bf65a7..e27e901e 100644 --- a/resources/views/components/navbar/device-dropdowns.blade.php +++ b/resources/views/components/navbar/device-dropdowns.blade.php @@ -22,6 +22,15 @@ icon="dollar-circle" icon-class="size-5.5" /> + @feature(App\Features\ShowPlugins::class) + + @endfeature @if($showShowcase) @endif + + {{-- --}} {{-- Desktop dropdown --}} diff --git a/resources/views/components/navbar/mobile-menu.blade.php b/resources/views/components/navbar/mobile-menu.blade.php index 0f25101d..b9cdc9bc 100644 --- a/resources/views/components/navbar/mobile-menu.blade.php +++ b/resources/views/components/navbar/mobile-menu.blade.php @@ -211,27 +211,20 @@ class="size-4 shrink-0" - {{-- Login/Logout --}} + {{-- Login/Dashboard --}} @feature(App\Features\ShowAuthButtons::class)
@auth -
- @csrf - -
+
+ Dashboard + {{ auth()->user()->email }} +
+ @else @endfeature + + {{-- Cart --}} + @feature(App\Features\ShowPlugins::class) + @if ($cartCount > 0) + + @endif + @endfeature
- @csrf - - + Dashboard + @else 0) + + + + + + {{ $cartCount > 9 ? '9+' : $cartCount }} + + Cart ({{ $cartCount }} items) + + @endif + @endfeature + {{-- Theme toggle --}} diff --git a/resources/views/components/plugin-card.blade.php b/resources/views/components/plugin-card.blade.php new file mode 100644 index 00000000..a673243b --- /dev/null +++ b/resources/views/components/plugin-card.blade.php @@ -0,0 +1,47 @@ +@props(['plugin']) + + +
+ @if ($plugin->hasLogo()) + {{ $plugin->name }} logo + @else +
+ +
+ @endif + @if ($plugin->isPaid()) + + Paid + + @else + + Free + + @endif +
+ +
+

+ {{ $plugin->name }} +

+ @if ($plugin->description) +

+ {{ $plugin->description }} +

+ @endif +
+ +
+ View details + + + +
+
diff --git a/resources/views/customer/developer/dashboard.blade.php b/resources/views/customer/developer/dashboard.blade.php new file mode 100644 index 00000000..42d2e48b --- /dev/null +++ b/resources/views/customer/developer/dashboard.blade.php @@ -0,0 +1,224 @@ + +
+ {{-- Header --}} +
+
+
+
+

Developer Dashboard

+

+ Manage your plugins and track your earnings +

+
+ +
+
+
+ + {{-- Session Messages --}} +
+ @if (session('success')) +
+

{{ session('success') }}

+
+ @endif + + @if (session('message')) +
+

{{ session('message') }}

+
+ @endif +
+ + {{-- Content --}} +
+ {{-- Stats Grid --}} +
+ {{-- Total Earnings --}} +
+
Total Earnings
+
+ ${{ number_format($totalEarnings / 100, 2) }} +
+
+ + {{-- Pending Earnings --}} +
+
Pending Payouts
+
+ ${{ number_format($pendingEarnings / 100, 2) }} +
+
+ + {{-- Total Plugins --}} +
+
Published Plugins
+
+ {{ $plugins->where('status', \App\Enums\PluginStatus::Approved)->count() }} +
+
+ + {{-- Total Sales --}} +
+
Total Sales
+
+ {{ $plugins->sum('licenses_count') }} +
+
+
+ + {{-- Account Status --}} +
+
+

Stripe Account Status

+
+
+
+
+ @if ($developerAccount->canReceivePayouts()) +
+ + + +
+
+

Account Active

+

Your account is fully set up to receive payouts

+
+ @else +
+ + + +
+
+

Action Required

+

Additional information needed for payouts

+
+ @endif +
+ @if (! $developerAccount->canReceivePayouts()) + + Complete Setup + + @endif +
+
+
+ + {{-- Two Column Layout --}} +
+ {{-- Plugins --}} + + + {{-- Recent Payouts --}} +
+
+

Recent Payouts

+
+
+ @forelse ($payouts as $payout) +
+
+
+

+ {{ $payout->pluginLicense->plugin->name ?? 'Unknown Plugin' }} +

+

+ {{ $payout->created_at->format('M j, Y') }} +

+
+
+

+ ${{ number_format($payout->developer_amount / 100, 2) }} +

+ @if ($payout->status === \App\Enums\PayoutStatus::Transferred) + + Paid + + @elseif ($payout->status === \App\Enums\PayoutStatus::Pending) + + Pending + + @else + + Failed + + @endif +
+
+
+ @empty +
+ + + +

No payouts yet

+

Payouts will appear here after you make your first sale

+
+ @endforelse +
+
+
+
+
+
diff --git a/resources/views/customer/developer/onboarding.blade.php b/resources/views/customer/developer/onboarding.blade.php new file mode 100644 index 00000000..3e6476ba --- /dev/null +++ b/resources/views/customer/developer/onboarding.blade.php @@ -0,0 +1,168 @@ + +
+ {{-- Header --}} +
+
+
+
+

Become a Plugin Developer

+

+ Set up your account to sell plugins on NativePHP +

+
+ + + My Plugins + +
+
+
+ + {{-- Session Messages --}} +
+ @if (session('success')) +
+

{{ session('success') }}

+
+ @endif + + @if (session('error')) +
+

{{ session('error') }}

+
+ @endif + + @if (session('message')) +
+

{{ session('message') }}

+
+ @endif +
+ + {{-- Content --}} +
+
+
+ {{-- Hero Section --}} +
+
+ + + +
+

+ @if ($hasExistingAccount) + Complete Your Onboarding + @else + Start Selling Plugins + @endif +

+

+ @if ($hasExistingAccount) + You've started the onboarding process. Complete the remaining steps to start receiving payouts. + @else + Connect your Stripe account to receive payments when users purchase your plugins. + @endif +

+
+ + {{-- Benefits --}} +
+

Why sell on NativePHP?

+
    +
  • + + + + 70% Revenue Share - You keep the majority of every sale +
  • +
  • + + + + Built-in Distribution - Automatic Composer repository hosting +
  • +
  • + + + + Targeted Audience - Reach NativePHP developers directly +
  • +
  • + + + + Automatic Payouts - Get paid directly to your bank account +
  • +
+
+ + {{-- Status for existing account --}} + @if ($hasExistingAccount && $developerAccount) +
+
+ + + +
+

Onboarding Incomplete

+

Your Stripe account requires additional information before you can receive payouts.

+
+
+
+ @endif + + {{-- CTA Button --}} +
+
+ @csrf + +
+
+ + {{-- Stripe Info --}} +

+ You'll be redirected to Stripe to complete the onboarding process securely. +

+
+
+ + {{-- FAQ --}} +
+

Frequently Asked Questions

+ +
+
+

How does the revenue share work?

+

+ You receive 70% of each sale. NativePHP retains 30% to cover payment processing, hosting, and platform maintenance. +

+
+ +
+

When do I get paid?

+

+ Payouts are processed automatically through Stripe Connect. Funds are typically available within 2-7 business days after a sale. +

+
+ +
+

What do I need to get started?

+

+ You'll need a Stripe account (or create one during onboarding), a GitHub repository for your plugin, and a nativephp.json configuration file. +

+
+
+
+
+
+
diff --git a/resources/views/customer/integrations.blade.php b/resources/views/customer/integrations.blade.php index 07bd2c34..42acef03 100644 --- a/resources/views/customer/integrations.blade.php +++ b/resources/views/customer/integrations.blade.php @@ -5,23 +5,12 @@
- + + + + + Dashboard +

Integrations

Connect your accounts to unlock additional features diff --git a/resources/views/customer/licenses/index.blade.php b/resources/views/customer/licenses/index.blade.php index 50de6b21..4351b7e2 100644 --- a/resources/views/customer/licenses/index.blade.php +++ b/resources/views/customer/licenses/index.blade.php @@ -14,12 +14,24 @@ Showcase + @feature(App\Features\ShowPlugins::class) + + + Plugins + + @endfeature Integrations Manage Subscription +

+ @csrf + +
@@ -35,31 +47,108 @@
- {{-- Content --}} -
- {{-- Flash Messages --}} - @if(session()->has('success')) -
-
- - - -

{{ session('success') }}

+ {{-- Purchased Plugins --}} + @feature(App\Features\ShowPlugins::class) + @if($pluginLicenses->count() > 0) +
+
+

Purchased Plugins

+ + Browse more plugins +
-
- @endif - @if(session()->has('error')) -
-
- - - -

{{ session('error') }}

+ {{-- Plugin License Key --}} + @if(auth()->user()->plugin_license_key) +
+
+
+

Your Plugin License Key

+

+ Use this key with your email to authenticate Composer for paid plugins. +

+
+ +
+
+ + {{ auth()->user()->plugin_license_key }} + +
+
+ + How to configure Composer + +
+

1. Add the NativePHP plugins repository:

+
+ composer config repositories.nativephp-plugins composer https://plugins.nativephp.com +
+

2. Configure your credentials:

+
+ composer config http-basic.plugins.nativephp.com {{ auth()->user()->email }} {{ auth()->user()->plugin_license_key }} +
+
+
+
+ @endif + +
@endif + @endfeature + {{-- Content --}} +
+

NativePHP Licenses

@if($licenses->count() > 0)
    diff --git a/resources/views/customer/licenses/show.blade.php b/resources/views/customer/licenses/show.blade.php index 207a34e6..ed3eda66 100644 --- a/resources/views/customer/licenses/show.blade.php +++ b/resources/views/customer/licenses/show.blade.php @@ -5,23 +5,12 @@
    - + + + + + Dashboard +

    {{ $license->name ?: $license->policy_name }}

    diff --git a/resources/views/customer/plugins/create.blade.php b/resources/views/customer/plugins/create.blade.php new file mode 100644 index 00000000..81cd4559 --- /dev/null +++ b/resources/views/customer/plugins/create.blade.php @@ -0,0 +1,415 @@ + +
    + {{-- Header --}} +
    +
    +
    +
    + +

    Submit Your Plugin

    +

    + Add your plugin to the NativePHP Plugin Directory +

    +
    +
    +
    +
    + + {{-- Content --}} +
    + {{-- Session Error Message --}} + @if (session('error')) +
    +
    +
    + + + +
    +
    +

    {{ session('error') }}

    +
    +
    +
    + @endif + + {{-- Validation Errors Summary --}} + @if ($errors->any()) +
    +
    +
    + + + +
    +
    +

    Please fix the following errors:

    +
      + @foreach ($errors->all() as $error) +
    • {{ $error }}
    • + @endforeach +
    +
    +
    +
    + @endif + +
    + @csrf + + {{-- Plugin Type (only show selector when paid plugins are enabled) --}} + @feature(App\Features\AllowPaidPlugins::class) +
    +

    Plugin Type

    +

    + Is your plugin free or paid? +

    + +
    + {{-- Free Option --}} + + + {{-- Paid Option --}} + +
    + + @error('type') +

    {{ $message }}

    + @enderror +
    + @else + {{-- When paid plugins are disabled, always submit as free --}} + + @endfeature + + {{-- Free Plugin: Repository URL --}} +
    +

    Repository

    +

    + Enter your public GitHub repository URL. +

    + +
    + +
    + +
    + @error('repository_url') +

    {{ $message }}

    + @else +

    + We'll fetch your README and plugin details from this repository. +

    + @enderror +
    +
    + + {{-- Paid Plugin Settings --}} + @feature(App\Features\AllowPaidPlugins::class) +
    + {{-- GitHub Connection Required --}} + @if (!auth()->user()->github_id) +
    +
    +
    + + + +
    +
    +

    + GitHub Connection Required +

    +
    +

    + To sell a paid plugin, you need to connect your GitHub account so we can access your repository. +

    + + + + + Connect GitHub Account + +
    +
    +
    +
    + @else + {{-- GitHub Connected - Show Repo Selector --}} +
    +
    +
    + + + + + Connected as {{ auth()->user()->github_username }} + +
    +
    + +

    Select Repository

    +

    + Choose the repository containing your plugin. +

    + +
    + +
    + + + {{-- Hidden input to submit the actual repository URL --}} + +
    + @error('repository_url') +

    {{ $message }}

    + @enderror +
    +
    + + {{-- Pricing --}} +
    +

    Pricing

    +

    + Set the price for your plugin. You'll receive 70% of each sale. +

    + +
    + +
    +
    + $ +
    + +
    + USD +
    +
    + @error('price') +

    {{ $message }}

    + @else +

    + Whole dollars only, minimum $10. This is a one-time purchase price. Customers get lifetime access to updates. +

    + @enderror +
    + +
    +

    + Revenue split: You receive 70%, NativePHP retains 30% for hosting, payment processing, and platform maintenance. +

    +
    +
    + + {{-- How it works --}} +
    +

    How paid plugins work

    +
      +
    • + + + + We pull your code from GitHub when you tag a release +
    • +
    • + + + + We host and distribute your plugin via plugins.nativephp.com +
    • +
    • + + + + Customers install via Composer with their license key +
    • +
    • + + + + You get paid automatically via Stripe Connect +
    • +
    +
    + @endif +
    + @endfeature + + {{-- Submit Button --}} +
    + + Cancel + + +
    +
    +
    +
    +
    diff --git a/resources/views/customer/plugins/index.blade.php b/resources/views/customer/plugins/index.blade.php new file mode 100644 index 00000000..619ddd0d --- /dev/null +++ b/resources/views/customer/plugins/index.blade.php @@ -0,0 +1,337 @@ + +
    + {{-- Header --}} +
    +
    +
    +
    + + + + + Dashboard + +

    Plugins

    +

    + Extend NativePHP Mobile with powerful native features +

    +
    +
    +
    +
    + + {{-- Content --}} +
    + {{-- Action Cards --}} +
    + {{-- Submit Plugin Card (Most Prominent) --}} +
    +
    +
    +
    + + + +
    +

    Submit Your Plugin

    +

    + Built a plugin? Submit it to the NativePHP Plugin Directory and share it with the community. +

    + + Submit a Plugin + + + + +
    +
    + + {{-- Browse Plugins Card --}} +
    +
    + +
    +

    Browse Plugins

    +

    + Discover plugins built by the community to add native features to your mobile apps. +

    + + View Directory + + + + +
    + + {{-- Learn to Build Card --}} +
    +
    + + + +
    +

    Learn to Build Plugins

    +

    + Read the documentation to learn how to create your own NativePHP Mobile plugins. +

    + + Read the Docs + + + + +
    +
    + + {{-- Author Display Name Section --}} +
    +
    +
    +
    +
    + + + +
    +
    +

    Author Display Name

    +

    + This is how your name will appear on your plugins in the directory. +

    +
    + @csrf + @method('PATCH') +
    + + @error('display_name') +

    {{ $message }}

    + @enderror +
    + +
    +

    + Leave blank to use your account name: {{ auth()->user()->name }} +

    +
    +
    +
    +
    +
    + + {{-- Stripe Connect Section (only show when paid plugins are enabled) --}} + @feature(App\Features\AllowPaidPlugins::class) +
    + @if ($developerAccount && $developerAccount->hasCompletedOnboarding()) + {{-- Connected Account --}} +
    +
    +
    +
    + + + +
    +
    +

    Stripe Connect Active

    +

    + Your developer account is set up and ready to receive payouts for paid plugin sales. +

    +
    + + + + + Payouts {{ $developerAccount->payouts_enabled ? 'Enabled' : 'Pending' }} + + + + + + {{ $developerAccount->stripe_connect_status->label() }} + +
    +
    +
    + + View Dashboard + + + + +
    +
    + @elseif ($developerAccount) + {{-- Onboarding In Progress --}} +
    +
    +
    +
    + + + +
    +
    +

    Complete Your Stripe Setup

    +

    + You've started the Stripe Connect setup but there are still some steps remaining. Complete the onboarding to start receiving payouts. +

    +
    +
    + + Continue Setup + + + + +
    +
    + @else + {{-- No Developer Account --}} +
    +
    +
    +
    + + + +
    +
    +

    Sell Paid Plugins

    +

    + Want to sell premium plugins? Connect your Stripe account to receive payouts when customers purchase your paid plugins. You'll earn 70% of each sale. +

    +
    +
    + + Connect Stripe + + + + +
    +
    + @endif +
    + @endfeature + + {{-- Success Message --}} + @if (session('success')) +
    +
    +
    + + + +
    +
    +

    {{ session('success') }}

    +
    +
    +
    + @endif + + {{-- Submitted Plugins List --}} + @if ($plugins->count() > 0) +
    +

    Your Submitted Plugins

    +

    Track the status of your plugin submissions.

    + +
    +
      + @foreach ($plugins as $plugin) +
    • +
      +
      +
      + @if ($plugin->isPending()) +
      + @elseif ($plugin->isApproved()) +
      + @else +
      + @endif +
      +
      +

      + {{ $plugin->name }} +

      +

      + {{ $plugin->type->label() }} plugin • Submitted {{ $plugin->created_at->diffForHumans() }} +

      +
      +
      +
      + @if ($plugin->isPending()) + + Pending Review + + @elseif ($plugin->isApproved()) + + Approved + + @else + + Rejected + + @endif + + Edit + + + + +
      +
      + + {{-- Rejection Reason --}} + @if ($plugin->isRejected() && $plugin->rejection_reason) +
      +
      +
      + + + +
      +
      +

      Rejection Reason

      +

      {{ $plugin->rejection_reason }}

      +
      +
      + @csrf + +
      +
      +
      +
      +
      + @endif +
    • + @endforeach +
    +
    +
    + @endif +
    +
    +
    diff --git a/resources/views/customer/plugins/show.blade.php b/resources/views/customer/plugins/show.blade.php new file mode 100644 index 00000000..e2475dac --- /dev/null +++ b/resources/views/customer/plugins/show.blade.php @@ -0,0 +1,304 @@ + +
    + {{-- Header --}} +
    +
    +
    + + + + + Plugins + +

    Edit Plugin

    +

    + {{ $plugin->name }} +

    +
    +
    +
    + + {{-- Content --}} +
    + {{-- Success Message --}} + @if (session('success')) +
    +
    +
    + + + +
    +
    +

    {{ session('success') }}

    +
    +
    +
    + @endif + + {{-- Plugin Status --}} +
    +
    +
    + @if ($plugin->hasLogo()) + {{ $plugin->name }} logo + @else +
    + +
    + @endif +
    +

    {{ $plugin->name }}

    +

    + {{ $plugin->type->label() }} plugin + @if ($plugin->latest_version) + • + v{{ $plugin->latest_version }} + @endif +

    +
    +
    + @if ($plugin->isPending()) + + Pending Review + + @elseif ($plugin->isApproved()) + + Approved + + @else + + Rejected + + @endif +
    +
    + + {{-- Plugin Logo --}} +
    +

    Plugin Logo

    +

    + Upload a logo for your plugin. This will be displayed in the plugin directory. +

    + +
    + @if ($plugin->hasLogo()) +
    + {{ $plugin->name }} logo +
    +
    + @csrf + + +
    +
    + @csrf + @method('DELETE') + +
    +
    +
    + @else +
    + @csrf + + +
    + @endif + @error('logo') +

    {{ $message }}

    + @enderror +

    + PNG, JPG, SVG, or WebP. Max 1MB. Recommended: 256x256 pixels, square. +

    +
    +
    + + {{-- Webhook Setup --}} + @if ($plugin->webhook_secret) +
    +

    + + + + GitHub Webhook Setup +

    +

    + Add a webhook to your GitHub repository to automatically sync your plugin data when you push changes. +

    + +
    +
    + +
    + {{ $plugin->getWebhookUrl() }} + +
    +
    + +
    +

    Setup Instructions

    +
      +
    1. Go to your repository's Settings → Webhooks
    2. +
    3. Click Add webhook
    4. +
    5. Paste the Webhook URL above into the Payload URL field
    6. +
    7. Set Content type to application/json
    8. +
    9. Under "Which events would you like to trigger this webhook?", select Let me select individual events
    10. +
    11. Check Pushes and Releases
    12. +
    13. Click Add webhook
    14. +
    +
    +
    +
    + @endif + + {{-- Pricing (Paid plugins only) --}} + @if ($plugin->isPaid()) +
    +

    Pricing

    +

    + Set the price for your plugin. Minimum price is $10. +

    + +
    + @csrf + @method('PATCH') + +
    + +
    +
    + $ +
    + +
    + USD +
    +
    + @error('price') +

    {{ $message }}

    + @enderror +

    + Whole dollars only, no cents. You will receive 70% of each sale after payment processing fees. +

    +
    + +
    + +
    +
    +
    + @endif + + {{-- Description Form --}} +
    +

    Plugin Description

    +

    + Describe what your plugin does. This will be displayed in the plugin directory. +

    + +
    + @csrf + @method('PATCH') + +
    + + + @error('description') +

    {{ $message }}

    + @enderror +

    + Maximum 1000 characters +

    +
    + +
    + +
    +
    +
    + + {{-- Rejection Reason --}} + @if ($plugin->isRejected() && $plugin->rejection_reason) +
    +
    +
    + + + +
    +
    +

    Rejection Reason

    +

    {{ $plugin->rejection_reason }}

    +
    +
    + @csrf + +
    +
    +
    +
    +
    + @endif +
    +
    +
    diff --git a/resources/views/customer/showcase/create.blade.php b/resources/views/customer/showcase/create.blade.php index 559f8437..2ce74db0 100644 --- a/resources/views/customer/showcase/create.blade.php +++ b/resources/views/customer/showcase/create.blade.php @@ -4,24 +4,12 @@
    - {{-- Breadcrumb --}} - + + + + + Dashboard +

    Submit Your App to the Showcase

    diff --git a/resources/views/customer/showcase/edit.blade.php b/resources/views/customer/showcase/edit.blade.php index f4888849..7fef781f 100644 --- a/resources/views/customer/showcase/edit.blade.php +++ b/resources/views/customer/showcase/edit.blade.php @@ -4,24 +4,12 @@
    - {{-- Breadcrumb --}} - + + + + + Dashboard +
    diff --git a/resources/views/customer/showcase/index.blade.php b/resources/views/customer/showcase/index.blade.php index 58b9ce03..d7c4c028 100644 --- a/resources/views/customer/showcase/index.blade.php +++ b/resources/views/customer/showcase/index.blade.php @@ -5,23 +5,12 @@
    - + + + + + Dashboard +

    Your Showcase Submissions

    Submit your NativePHP apps to be featured on our showcase diff --git a/resources/views/customer/wall-of-love/create.blade.php b/resources/views/customer/wall-of-love/create.blade.php index d75fa7a8..378276d4 100644 --- a/resources/views/customer/wall-of-love/create.blade.php +++ b/resources/views/customer/wall-of-love/create.blade.php @@ -4,24 +4,12 @@

    - {{-- Breadcrumb --}} - + + + + + Dashboard +

    Join our Wall of Love! 💙

    diff --git a/resources/views/license/renewal-success.blade.php b/resources/views/license/renewal-success.blade.php index dd11d301..bb58940b 100644 --- a/resources/views/license/renewal-success.blade.php +++ b/resources/views/license/renewal-success.blade.php @@ -64,7 +64,7 @@
    - + View Your Licenses diff --git a/resources/views/livewire/claim-donation-license.blade.php b/resources/views/livewire/claim-donation-license.blade.php index 7adfcd49..5ef6789b 100644 --- a/resources/views/livewire/claim-donation-license.blade.php +++ b/resources/views/livewire/claim-donation-license.blade.php @@ -14,7 +14,7 @@ Your license is being generated and will be sent to your email shortly.

    diff --git a/resources/views/livewire/license-renewal-success.blade.php b/resources/views/livewire/license-renewal-success.blade.php index e5fa16ee..78485daa 100644 --- a/resources/views/livewire/license-renewal-success.blade.php +++ b/resources/views/livewire/license-renewal-success.blade.php @@ -145,7 +145,7 @@
    - + View Your Licenses diff --git a/resources/views/livewire/plugin-directory.blade.php b/resources/views/livewire/plugin-directory.blade.php new file mode 100644 index 00000000..af69ef88 --- /dev/null +++ b/resources/views/livewire/plugin-directory.blade.php @@ -0,0 +1,172 @@ + diff --git a/resources/views/livewire/wall-of-love-submission-form.blade.php b/resources/views/livewire/wall-of-love-submission-form.blade.php index a70d7ef0..4ac4d37e 100644 --- a/resources/views/livewire/wall-of-love-submission-form.blade.php +++ b/resources/views/livewire/wall-of-love-submission-form.blade.php @@ -48,7 +48,7 @@
    - + Cancel + +
    + @endif + +
    +

    + Plugin Details +

    + +
    + {{-- Type --}} +
    +
    Type
    +
    + @if ($plugin->isPaid()) + Paid + @else + Free + @endif +
    +
    + + {{-- Version --}} +
    +
    Version
    +
    + {{ $plugin->latest_version ?? '—' }} +
    +
    + + {{-- iOS Version --}} +
    +
    iOS
    +
    + {{ $plugin->ios_version ?? '—' }} +
    +
    + + {{-- Android Version --}} +
    +
    Android
    +
    + {{ $plugin->android_version ?? '—' }} +
    +
    + + {{-- Author --}} + + + {{-- License --}} +
    +
    License
    +
    + @if ($plugin->getLicense()) + + {{ $plugin->getLicense() }} + + + + + @else + — + @endif +
    +
    +
    + + {{-- Links (only for free plugins with repository) --}} + @if ($plugin->isFree() && $plugin->repository_url) + + @endif + + @if ($plugin->last_synced_at) +
    +

    + Last updated {{ $plugin->last_synced_at->diffForHumans() }} +

    +
    + @endif +
    + + {{-- Bundles containing this plugin --}} + @if ($bundles->isNotEmpty()) + + @endif + +
    + + diff --git a/resources/views/plugins.blade.php b/resources/views/plugins.blade.php new file mode 100644 index 00000000..791b0c89 --- /dev/null +++ b/resources/views/plugins.blade.php @@ -0,0 +1,700 @@ + +
    + {{-- Hero Section --}} +
    +
    + {{-- Icon --}} +
    +
    + +
    +
    + + {{-- Title --}} +

    + Mobile + + { + + Plugins + + } + Rock +

    + + {{-- Subtitle --}} +

    + Extend your NativePHP Mobile apps with powerful native features. + Install with Composer. Build anything for iOS and Android. +

    + + {{-- Call to Action Buttons --}} +
    + {{-- Primary CTA - Browse Plugins --}} + + + {{-- Secondary CTA - Documentation --}} + +
    +
    +
    + + {{-- Featured Plugins Section --}} + @if ($featuredPlugins->isNotEmpty()) +
    +
    +

    + Featured Plugins +

    +

    + Hand-picked plugins to supercharge your mobile apps. +

    + + {{-- Plugin Cards Grid --}} +
    + @forelse ($featuredPlugins as $plugin) + + @empty +
    + +

    + Featured plugins coming soon +

    +
    + + + @endforelse +
    +
    +
    + @endif + + {{-- Plugin Bundles Section --}} + @if ($bundles->isNotEmpty()) +
    +
    +

    + Plugin Bundles +

    +

    + Save money with curated plugin collections. +

    + + {{-- Bundle Cards Grid --}} +
    + @foreach ($bundles as $bundle) + + @endforeach +
    +
    +
    + @endif + + {{-- Latest Plugins Section --}} +
    +
    +

    + Latest Plugins +

    +

    + Freshly released plugins from our community. +

    + + {{-- Plugin Cards Grid --}} +
    + @forelse ($latestPlugins as $plugin) + + @empty +
    + +

    + New plugins coming soon +

    +
    + + + @endforelse +
    +
    +
    + + {{-- Benefits Section --}} +
    +
    +

    + Why Use Plugins? +

    +

    + Unlock native capabilities without leaving Laravel. +

    +
    + +
    + {{-- Card - Composer Install --}} + + + + + + + + One Command Install + + + Add native features with a single composer require. No Xcode or Android Studio knowledge required. + + + + {{-- Card - Build Anything --}} + + + + + + + + Build Anything + + + There's no limit to what plugins can do. Access any native API, sensor, or hardware feature on iOS and Android. + + + + {{-- Card - Auto-Registered --}} + + + + + + + + Auto-Registered + + + Plugins are automatically discovered and registered. Just enable them in your config and you're ready to go. + + + + {{-- Card - Platform Dependencies --}} + + + + + + + + Native Dependencies + + + Plugins can add Gradle dependencies, CocoaPods, and Swift Package Manager packages automatically. + + + + {{-- Card - Lifecycle Hooks --}} + + + + + + + + Build Lifecycle Hooks + + + Hook into critical moments in the build pipeline. Run custom logic before, during, or after builds. + + + + {{-- Card - Security --}} + + + + + + + + Security First + + + Security is our top priority. Plugins are sandboxed and permissions are explicit, keeping your users safe. + + +
    +
    + + {{-- For Plugin Authors Section --}} +
    +
    +

    + Build & Sell Your Own Plugins +

    + +

    + Know Swift or Kotlin? Create plugins for the NativePHP community and generate revenue from your expertise. +

    + +
    +
    +
    + + + +
    +
    +

    + Write Swift & Kotlin +

    +

    + Build the native code and PHP bridging layer. We handle the rest, mapping everything so it just works. +

    +
    +
    + +
    +
    + + + +
    +
    +

    + Full Laravel Power +

    +

    + Set permissions, create config files, publish views, and do everything a Laravel package can do. +

    +
    +
    + +
    +
    + + + +
    +
    +

    + Sell Your Plugins (Soon!) +

    +

    + Sell your plugins through our marketplace and earn money from your native development skills. +

    +
    +
    +
    + + +
    +
    + + {{-- Call to Action Section --}} +
    +
    +

    + Ready to Extend Your App? +

    + +

    + Discover plugins that add powerful native features to your NativePHP Mobile apps, or start building your own today. +

    + +
    + {{-- Primary CTA --}} + + + {{-- Secondary CTA --}} + +
    +
    +
    +
    +
    diff --git a/resources/views/plugins/purchase-success.blade.php b/resources/views/plugins/purchase-success.blade.php new file mode 100644 index 00000000..a1e98dc1 --- /dev/null +++ b/resources/views/plugins/purchase-success.blade.php @@ -0,0 +1,150 @@ + +
    +
    + {{-- Loading State --}} + + + {{-- Success State --}} + + + {{-- Timeout/Error State --}} + +
    +
    +
    diff --git a/resources/views/plugins/purchase.blade.php b/resources/views/plugins/purchase.blade.php new file mode 100644 index 00000000..a5c80f31 --- /dev/null +++ b/resources/views/plugins/purchase.blade.php @@ -0,0 +1,174 @@ + +
    +
    + {{-- Blurred circle - Decorative --}} + + + {{-- Back button --}} + + + {{-- Plugin icon and title --}} +
    +
    + +
    +
    +

    + Purchase {{ $plugin->name }} +

    + @if ($plugin->description) +

    + {{ $plugin->description }} +

    + @endif +
    +
    +
    + + + + {{-- Session Messages --}} + @if (session('error')) +
    +

    {{ session('error') }}

    +
    + @endif + + {{-- Purchase Card --}} +
    +

    Order Summary

    + +
    + {{-- Plugin Info --}} +
    +
    +

    {{ $plugin->name }}

    +

    Lifetime access

    +
    +
    + @if ($discountPercent > 0) +

    + ${{ number_format($originalAmount / 100, 2) }} +

    + @endif +

    + @if ($discountPercent === 100) + Free + @else + ${{ number_format($discountedAmount / 100, 2) }} + @endif +

    +
    +
    + + {{-- Discount Badge --}} + @if ($discountPercent > 0 && $discountPercent < 100) +
    +
    + + + +

    + {{ $discountPercent }}% Early Adopter Discount Applied +

    +
    +
    + @elseif ($discountPercent === 100) +
    +
    + + + +

    + Included with your Early Adopter Benefits +

    +
    +
    + @endif + + {{-- Divider --}} +
    +
    +

    Total

    +

    + @if ($discountPercent === 100) + Free + @else + ${{ number_format($discountedAmount / 100, 2) }} + @endif +

    +
    +
    +
    + + {{-- Purchase Button --}} +
    + @if ($discountPercent === 100) +
    + @csrf + +
    + @else +
    + @csrf + +
    + @endif +
    + + {{-- Payment Info --}} +
    + + + + Secure payment via Stripe +
    +
    + + {{-- What You Get --}} +
    +

    What's Included

    +
      +
    • + + + + Lifetime access to plugin updates +
    • +
    • + + + + Install via Composer from plugins.nativephp.com +
    • +
    • + + + + Access to source code +
    • +
    +
    +
    +
    diff --git a/resources/views/pricing.blade.php b/resources/views/pricing.blade.php index 9e3ecf36..6863466d 100644 --- a/resources/views/pricing.blade.php +++ b/resources/views/pricing.blade.php @@ -739,6 +739,17 @@ class="mx-auto flex w-full max-w-2xl flex-col items-center gap-4 pt-10"

    + +

    + You will get direct access to our GitHub repository for NativePHP for Mobile. This will allow you + to raise issues directly with the team, which are prioritized higher than issues we see on Discord. +

    +

    + It also means you can try out features that are still in development before they're generally + available and help us to shape and refine them for release. +

    +
    + diff --git a/routes/api.php b/routes/api.php index 2b2ad3b3..cdce1d3e 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,6 +1,7 @@ group(function () { + Route::prefix('plugins')->name('api.plugins.')->group(function () { + Route::get('/access', [PluginAccessController::class, 'index'])->name('access'); + Route::get('/access/{vendor}/{package}', [PluginAccessController::class, 'checkAccess'])->name('access.check'); + }); + Route::post('/licenses', [LicenseController::class, 'store']); Route::get('/licenses/{key}', [LicenseController::class, 'show']); Route::get('/licenses', [LicenseController::class, 'index']); diff --git a/routes/web.php b/routes/web.php index ec50f34f..691bfe27 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,11 +1,19 @@ name('terms-of-service'); Route::view('partners', 'partners')->name('partners'); Route::view('build-my-app', 'build-my-app')->name('build-my-app'); + +// Public plugin directory routes +Route::middleware(EnsureFeaturesAreActive::using(ShowPlugins::class))->group(function () { + Route::get('plugins', [PluginDirectoryController::class, 'index'])->name('plugins'); + Route::get('plugins/directory', App\Livewire\PluginDirectory::class)->name('plugins.directory'); + Route::get('plugins/{plugin}', [PluginDirectoryController::class, 'show'])->name('plugins.show'); +}); + Route::view('sponsor', 'sponsoring')->name('sponsoring'); Route::view('vs-react-native-expo', 'vs-react-native-expo')->name('vs-react-native-expo'); Route::view('vs-flutter', 'vs-flutter')->name('vs-flutter'); @@ -146,18 +162,23 @@ Route::get('reset-password/{token}', [CustomerAuthController::class, 'showResetPassword'])->name('password.reset'); Route::post('reset-password', [CustomerAuthController::class, 'resetPassword'])->name('password.update'); + + Route::get('auth/github/login', [App\Http\Controllers\GitHubAuthController::class, 'redirect'])->name('login.github'); }); Route::post('logout', [CustomerAuthController::class, 'logout']) ->middleware(EnsureFeaturesAreActive::using(ShowAuthButtons::class)) ->name('customer.logout'); -// GitHub OAuth routes +// GitHub OAuth callback (no auth required - handles both login and linking) +Route::get('auth/github/callback', [App\Http\Controllers\GitHubIntegrationController::class, 'handleCallback'])->name('github.callback'); + +// GitHub OAuth routes (auth required) Route::middleware(['auth', EnsureFeaturesAreActive::using(ShowAuthButtons::class)])->group(function () { Route::get('auth/github', [App\Http\Controllers\GitHubIntegrationController::class, 'redirectToGitHub'])->name('github.redirect'); - Route::get('auth/github/callback', [App\Http\Controllers\GitHubIntegrationController::class, 'handleCallback'])->name('github.callback'); Route::post('customer/github/request-access', [App\Http\Controllers\GitHubIntegrationController::class, 'requestRepoAccess'])->name('github.request-access'); Route::delete('customer/github/disconnect', [App\Http\Controllers\GitHubIntegrationController::class, 'disconnect'])->name('github.disconnect'); + Route::get('customer/github/repositories', [App\Http\Controllers\GitHubIntegrationController::class, 'repositories'])->name('github.repositories'); }); // Discord OAuth routes @@ -177,9 +198,15 @@ return response('Goodbye'); })->name('callback'); +// Dashboard route +Route::middleware(['auth', EnsureFeaturesAreActive::using(ShowAuthButtons::class)]) + ->get('dashboard', [CustomerLicenseController::class, 'index']) + ->name('dashboard'); + // Customer license management routes Route::middleware(['auth', EnsureFeaturesAreActive::using(ShowAuthButtons::class)])->prefix('customer')->name('customer.')->group(function () { - Route::get('licenses', [CustomerLicenseController::class, 'index'])->name('licenses'); + // Redirect old licenses URL to dashboard + Route::redirect('licenses', '/dashboard')->name('licenses'); Route::view('integrations', 'customer.integrations')->name('integrations'); Route::get('licenses/{licenseKey}', [CustomerLicenseController::class, 'show'])->name('licenses.show'); Route::patch('licenses/{licenseKey}', [CustomerLicenseController::class, 'update'])->name('licenses.update'); @@ -192,6 +219,20 @@ Route::get('showcase/create', [App\Http\Controllers\CustomerShowcaseController::class, 'create'])->name('showcase.create'); Route::get('showcase/{showcase}/edit', [App\Http\Controllers\CustomerShowcaseController::class, 'edit'])->name('showcase.edit'); + // Plugin management + Route::middleware(EnsureFeaturesAreActive::using(ShowPlugins::class))->group(function () { + Route::get('plugins', [CustomerPluginController::class, 'index'])->name('plugins.index'); + Route::get('plugins/submit', [CustomerPluginController::class, 'create'])->name('plugins.create'); + Route::post('plugins', [CustomerPluginController::class, 'store'])->name('plugins.store'); + Route::get('plugins/{plugin}', [CustomerPluginController::class, 'show'])->name('plugins.show'); + Route::patch('plugins/{plugin}', [CustomerPluginController::class, 'update'])->name('plugins.update'); + Route::post('plugins/{plugin}/resubmit', [CustomerPluginController::class, 'resubmit'])->name('plugins.resubmit'); + Route::post('plugins/{plugin}/logo', [CustomerPluginController::class, 'updateLogo'])->name('plugins.logo.update'); + Route::delete('plugins/{plugin}/logo', [CustomerPluginController::class, 'deleteLogo'])->name('plugins.logo.delete'); + Route::patch('plugins/{plugin}/price', [CustomerPluginController::class, 'updatePrice'])->name('plugins.price.update'); + Route::patch('plugins/display-name', [CustomerPluginController::class, 'updateDisplayName'])->name('plugins.display-name'); + }); + // Billing portal Route::get('billing-portal', function (Illuminate\Http\Request $request) { $user = $request->user(); @@ -201,7 +242,7 @@ $user->createAsStripeCustomer(); } - return $user->redirectToBillingPortal(route('customer.licenses')); + return $user->redirectToBillingPortal(route('dashboard')); })->name('billing-portal'); // Sub-license management routes @@ -213,3 +254,44 @@ }); Route::get('.well-known/assetlinks.json', [ApplinksController::class, 'assetLinks']); + +Route::post('webhooks/plugins/{secret}', PluginWebhookController::class)->name('webhooks.plugins'); + +// Plugin purchase routes +Route::middleware(['auth', EnsureFeaturesAreActive::using(ShowAuthButtons::class), EnsureFeaturesAreActive::using(ShowPlugins::class)])->group(function () { + Route::get('plugins/{plugin}/purchase', [PluginPurchaseController::class, 'show'])->name('plugins.purchase.show'); + Route::post('plugins/{plugin}/purchase', [PluginPurchaseController::class, 'checkout'])->name('plugins.purchase.checkout'); + Route::get('plugins/{plugin}/purchase/success', [PluginPurchaseController::class, 'success'])->name('plugins.purchase.success'); + Route::get('plugins/{plugin}/purchase/status/{sessionId}', [PluginPurchaseController::class, 'status'])->name('plugins.purchase.status'); + Route::get('plugins/{plugin}/purchase/cancel', [PluginPurchaseController::class, 'cancel'])->name('plugins.purchase.cancel'); +}); + +// Bundle routes (public) +Route::middleware(EnsureFeaturesAreActive::using(ShowPlugins::class))->group(function () { + Route::get('bundles/{bundle:slug}', [BundleController::class, 'show'])->name('bundles.show'); +}); + +// Cart routes (public - allows guest cart) +Route::middleware(EnsureFeaturesAreActive::using(ShowPlugins::class))->group(function () { + Route::get('cart', [CartController::class, 'show'])->name('cart.show'); + Route::post('cart/add/{plugin}', [CartController::class, 'add'])->name('cart.add'); + Route::delete('cart/remove/{plugin}', [CartController::class, 'remove'])->name('cart.remove'); + Route::post('cart/bundle/{bundle:slug}', [CartController::class, 'addBundle'])->name('cart.bundle.add'); + Route::post('cart/bundle/{bundle:slug}/exchange', [CartController::class, 'exchangeForBundle'])->name('cart.bundle.exchange'); + Route::delete('cart/bundle/{bundle:slug}', [CartController::class, 'removeBundle'])->name('cart.bundle.remove'); + Route::delete('cart/clear', [CartController::class, 'clear'])->name('cart.clear'); + Route::get('cart/count', [CartController::class, 'count'])->name('cart.count'); + Route::post('cart/checkout', [CartController::class, 'checkout'])->name('cart.checkout'); + Route::get('cart/success', [CartController::class, 'success'])->name('cart.success')->middleware('auth'); + Route::get('cart/status/{sessionId}', [CartController::class, 'status'])->name('cart.status')->middleware('auth'); + Route::get('cart/cancel', [CartController::class, 'cancel'])->name('cart.cancel'); +}); + +// Developer onboarding routes +Route::middleware(['auth', EnsureFeaturesAreActive::using(ShowAuthButtons::class), EnsureFeaturesAreActive::using(ShowPlugins::class)])->prefix('customer/developer')->name('customer.developer.')->group(function () { + Route::get('onboarding', [DeveloperOnboardingController::class, 'show'])->name('onboarding'); + Route::post('onboarding/start', [DeveloperOnboardingController::class, 'start'])->name('onboarding.start'); + Route::get('onboarding/return', [DeveloperOnboardingController::class, 'return'])->name('onboarding.return'); + Route::get('onboarding/refresh', [DeveloperOnboardingController::class, 'refresh'])->name('onboarding.refresh'); + Route::get('dashboard', [DeveloperOnboardingController::class, 'dashboard'])->name('dashboard'); +}); diff --git a/tests/Feature/Api/PluginAccessTest.php b/tests/Feature/Api/PluginAccessTest.php new file mode 100644 index 00000000..cae1b7b8 --- /dev/null +++ b/tests/Feature/Api/PluginAccessTest.php @@ -0,0 +1,230 @@ +getJson('/api/plugins/access'); + + $response->assertStatus(401) + ->assertJson(['message' => 'Unauthorized']); + } + + public function test_returns_401_without_credentials(): void + { + $response = $this->withApiKey() + ->getJson('/api/plugins/access'); + + $response->assertStatus(401) + ->assertJson(['error' => 'Authentication required']); + } + + public function test_returns_401_with_invalid_credentials(): void + { + $response = $this->withApiKey() + ->asBasicAuth('invalid@example.com', 'invalid-key') + ->getJson('/api/plugins/access'); + + $response->assertStatus(401) + ->assertJson(['error' => 'Invalid credentials']); + } + + public function test_returns_accessible_plugins_with_valid_credentials(): void + { + $user = User::factory()->create([ + 'plugin_license_key' => 'test-license-key-123', + ]); + + // Create a free plugin + $freePlugin = Plugin::factory()->create([ + 'name' => 'vendor/free-plugin', + 'type' => PluginType::Free, + 'status' => PluginStatus::Approved, + 'is_active' => true, + ]); + + // Create a paid plugin + $paidPlugin = Plugin::factory()->create([ + 'name' => 'vendor/paid-plugin', + 'type' => PluginType::Paid, + 'status' => PluginStatus::Approved, + 'is_active' => true, + ]); + + // Give user a license for the paid plugin + PluginLicense::factory()->create([ + 'user_id' => $user->id, + 'plugin_id' => $paidPlugin->id, + 'expires_at' => null, // Never expires + ]); + + $response = $this->withApiKey() + ->asBasicAuth($user->email, 'test-license-key-123') + ->getJson('/api/plugins/access'); + + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'user' => ['email' => $user->email], + ]); + + $plugins = $response->json('plugins'); + $pluginNames = array_column($plugins, 'name'); + + // Only paid plugins with licenses are returned (not free plugins) + $this->assertNotContains('vendor/free-plugin', $pluginNames); + $this->assertContains('vendor/paid-plugin', $pluginNames); + } + + public function test_excludes_expired_plugin_licenses(): void + { + $user = User::factory()->create([ + 'plugin_license_key' => 'test-license-key-123', + ]); + + $paidPlugin = Plugin::factory()->create([ + 'name' => 'vendor/paid-plugin', + 'type' => PluginType::Paid, + 'status' => PluginStatus::Approved, + 'is_active' => true, + ]); + + // Create an expired license + PluginLicense::factory()->create([ + 'user_id' => $user->id, + 'plugin_id' => $paidPlugin->id, + 'expires_at' => now()->subDay(), + ]); + + $response = $this->withApiKey() + ->asBasicAuth($user->email, 'test-license-key-123') + ->getJson('/api/plugins/access'); + + $response->assertStatus(200); + + $plugins = $response->json('plugins'); + $pluginNames = array_column($plugins, 'name'); + + $this->assertNotContains('vendor/paid-plugin', $pluginNames); + } + + public function test_check_access_returns_true_for_licensed_plugin(): void + { + $user = User::factory()->create([ + 'plugin_license_key' => 'test-license-key-123', + ]); + + $paidPlugin = Plugin::factory()->create([ + 'name' => 'vendor/paid-plugin', + 'type' => PluginType::Paid, + 'status' => PluginStatus::Approved, + 'is_active' => true, + ]); + + PluginLicense::factory()->create([ + 'user_id' => $user->id, + 'plugin_id' => $paidPlugin->id, + ]); + + $response = $this->withApiKey() + ->asBasicAuth($user->email, 'test-license-key-123') + ->getJson('/api/plugins/access/vendor/paid-plugin'); + + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'package' => 'vendor/paid-plugin', + 'has_access' => true, + ]); + } + + public function test_check_access_returns_false_for_unlicensed_plugin(): void + { + $user = User::factory()->create([ + 'plugin_license_key' => 'test-license-key-123', + ]); + + Plugin::factory()->create([ + 'name' => 'vendor/paid-plugin', + 'type' => PluginType::Paid, + 'status' => PluginStatus::Approved, + 'is_active' => true, + ]); + + $response = $this->withApiKey() + ->asBasicAuth($user->email, 'test-license-key-123') + ->getJson('/api/plugins/access/vendor/paid-plugin'); + + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'package' => 'vendor/paid-plugin', + 'has_access' => false, + ]); + } + + public function test_check_access_returns_true_for_free_plugin(): void + { + $user = User::factory()->create([ + 'plugin_license_key' => 'test-license-key-123', + ]); + + Plugin::factory()->create([ + 'name' => 'vendor/free-plugin', + 'type' => PluginType::Free, + 'status' => PluginStatus::Approved, + 'is_active' => true, + ]); + + $response = $this->withApiKey() + ->asBasicAuth($user->email, 'test-license-key-123') + ->getJson('/api/plugins/access/vendor/free-plugin'); + + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'package' => 'vendor/free-plugin', + 'has_access' => true, + ]); + } + + public function test_check_access_returns_404_for_nonexistent_plugin(): void + { + $user = User::factory()->create([ + 'plugin_license_key' => 'test-license-key-123', + ]); + + $response = $this->withApiKey() + ->asBasicAuth($user->email, 'test-license-key-123') + ->getJson('/api/plugins/access/vendor/nonexistent'); + + $response->assertStatus(404) + ->assertJson(['error' => 'Plugin not found']); + } + + protected function asBasicAuth(string $username, string $password): static + { + return $this->withHeaders([ + 'Authorization' => 'Basic '.base64_encode("{$username}:{$password}"), + ]); + } + + protected function withApiKey(): static + { + return $this->withHeaders([ + 'X-API-Key' => config('services.bifrost.api_key'), + ]); + } +} diff --git a/tests/Feature/CustomerAuthenticationTest.php b/tests/Feature/CustomerAuthenticationTest.php index d849388e..2673937c 100644 --- a/tests/Feature/CustomerAuthenticationTest.php +++ b/tests/Feature/CustomerAuthenticationTest.php @@ -42,7 +42,7 @@ public function test_customer_can_login_with_valid_credentials(): void 'password' => 'password', ]); - $response->assertRedirect('/customer/licenses'); + $response->assertRedirect('/dashboard'); $this->assertAuthenticatedAs($user); } @@ -86,12 +86,12 @@ public function test_authenticated_customer_is_redirected_from_login_page(): voi $response = $this->actingAs($user)->get('/login'); - $response->assertRedirect('/customer/licenses'); + $response->assertRedirect('/dashboard'); } public function test_unauthenticated_customer_is_redirected_to_login(): void { - $response = $this->get('/customer/licenses'); + $response = $this->get('/dashboard'); $response->assertRedirect('/login'); } diff --git a/tests/Feature/CustomerLicenseManagementTest.php b/tests/Feature/CustomerLicenseManagementTest.php index ba266744..e245255f 100644 --- a/tests/Feature/CustomerLicenseManagementTest.php +++ b/tests/Feature/CustomerLicenseManagementTest.php @@ -25,7 +25,7 @@ public function test_customer_can_view_licenses_page(): void { $user = User::factory()->create(); - $response = $this->actingAs($user)->get('/customer/licenses'); + $response = $this->actingAs($user)->get('/dashboard'); $response->assertStatus(200); $response->assertSee('Your Licenses'); @@ -36,7 +36,7 @@ public function test_customer_sees_no_licenses_message_when_no_licenses_exist(): { $user = User::factory()->create(); - $response = $this->actingAs($user)->get('/customer/licenses'); + $response = $this->actingAs($user)->get('/dashboard'); $response->assertStatus(200); $response->assertSee('No licenses found'); @@ -57,7 +57,7 @@ public function test_customer_can_view_their_licenses(): void 'key' => 'test-key-2', ]); - $response = $this->actingAs($user)->get('/customer/licenses'); + $response = $this->actingAs($user)->get('/dashboard'); $response->assertStatus(200); $response->assertSee('Standard License'); @@ -80,7 +80,7 @@ public function test_customer_cannot_view_other_customers_licenses(): void 'policy_name' => 'User 2 License', ]); - $response = $this->actingAs($user1)->get('/customer/licenses'); + $response = $this->actingAs($user1)->get('/dashboard'); $response->assertStatus(200); $response->assertSee('User 1 License'); @@ -145,7 +145,7 @@ public function test_license_status_displays_correctly(): void 'is_suspended' => true, ]); - $response = $this->actingAs($user)->get('/customer/licenses'); + $response = $this->actingAs($user)->get('/dashboard'); $response->assertStatus(200); $response->assertSee('Active'); @@ -249,7 +249,7 @@ public function test_license_names_display_on_index_page(): void 'name' => null, ]); - $response = $this->actingAs($user)->get('/customer/licenses'); + $response = $this->actingAs($user)->get('/dashboard'); $response->assertStatus(200); // Named license should show custom name prominently