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(' ')
+ : '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 .
+
+
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 @@
+
+
+
+
+ {{-- 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 --}}
+
+
+
+
Bundle Price
+
+ {{ $bundle->formatted_price }}
+
+ @if ($bundle->discount_percent > 0)
+
+
+ {{ $bundle->formatted_retail_value }}
+
+
+
+ Save {{ $bundle->formatted_savings }} ({{ $bundle->discount_percent }}% off)
+
+ @endif
+
One-time purchase
+
+
+
+ @auth
+ @if ($bundle->isOwnedBy(auth()->user()))
+
+
+
+
+ You own all plugins
+
+ @else
+
+ @endif
+ @else
+
+ Log in to Purchase
+
+ @endauth
+
+
+
+
+
+
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 --}}
+
+
+ {{-- 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())
+
+ @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).
+
+
+
+
+
+
+ @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())
+
+ @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() }}
+
+
+
+
+
+
+
+
+ @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())
+
+ @else
+
+
+
+ @endif
+
+
+ {{-- Plugin Details --}}
+
+
+
+
+
+ by {{ $item->plugin->user->display_name }}
+
+
+
+ {{ $item->getFormattedPrice() }}
+
+
+
+
+
+
+
+
+ @endif
+ @endforeach
+
+
+
+ {{-- Clear Cart --}}
+
+
+
+
+
+ {{-- Order Summary --}}
+
+
+
Order Summary
+
+
+
+
Subtotal ({{ $cart->itemCount() }} {{ Str::plural('item', $cart->itemCount()) }})
+ {{ $cart->getFormattedSubtotal() }}
+
+
+
+
+
+
Total
+ {{ $cart->getFormattedSubtotal() }}
+
+
+
+
+
+ @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 --}}
+
+
+
+
Processing Your Purchase...
+
+ Please wait while we confirm your payment and set up your licenses.
+
+
+ This usually takes just a few seconds.
+
+
+
+
+ {{-- Success State --}}
+
+
+ {{-- Success Icon --}}
+
+
+
Thank You for Your Purchase!
+
+ Your payment was successful. You now have access to the following plugins:
+
+
+ {{-- Purchased Plugins --}}
+
+
+ {{-- Next Steps --}}
+
+
What's Next?
+
+
+
+
+
+ Configure your Composer credentials in your project
+
+
+
+
+
+ Run composer require [package-name]
+
+
+
+
+
+ Follow the plugin's installation instructions
+
+
+
+
+ {{-- Actions --}}
+
+
+
+
+ {{-- Timeout/Error State --}}
+
+
+
+
Taking Longer Than Expected
+
+ Your payment is being processed, but it's taking longer than usual. Don't worry - your purchase is confirmed.
+
+
+ Please check your dashboard in a few minutes, or contact support if you don't see your plugins.
+
+
+
+
+
+
+
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())
+
+ @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)
- {{-- Content --}}
-
- {{-- Flash Messages --}}
- @if(session()->has('success'))
-
-
-
-
-
-
{{ session('success') }}
+ {{-- Purchased Plugins --}}
+ @feature(App\Features\ShowPlugins::class)
+ @if($pluginLicenses->count() > 0)
+
- @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.
+
+
+
+ Copy
+
+
+
+
+ {{ 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 @@
-
-
-
-
-
-
-
- Back to Licenses
-
-
-
-
- License Details
-
-
-
-
+
+
+
+
+ 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 --}}
+
+
+
+
+
+
+
+
+ Plugins
+
+
+
+
+
+
+
+
+ Submit Plugin
+
+
+
+
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
+
+
+
+
+
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 --}}
+
+
+ {{-- 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.
+
+
+
+ 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 }}
+
+
+
+
+ @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 --}}
+
+
+ {{-- Content --}}
+
+ {{-- Success Message --}}
+ @if (session('success'))
+
+
+
+
+
{{ session('success') }}
+
+
+
+ @endif
+
+ {{-- Plugin Status --}}
+
+
+
+ @if ($plugin->hasLogo())
+
+ @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())
+
+
+
+
+ @else
+
+ @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.
+
+
+
+
+
Webhook URL
+
+
{{ $plugin->getWebhookUrl() }}
+
+
+
+
+
+
+
+
+
+
Setup Instructions
+
+ Go to your repository's Settings → Webhooks
+ Click Add webhook
+ Paste the Webhook URL above into the Payload URL field
+ Set Content type to application/json
+ Under "Which events would you like to trigger this webhook?", select Let me select individual events
+ Check Pushes and Releases
+ Click Add webhook
+
+
+
+
+ @endif
+
+ {{-- Pricing (Paid plugins only) --}}
+ @if ($plugin->isPaid())
+
+
Pricing
+
+ Set the price for your plugin. Minimum price is $10.
+
+
+
+
+ @endif
+
+ {{-- Description Form --}}
+
+
Plugin Description
+
+ Describe what your plugin does. This will be displayed in the plugin directory.
+
+
+
+
+
+ {{-- Rejection Reason --}}
+ @if ($plugin->isRejected() && $plugin->rejection_reason)
+
+
+
+
+
Rejection Reason
+
{{ $plugin->rejection_reason }}
+
+
+ @csrf
+
+
+
+
+ Resubmit for Review
+
+
+
+
+
+
+ @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 --}}
-
-
-
-
-
-
-
- Back to Submissions
-
-
-
-
- Submit App
-
-
-
-
+
+
+
+
+ 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 --}}
-
-
-
-
-
-
-
- Back to Submissions
-
-
-
-
- Edit: {{ $showcase->title }}
-
-
-
-
+
+
+
+
+ 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 @@
-
-
-
-
-
-
-
- Back to Licenses
-
-
-
-
- App Showcase
-
-
-
-
+
+
+
+
+ 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 --}}
-
-
-
-
-
-
-
- Back to Licenses
-
-
-
-
- Wall of Love
-
-
-
-
+
+
+
+
+ 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 @@
-
+
Cancel
diff --git a/resources/views/plugin-show.blade.php b/resources/views/plugin-show.blade.php
new file mode 100644
index 00000000..934ba434
--- /dev/null
+++ b/resources/views/plugin-show.blade.php
@@ -0,0 +1,314 @@
+
+
+
+
+ {{-- Divider --}}
+
+
+
+ {{-- Main content - README --}}
+
+ @if ($plugin->readme_html)
+ {!! $plugin->readme_html !!}
+ @else
+
+
+ README not available yet.
+
+
+ @endif
+
+
+ {{-- Sidebar - Plugin details --}}
+
+
+
+
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
+
+
+
+
+
+ Featured plugins coming soon
+
+
+
+
+
+ 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
+
+
+
+
+
+ New plugins coming soon
+
+
+
+
+
+ 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 --}}
+
+
+
+
Processing Your Purchase...
+
+ Please wait while we confirm your payment and set up your license.
+
+
+ This usually takes just a few seconds.
+
+
+
+
+ {{-- Success State --}}
+
+
+ {{-- Success Icon --}}
+
+
+
Thank You for Your Purchase!
+
+ Your payment was successful. You now have access to {{ $plugin->name }} .
+
+
+ {{-- Plugin Card --}}
+
+
+
+ @if ($plugin->hasLogo())
+
+ @else
+
+
+
+ @endif
+
{{ $plugin->name }}
+
+
+ View Plugin
+
+
+
+
+ {{-- Next Steps --}}
+
+
What's Next?
+
+
+
+
+
+ Configure your Composer credentials in your project
+
+
+
+
+
+ @if ($plugin->package_name)
+ Run composer require {{ $plugin->package_name }}
+ @else
+ Run composer require [package-name]
+ @endif
+
+
+
+
+
+ Follow the plugin's installation instructions
+
+
+
+
+ {{-- Actions --}}
+
+
+
+
+ {{-- Timeout/Error State --}}
+
+
+
+
Taking Longer Than Expected
+
+ Your payment is being processed, but it's taking longer than usual. Don't worry - your purchase is confirmed.
+
+
+ Please check your dashboard in a few minutes, or contact support if you don't see your plugin.
+
+
+
+
+
+
+
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
+
+ Get Plugin for Free
+
+
+ @else
+
+ @csrf
+
+ Proceed to Checkout
+
+
+ @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